Skip to content

Commit d54cb0b

Browse files
Michael Bunsenclaude
authored andcommitted
feat(occurrence-stats): drop ORDER threshold; add coarsest_rank query param
Replaces hardcoded `lca >= TaxonRank.ORDER` agreement gate with two layers: - Always returned: `agreed_any_rank_*` — exact matches plus any non-null LCA at a real rank (UNKNOWN excluded). The upstream filter (e.g. a Lepidoptera include list) is what bounds the meaningful scope, not a hardcoded threshold in this function. - Optional `?agreement_coarsest_rank=FAMILY`: when supplied, response also includes `agreed_coarser_rank_*` (exact + LCAs at or below the threshold). The applied rank is echoed in `agreement_coarsest_rank`; null when absent. Also addresses CodeRabbit feedback on the existing branch: - Dedupe base queryset before counting (joins from default-filter chain can inflate Occurrence rows). - Bound `*_pct` FloatFields to [0.0, 1.0] in the serializer. Param validation: invalid rank → 400; UNKNOWN rejected as not meaningful. Tests cover any-rank fallback, threshold filtering, invalid + UNKNOWN rejection, and threshold echo. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3946b7e commit d54cb0b

4 files changed

Lines changed: 176 additions & 37 deletions

File tree

ami/main/api/serializers.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,31 +1754,70 @@ class TopIdentifiersResponseSerializer(serializers.Serializer):
17541754
class ModelAgreementSerializer(serializers.Serializer):
17551755
"""Verified / agreement rates over the filtered Occurrence set.
17561756
1757-
`agreed_exact_count` is a subset of `agreed_under_order_count` by
1758-
construction — an exact match implies an LCA at SPECIES, which is
1759-
deeper than ORDER. `*_pct` percentages are 0.0..1.0 (not 0..100).
1757+
`agreed_exact_count` is a subset of `agreed_any_rank_count` by
1758+
construction — an exact match implies the LCA is the taxon itself.
1759+
`*_pct` percentages are 0.0..1.0 (not 0..100).
17601760
17611761
Denominator note: `agreed_*_pct` divide by `verified_with_prediction_count`
17621762
(verified occurrences that *also* have a machine prediction), NOT by
17631763
`verified_count`. A verified occurrence with no machine prediction can't
17641764
agree or disagree — including it in the denominator would drag the rate
17651765
down without representing actual model disagreement. `no_prediction_count`
17661766
is surfaced so the consumer can see how many such occurrences exist.
1767+
1768+
Optional rank threshold: when the caller passes
1769+
`?agreement_coarsest_rank=FAMILY`, the response also includes
1770+
`agreed_coarser_rank_*` counting only LCAs at that rank or deeper. The
1771+
threshold rank is echoed in `agreement_coarsest_rank`. When the param is
1772+
absent, the coarser-rank fields are null and `agreement_coarsest_rank`
1773+
is null.
17671774
"""
17681775

