Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# Local planning notes (not part of the shipped code)
PLAN.md
14 changes: 14 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,20 @@ services:
volumes:
- ./logs:/app/logs
- ./.env:/app/.env
filter_edges_by_statistical_significance:
container_name: filter_edges_by_statistical_significance
build:
context: .
dockerfile: workers/filter_edges_by_statistical_significance/Dockerfile
restart: unless-stopped
depends_on:
shepherd_db:
condition: service_healthy
shepherd_broker:
condition: service_healthy
volumes:
- ./logs:/app/logs
- ./.env:/app/.env
finish_query:
container_name: finish_query
build:
Expand Down
50 changes: 50 additions & 0 deletions shepherd_utils/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,56 @@ def merge_kgraph(og_message, new_message, source, logger: logging.Logger):
return og_message


def filter_edges_by_statistical_significance(message, minimum_significance, logger):
"""Remove KG edges whose significance band is below `minimum_significance`.

Qualifier-less edges are kept. Analyses/results that can no longer satisfy the
query graph are pruned so the TRAPI stays valid; downstream filter_kgraph_orphans
removes orphaned nodes. (shepherd#134)
"""
from shepherd_utils.statistical_significance_qualifier import (
SIGNIFICANCE_ORDINAL,
get_statistical_significance,
)

threshold = SIGNIFICANCE_ORDINAL.get(minimum_significance)
if threshold is None:
logger.error(f"Unknown minimum_significance '{minimum_significance}'")
return
msg = message.get("message", {})
kg_edges = msg.get("knowledge_graph", {}).get("edges", {})

# 1. Edges explicitly below threshold (qualifier-less kept).
remove = {
eid
for eid, edge in kg_edges.items()
if (band := get_statistical_significance(edge)) is not None
and (ordinal := SIGNIFICANCE_ORDINAL.get(band)) is not None
and ordinal < threshold
}
if not remove:
return

# 2. Prune analyses/results that lose a required edge binding.
for result in msg.get("results", []):
for analysis in result.get("analyses", []):
for qedge_key, bindings in analysis.get("edge_bindings", {}).items():
analysis["edge_bindings"][qedge_key] = [
b for b in bindings if b.get("id") not in remove
]
result["analyses"] = [
a
for a in result.get("analyses", [])
if a.get("edge_bindings") and all(a["edge_bindings"].values())
]
msg["results"] = [r for r in msg.get("results", []) if r.get("analyses")]

# 3. Drop the edges from the KG in place.
for eid in remove:
del kg_edges[eid]
logger.info(f"Removed {len(remove)} edges below '{minimum_significance}'")


