Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,9 @@ venv
sample_names.txt
*.log

# local uv/python tooling (not part of the ARAX codebase)
pyproject.toml
uv.lock
.python-version
PLAN.md

111 changes: 111 additions & 0 deletions code/ARAX/ARAXQuery/ARAX_filter_kg.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys
import traceback
from typing import Self
from collections import Counter
from collections.abc import Hashable
from Filter_KG.remove_edges import RemoveEdges
Expand All @@ -25,6 +26,7 @@ def __init__(self):
'remove_nodes_by_property',
'remove_nodes_by_category',
'remove_edges_by_discrete_attribute',
'remove_edges_by_statistical_significance',
'remove_orphaned_nodes',
'remove_general_concept_nodes'
}
Expand Down Expand Up @@ -76,6 +78,14 @@ def __init__(self):
"type": "string",
"description": "The name of the edge attribute to filter on."
}
self.minimum_significance_info = {
"is_required": True,
"enum": ['very_strongly_significant', 'strongly_significant', 'significant', 'suggestive', 'not_significant'],
"type": "string",
"description": "The minimum statistical significance band to keep. "
"Edges with a significance qualifier below this band are removed. "
"Edges without a qualifier are always kept."
}
self.direction_info = {
"is_required": True,
"enum": ['above', 'below'],
Expand Down Expand Up @@ -264,6 +274,36 @@ def __init__(self):
"qedge_keys": self.qedge_key_info
}
},
"remove_edges_by_statistical_significance": {
"dsl_command": "filter_kg(action=remove_edges_by_statistical_significance)",
"description": """
`remove_edges_by_statistical_significance` removes edges from the knowledge graph (KG) whose
`biolink:statistical_significance_qualifier` falls below a specified minimum band.

This is an **ordinal** filter: setting `minimum_significance=significant` removes edges
labelled `suggestive` or `not_significant`, while keeping `significant`, `strongly_significant`,
and `very_strongly_significant`.

**Important:** Edges that do not carry the qualifier at all are **kept** (not penalized),
since the qualifier is still being rolled out across knowledge sources.

Use cases include:

* removing all non-significant edges: `filter_kg(action=remove_edges_by_statistical_significance, minimum_significance=suggestive)`
* keeping only strongly significant evidence: `filter_kg(action=remove_edges_by_statistical_significance, minimum_significance=strongly_significant)`

You have the option (defaults to false) to remove connected nodes via `remove_connected_nodes=t`.
""",
'brief_description': """
remove_edges_by_statistical_significance removes edges whose statistical significance qualifier falls below a minimum band.
""",
"parameters": {
"minimum_significance": self.minimum_significance_info,
"remove_connected_nodes": self.remove_connected_nodes_info,
"qnode_keys": self.qnode_key_info,
"qedge_keys": self.qedge_key_info
}
},
"remove_edges_by_std_dev": {
"dsl_command": "filter_kg(action=remove_edges_by_std_dev)",
"description": """
Expand Down Expand Up @@ -753,6 +793,77 @@ def __remove_edges_by_discrete_attribute(self, describe=False):
response = RE.remove_edges_by_property()
return response

def __remove_edges_by_statistical_significance(self: Self, describe: bool = False):
"""
Removes edges from the KG whose statistical_significance_qualifier falls below
the specified minimum band. Edges without the qualifier are kept.
"""
message = self.message
parameters = self.parameters

significance_values = {'very_strongly_significant', 'strongly_significant',
'significant', 'suggestive', 'not_significant'}

if message and parameters and hasattr(message, 'query_graph') and hasattr(message.query_graph, 'edges'):
known_values = set()
_sig_type_id = "biolink:statistical_significance_qualifier"
for edge in message.knowledge_graph.edges.values():
if edge.qualifiers:
for q in edge.qualifiers:
if q.qualifier_type_id == _sig_type_id:
val = q.qualifier_value.replace("biolink:", "") if isinstance(q.qualifier_value, str) and q.qualifier_value.startswith("biolink:") else q.qualifier_value
known_values.add(val)
allowable_parameters = {'action': {'remove_edges_by_statistical_significance'},
'minimum_significance': significance_values,
'remove_connected_nodes': {'true', 'false', 'True', 'False', 't', 'f', 'T', 'F'},
'qnode_keys': set([t for x in self.message.knowledge_graph.nodes.values() if x.qnode_keys is not None for t in x.qnode_keys]),
'qedge_keys': set([t for x in self.message.knowledge_graph.edges.values() if x.qedge_keys is not None for t in x.qedge_keys])
}
else:
allowable_parameters = {'action': {'remove_edges_by_statistical_significance'},
'minimum_significance': significance_values,
'remove_connected_nodes': {'true', 'false', 'True', 'False', 't', 'f', 'T', 'F'},
'qnode_keys': {'a specific query node id to remove'},
'qedge_keys': {'a list of specific query edge ids to remove'}
}

if describe:
brief_description = self.command_definitions['remove_edges_by_statistical_significance']
allowable_parameters['brief_description'] = brief_description
return allowable_parameters

# FW: patch to allow qnode_key to be backwards compatible:
if 'qnode_key' in self.parameters and 'qnode_keys' not in self.parameters:
self.parameters['qnode_keys'] = [self.parameters['qnode_key']]

resp = self.check_params(allowable_parameters)
if self.response.status != 'OK' or resp == -1:
return self.response

edge_params = self.parameters
if 'remove_connected_nodes' in edge_params:
value = edge_params['remove_connected_nodes']
if value in {'true', 'True', 't', 'T'}:
edge_params['remove_connected_nodes'] = True
elif value in {'false', 'False', 'f', 'F'}:
edge_params['remove_connected_nodes'] = False
else:
self.response.error(f"Supplied value {value} is not permitted. In parameter remove_connected_nodes, allowable values are: {list(allowable_parameters['remove_connected_nodes'])}",
error_code="UnknownValue")
else:
edge_params['remove_connected_nodes'] = False

if 'minimum_significance' not in edge_params:
self.response.error(
f"minimum_significance must be provided, allowable values are: {list(allowable_parameters['minimum_significance'])}",
error_code="UnknownValue")
if self.response.status != 'OK':
return self.response

RE = RemoveEdges(self.response, self.message, edge_params)
response = RE.remove_edges_by_statistical_significance()
return response

def __remove_edges_by_continuous_attribute(self, describe=False):
"""
Removes edges from the KG.
Expand Down
51 changes: 45 additions & 6 deletions code/ARAX/ARAXQuery/ARAX_ranker.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import re


from typing import Union, Dict, Callable
from typing import Union, Dict, Callable, Optional, Self
from ARAX_response import ARAXResponse
from query_graph_info import QueryGraphInfo

Expand All @@ -23,6 +23,22 @@

edge_confidence_manual_agent = 0.90

# Score mapping for the statistical significance qualifier (conservative).
# TODO: Once all KGs populate statistical_significance_qualifier, revisit the
# trust weight (_significance_trust_weight) — the current conservative value
# accounts for the rollout asymmetry where qualifier-bearing edges are scored
# against edges that lack the qualifier entirely.
_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,
}
_significance_qualifier_type_id: str = "biolink:statistical_significance_qualifier"
# How much to trust the significance qualifier signal (conservative during rollout).
_significance_trust_weight: float = 0.5