17691776
project_id = serializers.IntegerField()
17701777
total_occurrences = serializers.IntegerField()
17711778
verified_count = serializers.IntegerField(help_text="Occurrences with at least one non-withdrawn identification.")
1772-
verified_pct = serializers.FloatField(help_text="verified_count / total_occurrences")
1779+
verified_pct = serializers.FloatField(
1780+
min_value=0.0,
1781+
max_value=1.0,
1782+
help_text="verified_count / total_occurrences",
1783+
)
17731784
verified_with_prediction_count = serializers.IntegerField(
17741785
help_text="Verified occurrences that also have a machine prediction (denominator for agreed_*_pct)."
17751786
)
17761787
no_prediction_count = serializers.IntegerField(
17771788
help_text="Verified occurrences with no machine prediction (excluded from agreement denominator)."
17781789
)
17791790
agreed_exact_count = serializers.IntegerField()
1780-
agreed_exact_pct = serializers.FloatField(help_text="agreed_exact_count / verified_with_prediction_count")
1781-
agreed_under_order_count = serializers.IntegerField()
1782-
agreed_under_order_pct = serializers.FloatField(
1783-
help_text="agreed_under_order_count / verified_with_prediction_count"
1791+
agreed_exact_pct = serializers.FloatField(
1792+
min_value=0.0,
1793+
max_value=1.0,
1794+
help_text="agreed_exact_count / verified_with_prediction_count",
1795+
)
1796+
agreed_any_rank_count = serializers.IntegerField(
1797+
help_text="Exact matches plus disagreements whose LCA is at any real rank (UNKNOWN excluded)."
1798+
)
1799+
agreed_any_rank_pct = serializers.FloatField(
1800+
min_value=0.0,
1801+
max_value=1.0,
1802+
help_text="agreed_any_rank_count / verified_with_prediction_count",
1803+
)
1804+
agreement_coarsest_rank = serializers.CharField(
1805+
allow_null=True,
1806+
required=False,
1807+
help_text="Threshold rank from ?agreement_coarsest_rank query param. Null when the param is absent.",
1808+
)
1809+
agreed_coarser_rank_count = serializers.IntegerField(
1810+
allow_null=True,
1811+
required=False,
1812+
help_text=(
1813+
"Exact matches plus disagreements whose LCA is at `agreement_coarsest_rank` or deeper. "
1814+
"Null when no threshold was supplied."
1815+
),
1816+
)
1817+
agreed_coarser_rank_pct = serializers.FloatField(
1818+
min_value=0.0,
1819+
max_value=1.0,
1820+
allow_null=True,
1821+
required=False,
1822+
help_text="agreed_coarser_rank_count / verified_with_prediction_count. Null when no threshold supplied.",
17841823
)

ami/main/api/views.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
Tag,
5757
TaxaList,
5858
Taxon,
59+
TaxonRank,
5960
User,
6061
update_detection_counts,
6162
)
@@ -1390,15 +1391,34 @@ def model_agreement(self, request):
13901391
Accepts every query param the `/occurrences/` list endpoint accepts.
13911392
Reuses `apply_default_filters` so `apply_defaults=false` bypasses
13921393
project default taxa lists + score thresholds.
1394+
1395+
Optional ?agreement_coarsest_rank=<RANK> adds `agreed_coarser_rank_*`
1396+
counts — LCAs at the given rank or deeper. Valid values: any
1397+
TaxonRank name (FAMILY, GENUS, etc.); invalid → 400.
13931398
"""
13941399
project = self.get_active_project()
13951400
assert project is not None # require_project=True guarantees this
13961401
if not Project.objects.visible_for_user(request.user).filter(pk=project.pk).exists():
13971402
raise NotFound("Project not found.")
13981403

1404+
coarsest_rank_param = request.query_params.get("agreement_coarsest_rank")
1405+
coarsest_rank = None
1406+
if coarsest_rank_param:
1407+
try:
1408+
coarsest_rank = TaxonRank[coarsest_rank_param.upper()]
1409+
except KeyError:
1410+
valid = ", ".join(r.name for r in TaxonRank if r.name != "UNKNOWN")
1411+
raise api_exceptions.ValidationError(
1412+
{"agreement_coarsest_rank": f"Invalid rank '{coarsest_rank_param}'. Must be one of: {valid}."}
1413+
)
1414+
if coarsest_rank == TaxonRank.UNKNOWN:
1415+
raise api_exceptions.ValidationError(
1416+
{"agreement_coarsest_rank": "UNKNOWN is not a valid threshold rank."}
1417+
)
1418+
13991419
base_qs = Occurrence.objects.filter(project=project).valid().apply_default_filters(project, request)
14001420
filtered_qs = self.filter_queryset(base_qs)
1401-
payload = model_agreement_for_project(filtered_qs)
1421+
payload = model_agreement_for_project(filtered_qs, coarsest_rank=coarsest_rank)
14021422
payload["project_id"] = project.pk
14031423
return Response(ModelAgreementSerializer(payload, context={"request": request}).data)
14041424

ami/main/models_future/occurrence.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ def detection_image_urls_from_prefetch(occurrence: Occurrence, limit: int | None
163163
return [get_media_url(det.path) for det in detections]
164164

165165

166-
def model_agreement_for_project(queryset: QuerySet[Occurrence]) -> dict:
166+
def model_agreement_for_project(
167+
queryset: QuerySet[Occurrence],
168+
coarsest_rank: TaxonRank | None = None,
169+
) -> dict:
167170
"""Verified / agreement stats over a pre-filtered Occurrence queryset.
168171
169172
The queryset MUST already be filtered to the project + user-supplied
@@ -174,9 +177,16 @@ def model_agreement_for_project(queryset: QuerySet[Occurrence]) -> dict:
174177
175178
"Verified" means the occurrence has at least one non-withdrawn
176179
Identification. "Model prediction" means the Classification chosen by
177-
BEST_MACHINE_PREDICTION_ORDER. "Under-order" agreement means the user's
178-
taxon and the model's prediction share an ancestor at rank >= ORDER
179-
(inclusive of ORDER itself).
180+
BEST_MACHINE_PREDICTION_ORDER. "Any-rank" agreement means the user's
181+
taxon and the model's prediction share an ancestor at any real rank
182+
(UNKNOWN excluded) — exact matches included. The upstream filter (e.g.
183+
a Lepidoptera include list) is what bounds the meaningful scope, not
184+
a hardcoded rank threshold in this function.
185+
186+
When ``coarsest_rank`` is supplied, additionally compute "coarser-rank"
187+
agreement: the LCA must be at ``coarsest_rank`` or deeper (e.g. passing
188+
FAMILY only counts LCAs at FAMILY, GENUS, or SPECIES). Exact matches
189+
always count regardless of rank.
180190
181191
Performance: the heavy work — correlated subqueries over Identification
182192
and Classification — is scoped to the verified set, which is typically
@@ -198,6 +208,10 @@ def model_agreement_for_project(queryset: QuerySet[Occurrence]) -> dict:
198208

