diff --git a/README.md b/README.md index e92a633..1e0aefa 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Build the same RAG-based alignment yourself for full control over every stage: ```python from ontoaligner.ontology import GenericOMDataset from ontoaligner.utils import metrics, xmlify +from ontoaligner.utils.sssom import write_sssom from ontoaligner.aligner import MistralLLMBERTRetrieverRAG from ontoaligner.encoder import ConceptParentRAGEncoder from ontoaligner.postprocess import rag_hybrid_postprocessor @@ -129,6 +130,9 @@ print("Hybrid Matching Evaluation Report:", evaluation) # Step 7: Convert matchings to XML format and save the XML representation xml_str = xmlify.xml_alignment_generator(matchings=hybrid_matchings) open("matchings.xml", "w", encoding="utf-8").write(xml_str) + +# Step 8: implicitly convert to SSSOM and write to disk +write_sssom(hybrid_matchings, "matchings.sssom.tsv") ``` ### Advanced `AlignerPipeline` diff --git a/ontoaligner/utils/__init__.py b/ontoaligner/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ontoaligner/utils/sssom.py b/ontoaligner/utils/sssom.py new file mode 100644 index 0000000..6f19837 --- /dev/null +++ b/ontoaligner/utils/sssom.py @@ -0,0 +1,94 @@ +"""Utilities for writing SSSOM.""" + +from __future__ import annotations + +import datetime +from pathlib import Path +from typing import Any, TextIO + +import bioregistry +import curies +import sssom_pydantic +from curies import vocabulary as v +from sssom_pydantic import MappingSet, SemanticMapping + +__all__ = [ + "to_semantic_mappings", + "write_sssom", +] + + +def write_sssom( + matchings: list[dict], + path: str | Path | TextIO, + *, + converter: curies.Converter | None = None, + metadata: MappingSet | None = None, + **kwargs: Any, +) -> None: + """Convert matchings to SSSOM objects and write a TSV to disk. + + :param matchings: OntoAligner matchings + :param path: The path or file to write to. + :param converter: + :param metadata: + :param kwargs: + :return: + """ + if converter is None: + converter = bioregistry.get_default_converter() + mappings = to_semantic_mappings(matchings, converter) + sssom_pydantic.write( + mappings, path, converter=converter, metadata=metadata, **kwargs + ) + + +LOOKUPS = { + "=": v.exact_match, +} + + +def to_semantic_mappings( + matchings: list[dict], + converter: curies.Converter | None = None, +) -> list[SemanticMapping]: + if converter is None: + converter = bioregistry.get_default_converter() + mappings = [] + for matching in matchings: + try: + mapping = _to_semantic_mapping(matching, converter) + except Exception: # noqa: BLE001 + continue + else: + if mapping is not None: + mappings.append(mapping) + return mappings + + +def _to_semantic_mapping( + matching: dict[str, Any], converter: curies.Converter +) -> SemanticMapping | None: + sub = converter.parse_uri(matching["source"]) + obj = converter.parse_uri(matching["target"]) + if sub is None or obj is None: + return None + + if confidence := matching.get("score"): + confidence = float(confidence) + if confidence < 0 or confidence > 1: + confidence = None + + return SemanticMapping( + subject=sub, + predicate=LOOKUPS[matching.get("relation", "=")], + object=obj, + # depending on the aligner, this might be one of several + # other things from the SEMAPV namespace + justification=v.unspecified_matching_process, + confidence=confidence, + mapping_date=datetime.date.today(), # noqa: DTZ011 + # TODO in follow-up, add additional fields such as: + # mapping tool, license, etc. from + # https://mapping-commons.github.io/sssom/dev/ + ) diff --git a/setup.py b/setup.py index 598d063..09d6412 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,9 @@ "scipy==1.15.3", "multiprocess", "cohere", + "sssom-pydantic", + "bioregistry", + "curies", ], classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/data/ensemble_alignments.json b/tests/data/ensemble_alignments.json new file mode 100644 index 0000000..2260149 --- /dev/null +++ b/tests/data/ensemble_alignments.json @@ -0,0 +1,57 @@ +[ + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_10", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_13", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_4", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_1", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_44", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_15", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_53", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_16", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_6", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_3", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_7", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_4", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_82", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_86", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_9", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_24", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_49", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_20", + "score": 4.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_51", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_19", + "score": 3.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_52", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_18", + "score": 3.0 + } +] \ No newline at end of file diff --git a/tests/test_sssom.py b/tests/test_sssom.py new file mode 100644 index 0000000..87e24bf --- /dev/null +++ b/tests/test_sssom.py @@ -0,0 +1,35 @@ +"""Tests for SSSOM export.""" + +import unittest +from pathlib import Path +import json +from ontoaligner.utils.sssom import to_semantic_mappings +from curies import Converter, NamableReference +from sssom_pydantic import SemanticMapping +from sssom_pydantic.testing import assert_semantic_mapping_equal +import datetime + +HERE = Path(__file__).parent.resolve() +TEST_DATA = HERE.parent.joinpath("data", "ensemble_alignments.json") + + +class TestSSSOM(unittest.TestCase): + """A test case for SSSOM export.""" + + def test_sssom(self) -> None: + today = datetime.date.today() + with TEST_DATA.open() as file: + matchings = json.load(file) + converter = Converter.from_prefix_map( + { + "fishtraits": "https://kos.lifewatch.eu/thesauri/fishtraits/", + "zooplanktontraits": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/", + } + ) + expected = SemanticMapping.exact( + "fishtraits:c_10", + "zooplanktontraits:c_13", + mapping_date=today, + ) + actual = to_semantic_mappings(matchings, converter=converter) + assert_semantic_mapping_equal(self, expected, actual[0])