def filter_kgraph_orphans(message, logger: logging.Logger):
"""Given a result-pruned message, filter out orphaned kgraph nodes and edges."""
try:
Expand Down
56 changes: 56 additions & 0 deletions shepherd_utils/statistical_significance_qualifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Shared helpers for biolink:statistical_significance_qualifier (shepherd#134).

The qualifier (enum StatisticalSignificanceQualifierEnum) is_a statement_qualifier,
a descendant of `qualifier` in biolink-model, so BMT/Retriever route it into TRAPI
edge.qualifiers[]. get_statistical_significance() therefore reads edge['qualifiers']
ONLY. (ARAX is likewise qualifier-only as of RTXteam/RTX#2859 — its defensive
edge.attributes lookup was removed; add a fallback here only if a KP is found to
send the qualifier as an attribute.) Shared by aragorn_score + arax_rank (ranking)
and the filter_edges_by_statistical_significance worker (filtering).
"""

from typing import Any, Dict, Optional

SIGNIFICANCE_QUALIFIER_TYPE_ID = "biolink:statistical_significance_qualifier"

# Conservative ranking scores per band. TODO: revisit once all KGs populate the
# qualifier (rollout asymmetry: qualifier-bearing edges scored against edges that
# lack it entirely). Mirrors the RTX ARAX_ranker change (RTXteam/RTX#2858).
SIGNIFICANCE_BAND_SCORES: Dict[str, float] = {
"very_strongly_significant": 0.70,
"strongly_significant": 0.55,
"significant": 0.40,
"suggestive": 0.15,
"not_significant": 0.0,
}

# Ordinal ranking for filtering (remove edges below a threshold).
SIGNIFICANCE_ORDINAL: Dict[str, int] = {
"very_strongly_significant": 4,
"strongly_significant": 3,
"significant": 2,
"suggestive": 1,
"not_significant": 0,
}

# Source-agnostic trust weight applied to the band score in ranking (conservative;
# matches RTX trust=0.5). NOT routed through aragorn's per-source get_source_weight.
SIGNIFICANCE_SOURCE_WEIGHT: float = 0.5


def _strip_biolink(value: Any) -> Optional[str]:
if isinstance(value, str) and value.startswith("biolink:"):
return value[len("biolink:"):]
return value


def get_statistical_significance(edge: Dict[str, Any]) -> Optional[str]:
"""Return the bare significance band for a dict-based TRAPI edge, or None.

Reads edge['qualifiers'] only (it is a biolink qualifier; BMT/Retriever route it
there). Strips any biolink: prefix from the value.
"""
for q in edge.get("qualifiers") or []:
if q.get("qualifier_type_id") == SIGNIFICANCE_QUALIFIER_TYPE_ID:
return _strip_biolink(q.get("qualifier_value"))
return None
1 change: 1 addition & 0 deletions tests/unit/aragorn/test_aragorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async def test_aragorn_entrypoint(redis_mock, mocker):
"aragorn.lookup",
"aragorn.omnicorp",
"aragorn.score",
"filter_edges_by_statistical_significance",
"sort_results_score",
"filter_results_top_n",
"filter_kgraph_orphans",
Expand Down
1 change: 1 addition & 0 deletions tests/unit/aragorn/test_aragorn_examine_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ async def test_aragorn_lookup_only_workflow(redis_mock, mocker):
"aragorn.lookup",
"aragorn.omnicorp",
"aragorn.score",
"filter_edges_by_statistical_significance",
"sort_results_score",
"filter_results_top_n",
"filter_kgraph_orphans",
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/aragorn/test_aragorn_score_ranker.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,3 +673,85 @@ def test_score_jaccard_like_returns_score_over_one_minus_score():
assert scored["analyses"][0]["score"] == pytest.approx(
raw_score / (1 - raw_score)
)


# --- statistical significance qualifier (shepherd#134) --------------------


def test_get_edge_values_extracts_significance_qualifier():
"""A qualifier-bearing edge gets a statistical_significance property."""
edge = {
"subject": "A",
"object": "B",
"predicate": "biolink:related_to",
"sources": [
{"resource_id": "infores:test", "resource_role": "primary_knowledge_source"}
],
"qualifiers": [
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "very_strongly_significant",
}
],
}
r = Ranker(_make_msg_with_edge(edge), logger)
vals = r.get_edge_values("e1")
assert "statistical_significance" in vals["infores:test"]
prop = vals["infores:test"]["statistical_significance"]
assert prop["value"] == "very_strongly_significant"
assert prop["weight"] > 0
assert prop["weight"] == pytest.approx(0.70 * 0.5)


def test_get_edge_values_significance_strips_biolink_prefix():
"""biolink:-prefixed qualifier values are stripped."""
edge = {
"subject": "A",
"object": "B",
"sources": [
{"resource_id": "infores:test", "resource_role": "primary_knowledge_source"}
],
"qualifiers": [
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "biolink:significant",
}
],
}
r = Ranker(_make_msg_with_edge(edge), logger)
vals = r.get_edge_values("e1")
assert vals["infores:test"]["statistical_significance"]["value"] == "significant"


def test_not_significant_contributes_nothing():
"""Band score 0 -> property omitted -> no admittance contribution (no penalty)."""
edge = {
"subject": "A",
"object": "B",
"sources": [
{"resource_id": "infores:test", "resource_role": "primary_knowledge_source"}
],
"qualifiers": [
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "not_significant",
}
],
}
r = Ranker(_make_msg_with_edge(edge), logger)
vals = r.get_edge_values("e1")
assert "statistical_significance" not in vals["infores:test"]


def test_qualifierless_edge_has_no_significance_property():
"""Edges without the qualifier get no statistical_significance property."""
edge = {
"subject": "A",
"object": "B",
"sources": [
{"resource_id": "infores:test", "resource_role": "primary_knowledge_source"}
],
}
r = Ranker(_make_msg_with_edge(edge), logger)
vals = r.get_edge_values("e1")
assert "statistical_significance" not in vals["infores:test"]
134 changes: 134 additions & 0 deletions tests/unit/test_arax_rank_ranker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Tests for statistical significance qualifier scoring in arax_rank (shepherd#134)."""

import logging

from workers.arax_rank.ranker import ARAXRanker

logger = logging.getLogger(__name__)


def _edge(**kw):
return {"subject": "A", "object": "B", "predicate": "biolink:related_to", **kw}


def test_significance_additive_boost():
"""A qualifier-bearing edge scores >= the same edge without the qualifier."""
ranker = ARAXRanker(logger)
base = ranker._calculate_edge_confidence(
"infores:test--A--B",
_edge(
attributes=[
{
"attribute_type_id": "biolink:pValue",
"original_attribute_name": "pValue",
"value": "0.001",
}
]
),
)
boosted = ranker._calculate_edge_confidence(
"infores:test--A--B",
_edge(
attributes=[
{
"attribute_type_id": "biolink:pValue",
"original_attribute_name": "pValue",
"value": "0.001",
}
],
qualifiers=[
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "very_strongly_significant",
}
],
),
)
assert boosted >= base # additive qualifier can only help or be neutral