def _get_query_graph_networkx_from_query_graph(query_graph: QueryGraph) -> nx.MultiDiGraph:
query_graph_nx = nx.MultiDiGraph()
Expand Down Expand Up @@ -376,15 +392,38 @@ def edge_attribute_score_combiner(self, edge_key, edge):
# add more rules in the future
continue

if len(edge_attribute_score_list) == 0: # if no appropriate attribute for score calculation, set the confidence to default base score (0.5)
edge_confidence = base
else:
edge_confidence = _calculate_final_individual_edge_confidence(base, edge_attribute_score_list)
else:
# Check for statistical significance qualifier (carried in edge.qualifiers)
sig_value = self._get_significance_qualifier_value(edge)
if sig_value is not None:
sig_score = _significance_band_scores.get(sig_value, 0.0)
if sig_score > 0:
edge_attribute_score_list.append(
sig_score * _significance_trust_weight
)

if len(edge_attribute_score_list) == 0:
edge_confidence = base
else:
edge_confidence = _calculate_final_individual_edge_confidence(base, edge_attribute_score_list)

return edge_confidence

def _get_significance_qualifier_value(self: Self, edge: Edge) -> Optional[str]:
"""
Look up the statistical significance qualifier from edge.qualifiers (the
TRAPI path for biolink qualifier descendants). Returns the bare enum value
or None if not present.
"""
# Check edge.qualifiers (expected path from Retriever/KPs)
if edge.qualifiers:
for q in edge.qualifiers:
if q.qualifier_type_id == _significance_qualifier_type_id:
value = q.qualifier_value
if isinstance(value, str) and value.startswith("biolink:"):
return value[len("biolink:"):]
return value
return None