199209
from ami.main.models import BEST_IDENTIFICATION_ORDER, Identification, Taxon
200210

211+
# Default filters can join Identification (verified_by_me) and Taxon
212+
# parents_json (taxa_list_id) which inflates row count if not deduped.
213+
# Dedupe up front so total + verified counts share one canonical set.
214+
queryset = queryset.distinct()
201215
total = queryset.count()
202216

203217
best_user_ident = Identification.objects.filter(occurrence=OuterRef("pk"), withdrawn=False).order_by(
@@ -244,32 +258,43 @@ def model_agreement_for_project(queryset: QuerySet[Occurrence]) -> dict:
244258
]
245259
taxa_by_id[t.pk] = (t.pk, t.rank, parents)
246260

247-
under_order_disagreement_count = 0
261+
any_rank_disagreement_count = 0
262+
coarser_rank_disagreement_count = 0
248263
for (u_id, m_id), count in pair_counts.items():
249264
u = taxa_by_id.get(u_id)
250265
m = taxa_by_id.get(m_id)
251266
if not u or not m:
252267
continue
253268
lca = lca_rank_between(u, m)
254-
if lca is not None and lca >= TaxonRank.ORDER:
255-
under_order_disagreement_count += count
269+
if lca is None:
270+
continue
271+
any_rank_disagreement_count += count
272+
if coarsest_rank is not None and lca >= coarsest_rank:
273+
coarser_rank_disagreement_count += count
256274

257-
agreed_under_order = agreed_exact + under_order_disagreement_count
275+
agreed_any_rank = agreed_exact + any_rank_disagreement_count
276+
agreed_coarser_rank = agreed_exact + coarser_rank_disagreement_count
258277

259278
def _pct(num: int, denom: int) -> float:
260279
return round(num / denom, 4) if denom else 0.0
261280