def test_significance_score_mapping():
"""Each band maps to band_score * 0.5 trust, appended to the score list."""
ranker = ARAXRanker(logger)
# Edge with no attributes and no qualifier -> base only
no_qual = ranker._calculate_edge_confidence("infores:test--A--B", _edge())
# Edge with qualifier only (no attributes) -> base + qualifier boost
with_qual = ranker._calculate_edge_confidence(
"infores:test--A--B",
_edge(
qualifiers=[
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "significant",
}
]
),
)
# significant = 0.40 * 0.5 = 0.20 additive boost
assert with_qual > no_qual


def test_not_significant_adds_nothing():
"""not_significant (score 0.0) adds no boost."""
ranker = ARAXRanker(logger)
no_qual = ranker._calculate_edge_confidence("infores:test--A--B", _edge())
not_sig = ranker._calculate_edge_confidence(
"infores:test--A--B",
_edge(
qualifiers=[
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "not_significant",
}
]
),
)
assert not_sig == no_qual


def test_biolink_prefix_stripped():
"""biolink:-prefixed qualifier values are handled."""
ranker = ARAXRanker(logger)
bare = ranker._calculate_edge_confidence(
"infores:test--A--B",
_edge(
qualifiers=[
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "strongly_significant",
}
]
),
)
prefixed = ranker._calculate_edge_confidence(
"infores:test--A--B",
_edge(
qualifiers=[
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "biolink:strongly_significant",
}
]
),
)
assert bare == prefixed


def test_qualifier_works_without_attributes():
"""Qualifier scoring works even for edges with no attributes at all."""
ranker = ARAXRanker(logger)
# No attributes, no qualifier -> base only (0.5 for infores)
base_only = ranker._calculate_edge_confidence("infores:test--A--B", _edge())
# No attributes, but has qualifier -> base + boost
with_qual = ranker._calculate_edge_confidence(
"infores:test--A--B",
_edge(
qualifiers=[
{
"qualifier_type_id": "biolink:statistical_significance_qualifier",
"qualifier_value": "very_strongly_significant",
}
]
),
)
assert with_qual > base_only
1 change: 1 addition & 0 deletions tests/unit/test_bte.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ async def test_bte_lookup_workflow(redis_mock, mocker):
"bte.lookup",
"aragorn.omnicorp",
"aragorn.score",
"filter_edges_by_statistical_significance",
"sort_results_score",
"filter_results_top_n",
"filter_kgraph_orphans",
Expand Down
Loading