def edge_attribute_score_normalizer(self, edge_attribute_name: str, edge_attribute_value) -> float:
"""
Takes an input edge attribute and value, dispatches it to the appropriate method that translates the value into
Expand Down
134 changes: 134 additions & 0 deletions code/ARAX/ARAXQuery/Filter_KG/remove_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
import traceback


_significance_ordinal: dict[str, int] = {
"very_strongly_significant": 4,
"strongly_significant": 3,
"significant": 2,
"suggestive": 1,
"not_significant": 0,
}
_significance_qualifier_type_id: str = "biolink:statistical_significance_qualifier"


class RemoveEdges:

#### Constructor
Expand Down Expand Up @@ -533,3 +543,127 @@ def remove_edges_by_stats(self):
self.response.info("Edges successfully removed")

return self.response

def remove_edges_by_statistical_significance(self):
"""
Iterate over all edges in the knowledge graph, remove any edges whose
statistical_significance_qualifier falls below the specified minimum band.
Edges without the qualifier are kept (not penalized during rollout).
:return: response
"""
self.response.debug("Removing Edges")
edge_params = self.edge_parameters
self.response.info(
"Removing edges from the knowledge graph with statistical significance below "
f"{edge_params['minimum_significance']}")
message = self.message
kg = message.knowledge_graph

try:
threshold_ordinal = _significance_ordinal.get(edge_params['minimum_significance'])
if threshold_ordinal is None:
self.response.error(
f"Invalid minimum_significance value '{edge_params['minimum_significance']}'. "
f"Allowable values: {list(_significance_ordinal.keys())}",
error_code="InvalidParameterValue")
return self.response

edges_to_remove = set()
node_keys_to_remove = {}
edge_qid_dict = {}
for key, q_edge in self.message.query_graph.edges.items():
edge_qid_dict[key] = {'subject': q_edge.subject, 'object': q_edge.object}

# iterate over edges, find those below the significance threshold
for key, edge in kg.edges.items():
sig_value = None
# Check edge.qualifiers (expected TRAPI path for biolink qualifier descendants)
if edge.qualifiers:
for q in edge.qualifiers:
if q.qualifier_type_id == _significance_qualifier_type_id:
sig_value = q.qualifier_value
if isinstance(sig_value, str) and sig_value.startswith("biolink:"):
sig_value = sig_value[len("biolink:"):]
break

# Only remove edges that explicitly carry a below-threshold qualifier;
# edges without the qualifier are kept.
if sig_value is not None:
edge_ordinal = _significance_ordinal.get(sig_value)
if edge_ordinal is not None and edge_ordinal < threshold_ordinal:
edges_to_remove.add(key)
if edge_params.get('remove_connected_nodes', False):
for qedge_key in (getattr(edge, 'qedge_keys', None) or []):
if edge.subject not in node_keys_to_remove:
node_keys_to_remove[edge.subject] = {edge_qid_dict[qedge_key]['subject']}
else:
node_keys_to_remove[edge.subject].add(edge_qid_dict[qedge_key]['subject'])
if edge.object not in node_keys_to_remove:
node_keys_to_remove[edge.object] = {edge_qid_dict[qedge_key]['object']}
else:
node_keys_to_remove[edge.object].add(edge_qid_dict[qedge_key]['object'])

if edge_params.get('remove_connected_nodes', False):
self.response.debug("Removing Nodes")
self.response.info("Removing connected nodes and their edges from the knowledge graph")
nodes_to_remove = set()
skipped_qnode_keys = set()
for key, node in kg.nodes.items():
if key in node_keys_to_remove:
node_qnode_keys = getattr(node, 'qnode_keys', None) or []
if 'qnode_keys' in edge_params:
if node_qnode_keys:
for param_qnode_key in edge_params['qnode_keys']:
if param_qnode_key in node_qnode_keys:
if len(node_qnode_keys) == 1:
nodes_to_remove.add(key)
else:
node_qnode_keys.remove(param_qnode_key)
node.qnode_keys = node_qnode_keys
else:
skipped_qnode_keys.add(key)
else:
skipped_qnode_keys.add(key)
else:
if len(node_qnode_keys) == 1:
nodes_to_remove.add(key)
else:
for node_key in node_keys_to_remove[key]:
node_qnode_keys.remove(node_key)
node.qnode_keys = node_qnode_keys
if len(node_qnode_keys) == 0:
nodes_to_remove.add(key)
for key in skipped_qnode_keys:
del node_keys_to_remove[key]
for key in nodes_to_remove:
del kg.nodes[key]
for key, edge in kg.edges.items():
if edge.subject in node_keys_to_remove or edge.object in node_keys_to_remove:
edges_to_remove.add(key)
self.check_kg_nodes()

# remove edges
for key in edges_to_remove:
if edge_params.get('qedge_keys', None) is not None:
key_edge = kg.edges[key]
key_edge_qedge_keys = getattr(key_edge, 'qedge_keys', None)
if key_edge_qedge_keys is not None:
qedge_key_diff = set(key_edge_qedge_keys) - set(edge_params['qedge_keys'])
if len(qedge_key_diff) < 1:
del kg.edges[key]
else:
key_edge.qedge_keys = list(qedge_key_diff)
else:
self.response.warning(
f"The edge {key} does not have a qedge_keys property. Since a value was supplied for the qedge_keys parameter the edge was not removed.")
else:
del kg.edges[key]
except Exception:
tb = traceback.format_exc()
error_type, error, _ = sys.exc_info()
self.response.error(tb, error_code=error_type.__name__)
self.response.error("Something went wrong removing edges from the knowledge graph")
else:
self.response.info(f"Edges successfully removed: {len(edges_to_remove)}; num left: {len(kg.edges)}")

return self.response
Loading