Skip to content

Commit f7e6714

Browse files
cthoytmberr
andauthoredJan 30, 2023
🪞🧬 Text representation for biomedical entities via PyOBO (pykeen#1055)
Similarly to the `WikidataTextRepresentation`, this uses `PyOBO` as a service for looking up labels for entities encoded with `CURIE`s appearing in biomedical knowledge graphs. Unfortunately, the semantics of all of the existing biomedical knowledge graphs are garbage, and don't use standardized identifiers, so this isn't applicable for anything built-in at the moment. An example for generating a graph where this works is given. Requirements: ```shell python -m pip install pyobo bioontologies ``` Example with very tiny dataet: ```python import numpy as np from pykeen.datasets import EagerDataset from pykeen.nn import BiomedicalCURIERepresentation from pykeen.triples import TriplesFactory triples = [ ('uberon:0000004', 'ro:0002216', 'go:0007608'), ] triples = TriplesFactory.from_labeled_triples(np.array(triples)) dataset = EagerDataset(triples, triples, triples) dataset.summarize() entity_representations = BiomedicalCURIERepresentation.from_dataset(dataset=dataset, encoder="transformer") print(entity_representations) ``` Example with full training: ```python from pykeen.datasets import get_dataset from pykeen.models import ERModel from pykeen.nn import BiomedicalCURIERepresentation from pykeen.pipeline import pipeline import bioontologies # Generate graph dataset from the Monarch Disease Ontology (MONDO) obograph = bioontologies.get_obograph_by_prefix("mondo").squeeze(standardize=True) triples = (edge.as_tuple() for edge in graph.obograph) triples = [t for t in triples if all(t)] triples_factory = TriplesFactory.from_labeled_triples(np.array(triples)) dataset = Dataset.from_tf(triples_factory) entity_representations = BiomedicalCURIERepresentation.from_dataset(dataset=dataset, encoder="transformer") result = pipeline( dataset=dataset, model=ERModel, model_kwargs=dict( interaction="distmult", entity_representations=entity_representations, relation_representation_kwargs=dict( shape=entity_representations.shape, ), ), ) ``` --------- Co-authored-by: Max Berrendorf <[email protected]>
1 parent aadf5a4 commit f7e6714

File tree

9 files changed

+251
-47
lines changed

9 files changed

+251
-47
lines changed
 

‎docs/source/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@
259259
"numpy": ("https://numpy.org/doc/stable", None),
260260
"optuna": ("https://optuna.readthedocs.io/en/latest", None),
261261
"pybel": ("https://pybel.readthedocs.io/en/latest/", None),
262+
"pyobo": ("https://pyobo.readthedocs.io/en/stable/", None),
262263
"class_resolver": ("https://class-resolver.readthedocs.io/en/latest/", None),
263264
"rexmex": ("https://rexmex.readthedocs.io/en/latest/", None),
264265
"bio2bel": ("https://bio2bel.readthedocs.io/en/latest/", None),

‎docs/source/installation.rst

+1
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,5 @@ Name Description
109109
``tests`` Code needed to run tests. Typically handled with ``tox -e py``
110110
``docs`` Building of the documentation
111111
``opt_einsum`` Improve performance of :func:`torch.einsum` by replacing with :func:`opt_einsum.contract`
112+
``biomedicine`` Use of :mod:`pyobo` for lookup of biomedical entity labels
112113
================ =========================================================================================

‎docs/source/tutorial/representations.rst

+20-10
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,18 @@ relations (including inverse relations) as tokens.
5151

5252
Text-based
5353
----------
54-
Text-based representations use, e.g., the entities' (or relations') labels to
55-
derive representations. To this end, PyKEEN provides a base class
56-
:class:`pykeen.nn.representation.TextRepresentation` with a configurable
57-
:class:`pykeen.nn.text.TextEncoder`. As a baseline without external dependencies,
58-
:class:`pykeen.nn.text.CharacterEmbeddingTextEncoder` encodes the label character-wise,
59-
with trainable representations for individual characters.
60-
A more advanced text encoder is given by
61-
:class:`pykeen.nn.text.TransformerTextEncoder`, which utilizes a
54+
Text-based representations use the entities' (or relations') labels to
55+
derive representations. To this end,
56+
:class:`pykeen.nn.representation.TextRepresentation` uses a
6257
(pre-trained) transformer model from the :mod:`transformers` library to encode
6358
the labels. Since the transformer models have been trained on huge corpora
6459
of text, their text encodings often contain semantic information, i.e.,
6560
labels with similar semantic meaning get similar representations. While we
6661
can also benefit from these strong features by just initializing an
6762
:class:`pykeen.nn.representation.Embedding` with the vectors, e.g., using
6863
:class:`pykeen.nn.init.LabelBasedInitializer`, the
69-
:class:`pykeen.nn.representation.TextEncoder` include the
70-
text encoder model as part of the KGE model, and thus allow fine-tuning
64+
:class:`pykeen.nn.representation.TextRepresentation` include the
65+
transformer model as part of the KGE model, and thus allow fine-tuning
7166
the language model for the KGE task. This is beneficial, e.g., since it
7267
allows a simple form of obtaining an inductive model, which can make
7368
predictions for entities not seen during training.
@@ -138,3 +133,18 @@ function, we would get similar scores
138133
139134
As a downside, this will usually substantially increase the
140135
computational cost of computing triple scores.
136+
137+
Biomedical Entities
138+
~~~~~~~~~~~~~~~~~~~
139+
If your dataset is labeled with compact uniform resource identifiers (e.g., CURIEs)
140+
for biomedical entities like chemicals, proteins, diseases, and pathways, then
141+
the :class:`pykeen.nn.representation.BiomedicalCURIERepresentation`
142+
representation can make use of :mod:`pyobo` to look up names (via CURIE) via the
143+
:func:`pyobo.get_name` function, then encode them using the text encoder.
144+
145+
All biomedical knowledge graphs in PyKEEN (at the time of adding this representation),
146+
unfortunately do not use CURIEs for referencing biomedical entities. In the future, we hope
147+
this will change.
148+
149+
To learn more about CURIEs, please take a look at the `Bioregistry <https://bioregistry.io>`_
150+
and `this blog post on CURIEs <https://cthoyt.com/2021/09/14/curies.html>`_.