262-
return {
281+
payload: dict = {
263282
"total_occurrences": total,
264283
"verified_count": verified,
265284
"verified_pct": _pct(verified, total),
266285
"verified_with_prediction_count": verified_with_pred,
267286
"no_prediction_count": no_prediction,
268287
"agreed_exact_count": agreed_exact,
269288
"agreed_exact_pct": _pct(agreed_exact, verified_with_pred),
270-
"agreed_under_order_count": agreed_under_order,
271-
"agreed_under_order_pct": _pct(agreed_under_order, verified_with_pred),
289+
"agreed_any_rank_count": agreed_any_rank,
290+
"agreed_any_rank_pct": _pct(agreed_any_rank, verified_with_pred),
291+
"agreement_coarsest_rank": coarsest_rank.name if coarsest_rank is not None else None,
292+
"agreed_coarser_rank_count": agreed_coarser_rank if coarsest_rank is not None else None,
293+
"agreed_coarser_rank_pct": (
294+
_pct(agreed_coarser_rank, verified_with_pred) if coarsest_rank is not None else None
295+
),
272296
}
297+
return payload
273298

274299

275300
def top_identifiers_for_project(project: Project) -> QuerySet[User]:

ami/main/tests.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4803,8 +4803,9 @@ def test_unknown_rank_excluded_from_lca(self):
48034803
class TestModelAgreementForProject(APITestCase):
48044804
"""Aggregation function over a filtered Occurrence queryset.
48054805
4806-
Covers the four bucket transitions: unverified, verified+exact-agreed,
4807-
verified+under-order-agreed, verified+disagreed-above-order.
4806+
Covers four bucket transitions: unverified, verified+exact-agreed,
4807+
verified+any-rank-agreed (no threshold), verified+disagreed-no-shared-rank.
4808+
Optional coarsest_rank threshold cases handled in the viewset tests below.
48084809
"""
48094810

48104811
def setUp(self) -> None:
@@ -4842,7 +4843,11 @@ def test_empty_project_returns_zeros_not_nans(self):
48424843
self.assertEqual(result["verified_count"], 0)
48434844
self.assertEqual(result["verified_pct"], 0.0)
48444845
self.assertEqual(result["agreed_exact_pct"], 0.0)
4845-
self.assertEqual(result["agreed_under_order_pct"], 0.0)
4846+
self.assertEqual(result["agreed_any_rank_pct"], 0.0)
4847+
# No threshold passed → coarser-rank fields null.
4848+
self.assertIsNone(result["agreement_coarsest_rank"])
4849+
self.assertIsNone(result["agreed_coarser_rank_count"])
4850+
self.assertIsNone(result["agreed_coarser_rank_pct"])
48464851

48474852
def test_buckets_canonical_cases(self):
48484853
from ami.main.models_future.occurrence import model_agreement_for_project
@@ -4851,20 +4856,43 @@ def test_buckets_canonical_cases(self):
48514856
self.assertEqual(len(occurrences), 4)
48524857
# 0: verified, machine == user (exact agreement at SPECIES)
48534858
self._identify(occurrences[0], self.vanessa_atalanta)
4854-
# 1: verified, sister species (under-order at GENUS)
4859+
# 1: verified, sister species (LCA at GENUS)
48554860
self._identify(occurrences[1], self.vanessa_cardui)
4856-
# 2: verified, different family same order (under-order at ORDER)
4861+
# 2: verified, different family same order (LCA at ORDER)
48574862
self._identify(occurrences[2], self.pieris_brassicae)
48584863
# 3: unverified
48594864

48604865
result = model_agreement_for_project(Occurrence.objects.filter(project=self.project))
48614866
self.assertEqual(result["total_occurrences"], 4)
48624867
self.assertEqual(result["verified_count"], 3)
48634868
self.assertEqual(result["agreed_exact_count"], 1)
4864-
self.assertEqual(result["agreed_under_order_count"], 3)
4869+
self.assertEqual(result["agreed_any_rank_count"], 3)
48654870
self.assertAlmostEqual(result["verified_pct"], 0.75)
48664871
self.assertAlmostEqual(result["agreed_exact_pct"], 1 / 3, places=3)
4867-
self.assertAlmostEqual(result["agreed_under_order_pct"], 1.0)
4872+
self.assertAlmostEqual(result["agreed_any_rank_pct"], 1.0)
4873+
4874+
def test_coarsest_rank_threshold_filters_shallow_lcas(self):
4875+
"""With coarsest_rank=FAMILY, an ORDER-only LCA pair is excluded."""
4876+
from ami.main.models import TaxonRank
4877+
from ami.main.models_future.occurrence import model_agreement_for_project
4878+
4879+
occurrences = list(Occurrence.objects.filter(project=self.project).order_by("pk"))
4880+
# 0: exact (SPECIES) — counts in both
4881+
self._identify(occurrences[0], self.vanessa_atalanta)
4882+
# 1: sister species (LCA = GENUS, deeper than FAMILY) — counts in both
4883+
self._identify(occurrences[1], self.vanessa_cardui)
4884+
# 2: different family same order (LCA = ORDER, NOT >= FAMILY) — counts in any_rank only
4885+
self._identify(occurrences[2], self.pieris_brassicae)
4886+
4887+
result = model_agreement_for_project(
4888+
Occurrence.objects.filter(project=self.project),
4889+
coarsest_rank=TaxonRank.FAMILY,
4890+
)
4891+
self.assertEqual(result["agreed_any_rank_count"], 3)
4892+
self.assertEqual(result["agreement_coarsest_rank"], "FAMILY")
4893+
# exact + GENUS LCA = 2; ORDER LCA excluded
4894+
self.assertEqual(result["agreed_coarser_rank_count"], 2)
4895+
self.assertAlmostEqual(result["agreed_coarser_rank_pct"], 2 / 3, places=3)
48684896

48694897

48704898
class TestOccurrenceStatsViewSet(APITestCase):
@@ -4973,7 +5001,11 @@ def test_agreement_empty_returns_zero_pcts(self):
49735001
self.assertEqual(body["verified_count"], 0)
49745002
self.assertEqual(body["verified_pct"], 0.0)
49755003
self.assertEqual(body["agreed_exact_pct"], 0.0)
4976-
self.assertEqual(body["agreed_under_order_pct"], 0.0)
5004+
self.assertEqual(body["agreed_any_rank_pct"], 0.0)
5005+
# No ?agreement_coarsest_rank → threshold + coarser fields null.
5006+
self.assertIsNone(body["agreement_coarsest_rank"])
5007+
self.assertIsNone(body["agreed_coarser_rank_count"])
5008+
self.assertIsNone(body["agreed_coarser_rank_pct"])
49775009

49785010
def test_agreement_happy_path(self):
49795011
"""One verified occurrence; user agrees with the machine prediction → exact match.
@@ -4996,15 +5028,14 @@ def test_agreement_happy_path(self):
49965028
self.assertEqual(body["verified_with_prediction_count"], 1)
49975029
self.assertEqual(body["no_prediction_count"], 0)
49985030
self.assertEqual(body["agreed_exact_count"], 1)
4999-
self.assertEqual(body["agreed_under_order_count"], 1)
5031+
self.assertEqual(body["agreed_any_rank_count"], 1)
50005032

5001-
def test_agreement_under_order_bucket(self):
5002-
"""Disagreement at species but same genus → counted under-order, not exact.
5033+
def test_agreement_any_rank_bucket(self):
5034+
"""Disagreement at species but same genus → counted as any-rank agreement, not exact.
50035035
50045036
Pick the machine prediction's sister species (same parent genus) for the
5005-
identification. LCA between the two species is GENUS, which is >= ORDER,
5006-
so the occurrence falls into the under-order bucket without contributing
5007-
to agreed_exact_count.
5037+
identification. LCA between the two species is GENUS, so the occurrence
5038+
falls into the any-rank bucket without contributing to agreed_exact_count.
50085039
"""
50095040
occurrence = Occurrence.objects.filter(project=self.project).order_by("pk").first()
50105041
machine_taxon = occurrence.detections.first().classifications.first().taxon
@@ -5024,10 +5055,34 @@ def test_agreement_under_order_bucket(self):
50245055
self.assertEqual(body["verified_count"], 1)
50255056
self.assertEqual(body["verified_with_prediction_count"], 1)
50265057
self.assertEqual(body["agreed_exact_count"], 0)
5027-
self.assertEqual(body["agreed_under_order_count"], 1)
5028-
# 0/1 exact, 1/1 under-order
5058+
self.assertEqual(body["agreed_any_rank_count"], 1)
5059+
# 0/1 exact, 1/1 any-rank
50295060
self.assertEqual(body["agreed_exact_pct"], 0.0)
5030-
self.assertEqual(body["agreed_under_order_pct"], 1.0)
5061+
self.assertEqual(body["agreed_any_rank_pct"], 1.0)
5062+
5063+
def test_agreement_coarsest_rank_invalid_returns_400(self):
5064+
response = self.client.get(
5065+
f"{self.agreement_url}?project_id={self.project.pk}&agreement_coarsest_rank=GARBAGE"
5066+
)
5067+
self.assertEqual(response.status_code, 400)
5068+
self.assertIn("agreement_coarsest_rank", response.json())
5069+
5070+
def test_agreement_coarsest_rank_unknown_rejected(self):
5071+
"""UNKNOWN is a real enum member but not a meaningful threshold."""
5072+
response = self.client.get(
5073+
f"{self.agreement_url}?project_id={self.project.pk}&agreement_coarsest_rank=UNKNOWN"
5074+
)
5075+
self.assertEqual(response.status_code, 400)
5076+
5077+
def test_agreement_coarsest_rank_echoed_in_response(self):
5078+
response = self.client.get(f"{self.agreement_url}?project_id={self.project.pk}&agreement_coarsest_rank=family")
5079+
self.assertEqual(response.status_code, 200)
5080+
body = response.json()
5081+
# Param is case-insensitive; response echoes enum name (uppercase).
5082+
self.assertEqual(body["agreement_coarsest_rank"], "FAMILY")
5083+
# No verified occurrences in this fixture → coarser fields present but zero.
5084+
self.assertEqual(body["agreed_coarser_rank_count"], 0)
5085+
self.assertEqual(body["agreed_coarser_rank_pct"], 0.0)
50315086

50325087
def test_agreement_filter_passthrough(self):
50335088
"""`?deployment=` should narrow the set."""

0 commit comments

Comments
 (0)