‎setup.cfg

+3
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ transformers =
113113
lightning =
114114
# cf. https://github.com/Lightning-AI/lightning/pull/14117
115115
pytorch_lightning>=1.7.2
116+
biomedicine =
117+
bioregistry
118+
pyobo
116119
tests =
117120
unittest-templates>=0.0.5
118121
coverage

‎src/pykeen/nn/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
)
6262
from .representation import (
6363
BackfillRepresentation,
64+
BiomedicalCURIERepresentation,
65+
CachedTextRepresentation,
6466
CombinedRepresentation,
6567
Embedding,
6668
LowRankRepresentation,
@@ -96,6 +98,7 @@
9698
"TextRepresentation",
9799
"TransformedRepresentation",
98100
"WikidataTextRepresentation",
101+
"BiomedicalCURIERepresentation",
99102
"VisualRepresentation",
100103
"WikidataVisualRepresentation",
101104
"tokenizer_resolver",
@@ -154,4 +157,7 @@
154157
representation_resolver: ClassResolver[Representation] = ClassResolver.from_subclasses(
155158
base=Representation,
156159
default=Embedding,
160+
skip={
161+
CachedTextRepresentation,
162+
},
157163
)

‎src/pykeen/nn/representation.py

+92-23
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import string
1111
import warnings
1212
from abc import ABC, abstractmethod
13-
from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
13+
from typing import Any, ClassVar, Iterable, List, Literal, Mapping, Optional, Sequence, Tuple, Type, Union, cast
1414

1515
import more_itertools
1616
import numpy
@@ -26,7 +26,7 @@
2626
from .compositions import CompositionModule, composition_resolver
2727
from .init import initializer_resolver, uniform_norm_p1_
2828
from .text import TextEncoder, text_encoder_resolver
29-
from .utils import ShapeError, WikidataCache
29+
from .utils import PyOBOCache, ShapeError, TextCache, WikidataCache
3030
from .weighting import EdgeWeighting, SymmetricEdgeWeighting, edge_weight_resolver
3131
from ..datasets import Dataset
3232
from ..regularizers import Regularizer, regularizer_resolver
@@ -57,9 +57,11 @@
5757
"SubsetRepresentation",
5858
"CombinedRepresentation",
5959
"TensorTrainRepresentation",
60-
"TextRepresentation",
6160
"TransformedRepresentation",
61+
"TextRepresentation",
62+
"CachedTextRepresentation",
6263
"WikidataTextRepresentation",
64+
"BiomedicalCURIERepresentation",
6365
# Utils
6466
"constrainer_resolver",
6567
"normalizer_resolver",
@@ -952,6 +954,21 @@ def _plain_forward(
952954
return x
953955

954956

957+
def _clean_labels(labels: Sequence[Optional[str]], missing_action: Literal["error", "blank"]) -> Sequence[str]:
958+
if missing_action == "error":
959+
idx = [i for i, label in enumerate(labels) if label is None]
960+
if idx:
961+
raise ValueError(
962+
f"The labels at the following indexes were none. "
963+
f"Consider an alternate `missing_action` policy.\n{idx}",
964+
)
965+
return cast(Sequence[str], labels)
966+
elif missing_action == "blank":
967+
return [label or "" for label in labels]
968+
else:
969+
raise ValueError(f"Invalid `missing_action` policy: {missing_action}")
970+
971+
955972
class TextRepresentation(Representation):
956973
"""
957974
Textual representations using a text encoder on labels.
@@ -969,7 +986,7 @@ class TextRepresentation(Representation):
969986
970987
dataset = get_dataset(dataset="nations")
971988
entity_representations = TextRepresentation.from_dataset(
972-
triples_factory=dataset,
989+
dataset=dataset,
973990
encoder="transformer",
974991
)
975992
model = ERModel(
@@ -983,11 +1000,12 @@ class TextRepresentation(Representation):
9831000

9841001
def __init__(
9851002
self,
986-
labels: Sequence[str],
1003+
labels: Sequence[Optional[str]],
9871004
max_id: Optional[int] = None,
9881005
shape: Optional[OneOrSequence[int]] = None,
9891006
encoder: HintOrType[TextEncoder] = None,
9901007
encoder_kwargs: OptionalKwargs = None,
1008+
missing_action: Literal["blank", "error"] = "error",
9911009
**kwargs,
9921010
):
9931011
"""
@@ -1003,6 +1021,9 @@ def __init__(
10031021
the text encoder, or a hint thereof
10041022
:param encoder_kwargs:
10051023
keyword-based parameters used to instantiate the text encoder
1024+
:param missing_action:
1025+
Which policy for handling nones in the given labels. If "error", raises an error
1026+
on any nones. If "blank", replaces nones with an empty string.
10061027
:param kwargs:
10071028
additional keyword-based parameters passed to :meth:`Representation.__init__`
10081029
@@ -1014,6 +1035,7 @@ def __init__(
10141035
max_id = max_id or len(labels)
10151036
if max_id != len(labels):
10161037
raise ValueError(f"max_id={max_id} does not match len(labels)={len(labels)}")
1038+
labels = _clean_labels(labels, missing_action)
10171039
# infer shape
10181040
shape = ShapeError.verify(shape=encoder.encode_all(labels[0:1]).shape[1:], reference=shape)
10191041
super().__init__(max_id=max_id, shape=shape, **kwargs)
@@ -1171,7 +1193,28 @@ def _plain_forward(
11711193
return self.combine(combination=self.combination, base=self.base, indices=indices)
11721194

11731195

1174-
class WikidataTextRepresentation(TextRepresentation):
1196+
class CachedTextRepresentation(TextRepresentation):
1197+
"""Textual representations for datasets with identifiers that can be looked up with a :class:`TextCache`."""
1198+
1199+
cache_cls: ClassVar[Type[TextCache]]
1200+
1201+
def __init__(self, identifiers: Sequence[str], **kwargs):
1202+
"""
1203+
Initialize the representation.
1204+
1205+
:param identifiers:
1206+
the IDs to be resolved by the class, e.g., wikidata IDs. for :class:`WikidataTextRepresentation`,
1207+
biomedical entities represented as compact URIs (CURIEs) for :class:`BiomedicalCURIERepresentation`
1208+
:param kwargs:
1209+
additional keyword-based parameters passed to :meth:`TextRepresentation.__init__`
1210+
"""
1211+
cache = self.cache_cls()
1212+
labels = cache.get_texts(identifiers=identifiers)
1213+
# delegate to super class
1214+
super().__init__(labels=labels, **kwargs)
1215+
1216+
1217+
class WikidataTextRepresentation(CachedTextRepresentation):
11751218
"""
11761219
Textual representations for datasets grounded in Wikidata.
11771220
@@ -1202,24 +1245,50 @@ class WikidataTextRepresentation(TextRepresentation):
12021245
)
12031246
"""
12041247

1205-
def __init__(self, labels: Sequence[str], **kwargs):
1206-
"""
1207-
Initialize the representation.
1248+
cache_cls = WikidataCache
12081249

1209-
:param labels:
1210-
the wikidata IDs.
1211-
:param kwargs:
1212-
additional keyword-based parameters passed to :meth:`TextRepresentation.__init__`
1213-
"""
1214-
# set up cache
1215-
cache = WikidataCache()
1216-
# get labels & descriptions
1217-
titles = cache.get_labels(ids=labels)
1218-
descriptions = cache.get_descriptions(ids=labels)
1219-
# compose labels
1220-
labels = [f"{title}: {description}" for title, description in zip(titles, descriptions)]
1221-
# delegate to super class
1222-
super().__init__(labels=labels, **kwargs)
1250+
1251+
class BiomedicalCURIERepresentation(CachedTextRepresentation):
1252+
"""
1253+
Textual representations for datasets grounded with biomedical CURIEs.
1254+
1255+
The label and description for each entity are obtained via :mod:`pyobo` using
1256+
:class:`pykeen.nn.utils.PyOBOCache` and encoded with :class:`TextRepresentation`.
1257+
1258+
Example usage:
1259+
1260+
.. code-block:: python
1261+
1262+
from pykeen.datasets import get_dataset
1263+
from pykeen.models import ERModel
1264+
from pykeen.nn import BiomedicalCURIERepresentation
1265+
from pykeen.pipeline import pipeline
1266+
import bioontologies
1267+
1268+
# Generate graph dataset from the Monarch Disease Ontology (MONDO)
1269+
graph = bioontologies.get_obograph_by_prefix("mondo").squeeze(standardize=True)
1270+
triples = (edge.as_tuple() for edge in graph.edges)
1271+
triples = [t for t in triples if all(t)]
1272+
triples = TriplesFactory.from_labeled_triples(np.array(triples))
1273+
dataset = Dataset.from_tf(triples)
1274+
1275+
entity_representations = BiomedicalCURIERepresentation.from_dataset(
1276+
dataset=dataset, encoder="transformer",
1277+
)
1278+
result = pipeline(
1279+
dataset=dataset,
1280+
model=ERModel,
1281+
model_kwargs=dict(
1282+
interaction="distmult",
1283+
entity_representations=entity_representations,
1284+
relation_representation_kwargs=dict(
1285+
shape=entity_representations.shape,
1286+
),
1287+
),
1288+
)
1289+
"""
1290+
1291+
cache_cls = PyOBOCache
12231292

12241293

12251294
class PartitionRepresentation(Representation):

‎src/pykeen/nn/utils.py

+98-11
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import logging
99
import pathlib
1010
import re
11+
import subprocess
12+
from abc import ABC, abstractmethod
1113
from itertools import chain
1214
from textwrap import dedent
1315
from typing import Any, Callable, Collection, Dict, Iterable, List, Literal, Mapping, Optional, Sequence, Union, cast
@@ -26,8 +28,11 @@
2628
"safe_diagonal",
2729
"adjacency_tensor_to_stacked_matrix",
2830
"use_horizontal_stacking",
29-
"WikidataCache",
3031
"ShapeError",
32+
# Caches
33+
"TextCache",
34+
"WikidataCache",
35+
"PyOBOCache",
3136
]
3237

3338
logger = logging.getLogger(__name__)
@@ -181,7 +186,15 @@ def adjacency_tensor_to_stacked_matrix(
181186
]
182187

183188

184-
class WikidataCache:
189+
class TextCache(ABC):
190+
"""An interface for looking up text for various flavors of entity identifiers."""
191+
192+
@abstractmethod
193+
def get_texts(self, identifiers: Sequence[str]) -> Sequence[Optional[str]]:
194+
"""Get text for the given identifiers for the cache."""
195+
196+
197+
class WikidataCache(TextCache):
185198
"""A cache for requests against Wikidata's SPARQL endpoint."""
186199

187200
#: Wikidata SPARQL endpoint. See https://www.wikidata.org/wiki/Wikidata:SPARQL_query_service#Interfacing
@@ -355,29 +368,44 @@ def _get(self, ids: Sequence[str], component: Literal["label", "description"]) -
355368
assert isinstance(item, str)
356369
return cast(Sequence[str], result)
357370

358-
def get_labels(self, ids: Sequence[str]) -> Sequence[str]:
371+
def get_texts(self, identifiers: Sequence[str]) -> Sequence[str]:
372+
"""Get a concatenation of the title and description for each Wikidata identifier.
373+
374+
:param identifiers:
375+
the Wikidata identifiers, each starting with Q (e.g., ``['Q42']``)
376+
377+
:return:
378+
the label and description for each Wikidata entity concatenated
379+
"""
380+
# get labels & descriptions
381+
titles = self.get_labels(wikidata_identifiers=identifiers)
382+
descriptions = self.get_descriptions(wikidata_identifiers=identifiers)
383+
# compose labels
384+
return [f"{title}: {description}" for title, description in zip(titles, descriptions)]
385+
386+
def get_labels(self, wikidata_identifiers: Sequence[str]) -> Sequence[str]:
359387
"""
360388
Get entity labels for the given IDs.
361389
362-
:param ids:
363-
the Wikidata IDs
390+
:param wikidata_identifiers:
391+
the Wikidata identifiers, each starting with Q (e.g., ``['Q42']``)
364392
365393
:return:
366394
the label for each Wikidata entity
367395
"""
368-
return self._get(ids=ids, component="label")
396+
return self._get(ids=wikidata_identifiers, component="label")
369397

370-
def get_descriptions(self, ids: Sequence[str]) -> Sequence[str]:
398+
def get_descriptions(self, wikidata_identifiers: Sequence[str]) -> Sequence[str]:
371399
"""
372400
Get entity descriptions for the given IDs.
373401
374-
:param ids:
375-
the Wikidata IDs
402+
:param wikidata_identifiers:
403+
the Wikidata identifiers, each starting with Q (e.g., ``['Q42']``)
376404
377405
:return:
378406
the description for each Wikidata entity
379407
"""
380-
return self._get(ids=ids, component="description")
408+
return self._get(ids=wikidata_identifiers, component="description")
381409

382410
def _discover_images(self, extensions: Collection[str]) -> Mapping[str, pathlib.Path]:
383411
image_dir = self.module.join("images")
@@ -410,7 +438,7 @@ def get_image_paths(
410438
num_missing = len(missing)
411439
logger.info(
412440
f"Downloading images for {num_missing:,} entities. With the rate limit in place, "
413-
f"this will take at least {num_missing/10:.2f} seconds.",
441+
f"this will take at least {num_missing / 10:.2f} seconds.",
414442
)
415443
res_json = self.query(
416444
sparql=functools.partial(
@@ -476,6 +504,65 @@ def get_image_paths(
476504
return [id_to_path.get(i) for i in ids]
477505

478506

507+
PYOBO_PREFIXES_WARNED = set()
508+
509+
510+
class PyOBOCache(TextCache):
511+
"""A cache that looks up labels of biomedical entities based on their CURIEs."""
512+
513+
def __init__(self, *args, **kwargs):
514+
"""Instantiate the PyOBO cache, ensuring PyOBO is installed."""
515+
try:
516+
import pyobo
517+
except ImportError:
518+
raise ImportError(f"Can not use {self.__class__.__name__} because pyobo is not installed.")
519+
else:
520+
self._get_name = pyobo.get_name
521+
super().__init__(*args, **kwargs)
522+
523+
def get_texts(self, identifiers: Sequence[str]) -> Sequence[Optional[str]]:
524+
"""Get text for the given CURIEs.
525+
526+
:param identifiers:
527+
The compact URIs for each entity (e.g., ``['doid:1234', ...]``)
528+
529+
:return:
530+
the label for each entity, looked up via :func:`pyobo.get_name`.
531+
Might be none if no label is available.
532+
"""
533+
# This import doesn't need a wrapper since it's a transitive
534+
# requirement of PyOBO
535+
import bioregistry
536+
537+
res: List[Optional[str]] = []
538+
for curie in identifiers:
539+
try:
540+
prefix, identifier = curie.split(":", maxsplit=1)
541+
except ValueError:
542+
res.append(None)
543+
continue
544+
545+
norm_prefix = bioregistry.normalize_prefix(prefix)
546+
if norm_prefix is None:
547+
if prefix not in PYOBO_PREFIXES_WARNED:
548+
logger.warning("Prefix not registered in the Bioregistry: %s", prefix)
549+
PYOBO_PREFIXES_WARNED.add(prefix)
550+
res.append(None)
551+
continue
552+
553+
try:
554+
name = self._get_name(norm_prefix, identifier)
555+
except subprocess.CalledProcessError:
556+
if norm_prefix not in PYOBO_PREFIXES_WARNED:
557+
logger.warning("could not get names from %s", norm_prefix)
558+
PYOBO_PREFIXES_WARNED.add(norm_prefix)
559+
res.append(None)
560+
continue
561+
else:
562+
res.append(name)
563+
return res
564+
565+
479566
class ShapeError(ValueError):
480567
"""An error for a mismatch in shapes."""
481568

‎tests/test_nn/test_representation.py

+29-3
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ class WikidataTextRepresentationTests(cases.RepresentationTestCase):
303303

304304
cls = pykeen.nn.representation.WikidataTextRepresentation
305305
kwargs = dict(
306-
labels=["Q100", "Q1000"],
306+
identifiers=["Q100", "Q1000"],
307307
encoder="character-embedding",
308308
)
309309

@@ -312,7 +312,29 @@ def _pre_instantiation_hook(self, kwargs: MutableMapping[str, Any]) -> MutableMa
312312
kwargs = super()._pre_instantiation_hook(kwargs)
313313
# the representation module infers the max_id from the provided labels
314314
kwargs.pop("max_id")
315-
self.max_id = len(kwargs["labels"])
315+
self.max_id = len(kwargs["identifiers"])
316+
return kwargs
317+
318+
319+
@needs_packages("pyobo")
320+
class BiomedicalCURIERepresentationTests(cases.RepresentationTestCase):
321+
"""Tests for biomedical CURIE representations."""
322+
323+
cls = pykeen.nn.representation.BiomedicalCURIERepresentation
324+
kwargs = dict(
325+
identifiers=[
326+
"hgnc:12929", # PCGF2
327+
"hgnc:391", # AKT1
328+
],
329+
encoder="character-embedding",
330+
)
331+
332+
# docstr-coverage: inherited
333+
def _pre_instantiation_hook(self, kwargs: MutableMapping[str, Any]) -> MutableMapping[str, Any]: # noqa: D102
334+
kwargs = super()._pre_instantiation_hook(kwargs)
335+
# the representation module infers the max_id from the provided labels
336+
kwargs.pop("max_id")
337+
self.max_id = len(kwargs["identifiers"])
316338
return kwargs
317339

318340

@@ -416,4 +438,8 @@ class RepresentationModuleMetaTestCase(unittest_templates.MetaTestCase[pykeen.nn
416438

417439
base_cls = pykeen.nn.representation.Representation
418440
base_test = cases.RepresentationTestCase
419-
skip_cls = {mocks.CustomRepresentation, pykeen.nn.pyg.MessagePassingRepresentation}
441+
skip_cls = {
442+
mocks.CustomRepresentation,
443+
pykeen.nn.pyg.MessagePassingRepresentation,
444+
pykeen.nn.CachedTextRepresentation,
445+
}

‎tox.ini

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ extras =
4545
tests
4646
transformers
4747
lightning
48+
biomedicine
4849
allowlist_externals =
4950
/bin/cat
5051
/bin/cp

0 commit comments

Comments
 (0)
Please sign in to comment.