All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
Annotation sources rebuilt on verified provider contracts (
embpy.resources). New offline indexes:molecule.chebi_obo.ChebiOboIndex(ChEBI OBO,has roleedges keyed by standard InChIKey),molecule.kegg_index(build_kegg_reference,KeggIndex: KEGG entries keyed by InChIKey through KEGG's own PubChem SIDs, with exact and connectivity match tiers and separate compound-pathway, drug-map and target-pathway families) andgene.reactome.ReactomeIndex(Reactome release files joined on Entrez ID, lowest-level and top-level pathways). New methods:MoleculeAnnotator.lookup_chebi_roles,lookup_kegg,resolve_pubchem_cids_by_inchikey,get_pubchem_xrefs_batch,get_chebi_compounds;GeneAnnotator.get_pathways_batch(one MyGene POST per 250 symbols, HGNC renames recorded asrenamed_from);ChEMBLAnnotator.resolve_manyandget_*_manybatch methods;ChEMBLAnnotator(backend="sqlite", db_path=...), a read-only ChEMBL SQLite backend with the REST return shapes;CellLineAnnotator(depmap_model_csv=..., passports_models=...)andget_cellosaurus_batch. Every result carries astatus, and failed requests are reported asservice_error/batch_failed(or raiseAnnotationServiceError), never as an empty answer. -
Stage-1
embpy.tl.fusion: ID-aligned concatenation, reusable per-view normalization and weighting, joint PCA, score/rank ensembles, AnnData/static-store adapters, provenance and serializable fitted state. -
Paired
benchmark_fusionandbenchmark_fusion_retrieval, with grouped splits, fold/entity results and a cluster-only LINCS validation workflow. Added the fusion guide and executable tutorial. -
benchmark_embeddingsaccepts multipleobsm_keys, afusionoperator andgroupby. PCA is now fitted inside training folds and rigorous evaluation uses nested hyperparameter search. Consequentlyreduce_dimno longer writes a full-data PCA toobsm, and rigorousbest_paramsis a list of dictionaries, one per outer fold. -
StateEmbeddingWrapperfetches the published SE-600M release when nocheckpointormodel_folderis given, instead of raisingValueError("Either checkpoint or model_folder must be provided.").statewas the one registry keylist_available_models()advertised thatload()categorically refused to run, while its weights sat public on the Hub -- every other single-cell wrapper downloads on first use. Only the three files needed to run it are fetched (config.yaml,protein_embeddings.pt,se600m_epoch16.ckpt, ~12 GB); the repo also ships anepoch4checkpoint and safetensors variants, and pulling it whole would cost ~29 GB.huggingface_hubcaches, so it is a one-time cost, and naming a local path still short-circuits the download entirely. -
BioEmbedder.embed_cells(..., model_kwargs={"state": {"checkpoint": ...}})forwards per-model constructor arguments to the single-cell wrapper. This is what makes the checkpoint-only models reachable at all:StateEmbeddingWrapperandStackWrappertakecheckpointon__init__and raise fromload()without it, and whileget_singlecell_wrapperhas always forwarded**kwargs,embed_cellshad no parameter to pass and_get_or_load_singlecell_wrapperpassed nothing -- sostateandstackwere listed bylist_available_models()and could not be run through the public API on any platform. The kwargs form part of the wrapper cache key, so two checkpoints of one model no longer collide on the first one loaded. Note that thearc-state/arc-stackbackends are linux-64 only;tests/embpy/models/test_singlecell_models.py::TestSingleCellModelKwargscovers the plumbing rather than a forward pass. -
embpy.resources.ChEMBLAnnotator(src/embpy/resources/molecule/chembl.py) covers the ChEMBL_37 drug record thatMoleculeAnnotatordid not reach.MoleculeAnnotatorused three endpoints --molecule,activity,mechanism; the new class addsdrug(clinical phase, first approval, routes of administration, availability, prodrug/orphan/first-in-class/withdrawn flags),drug_indication(MeSH + EFO indications with the phase reached per indication),drug_warning(black box warnings and withdrawals, with toxicity class, country and year),atc_class(WHO levels 1-5),molecule_synonyms(INN / USAN / trade names / research codes),metabolism(metabolites and the enzymes responsible),molecule_form(parent and salt forms),target+target_component+protein_classification(targets as gene symbols, UniProt accessions and protein families rather than bareCHEMBLaccessions),similarity(Tanimoto analogs), and the fullactivityfield set (assay description, BAO format, source document, ligand efficiency, data-validity flags). Adds derived per-target potency profiles (get_target_profile) and a selectivity summary (summarize_selectivity) that excludes cell-line "targets" before calling a compound's primary target. -
embpy.tl.annotate_drug_perturbations(adata, column=...)-- the compound-centric counterpart toannotate_drugs. Writes 38drug_*summary columns to.obsand the full records to.uns["chembl_annotations"], with*_at_limitcompanion flags and the fetch caps in.uns["chembl_annotation_limits"], following the contractGeneAnnotatoralready uses for capped counts. -
MoleculeAnnotatorgains the source groups"drug","target_profile","metabolism"and"analogs", delegating toChEMBLAnnotatorand landing the result underresult["drug"].sources="all"now includes the first three;"analogs"stays opt-in because a similarity search scans the whole database.MoleculeAnnotator.annotate_adatawrites thedrug_*columns alongside the existingmol_*ones. -
embpy.store.actions.compile_action_tableis now the shared headless action compiler, with explicit RESOLVED / CONTROL / UNRESOLVED status accounting and JSON sidecars. -
embpy.resources.gene.control.ControlPolicyand status-aware embedding APIs add clearer handling for control and unresolved identifiers. -
AlphaGenomeWrapper(Google DeepMind's cloud-API DNA model) andScoobyWrapper(gagneurlab/scooby, single-cell-resolution DNA sequence model) are now registered inDNA_MODELS/MODEL_REGISTRYunder the keysalphagenome,scooby_onek1k,scooby_neurips, andscooby_epicardioids, reachable throughBioEmbedder.embed(model=...)andlist_available_models()like any other model. Each is gated behind its own optional extra (embpy[alphagenome],embpy[scooby]) and standalone pixi environment (pixi install -e alphagenome/-e scooby) -- neither is pulled in byembpy[all]or the default/gpu/dev pixi environments, since AlphaGenome needs an externalALPHAGENOME_API_KEYand Scooby installs from a git URL.
-
MoleculeAnnotator.get_chebi_rolesis renamedget_chebi_terms: it always returned the molecule's own ChEBI term label (OLS), never roles.get_chebi_rolesstays as a deprecated alias (DeprecationWarning); uselookup_chebi_rolesfor roles.get_kegg_pathwayskeeps its list return shape but flattenslookup_kegg, which needs aKeggIndex({"status": "index_required"}without one).get_disease_associationsis documented as assay-name keywords (Active rows only, columns by name).get_cross_referencesqueries ChEMBL last, by exact standard InChIKey, and a ChEMBL failure givesstatus="partial".ChEMBLAnnotator.resolveuses the RDKit InChIKey for SMILES first; flexmatch is opt-in and InChIKey-verified.CellLineAnnotatorparses Cellosaurus API v2, resolves accessions first, returnsambiguousfor multi-hit name searches, uses the Passports JSON:API filters, and reads DepMap fromModel.csvonly. -
GeneAnnotator.get_pathways,annotateandannotate_batchno longer fillreactomefrom MyGene (ConsensusPathDB, not a Reactome release). Passreactome_index=ReactomeIndex(...)to get Reactome pathways; without it thereactomelist is empty. KEGG and WikiPathways still come from MyGene. Cell-line lookups (get_cellosaurus_info,get_depmap_info,get_passports_info) now return a status dict on a miss instead of{}, and DepMap needsdepmap_model_csv(otherwisesource_unavailable). -
docs/notebooks/04_benchmark_models.ipynbrewritten. The old version trained probes againstrng.normal(size=16)-- random noise -- and read the resulting R-squared as a model ranking, which ranked nothing. It now embeds the same cells with five single-cell models (pca,scvi,scgpt,geneformer_v2_12L,state) and scores them with two metric families:tl.compute_scib_metricsfor structure preservation andtl.run_cell_evalfor pairwise agreement, each plotted. Also documents the single-cell environment recipe, since no resolver-satisfiable environment holds helical, arc-state and cell-eval together. -
embpy/__init__.pyis now a PEP-562 lazy facade (audit step 1).import embpyno longer eagerly walksdt,models,pl,pp,resources,tl, or pullsembpy.embedder(3,600 lines, transformers + torch + numpy). Coldimport embpydrops from minutes (lustre, observed) to milliseconds. The public API is unchanged:embpy.BioEmbedder,embpy.GeneResolver,from embpy import tl, etc. still resolve -- they load on first access via a module-level__getattr__(PEP 562). Static analysis is preserved through aTYPE_CHECKINGimport block. Guarded bytests/embpy/test_boundary.py::{test_import_embpy_does_not_eagerly_load_subpackages, test_import_embpy_does_not_eagerly_load_embedder, test_lazy_bioembedder_attr_access_still_works}. -
MODEL_REGISTRYand the three DNA species sets (HUMAN_ONLY_MODELS,MOUSE_ONLY_MODELS,MULTI_SPECIES_DNA) moved fromsrc/embpy/embedder.pyto the newsrc/embpy/embedder_registry/flat.py(audit step 2). The optional-dep wrapper imports (Evo, Evo2, Boltz2) and the_HAVE_*flags moved alongside them.from embpy.embedder import MODEL_REGISTRYcontinues to return the exact samedictobject (re-export viafrom .embedder_registry.flat import ...). Net delta inembedder.py: -216 lines. -
Registry split into per-modality submodules under
src/embpy/embedder_registry/(audit step 3).flat.pyis now a 25-line merge ofdna.py(46 entries + the three DNA species sets),protein.py(23 entries, including Boltz-2 structural),molecule.py(14 entries),text.py(5 entries),morphology.py(11 entries),singlecell.py(0 entries today; reserved for the upcoming SingleCellEmbedder facade), andapi.py(7 entries). Total: 106 entries; the publicMODEL_REGISTRYdict has identical keys and identical(Wrapper, path)tuples vs. the pre-split snapshot (set + value byte-equivalence; iteration order changes only because GENA-LM / NT / HyenaDNA / Caduceus -- previously appended after morphology -- now group with the other DNA entries). Optional-dep gating (EvoWrapper if _HAVE_EVO else None) stays inside the per-modality submodule that owns the entry, so importing one modality's submodule never pays the cost of loading another's optional dependency. Guarded bytests/embpy/test_registry_split.py(snapshot of 106 entries + disjoint-modality + merge-equals-flat + species-sets-in-DNA + same-object-re-export tests). -
The
[scooby]extra no longer listsscooby @ git+https://github.com/gagneurlab/scooby.git. PyPI rejects uploads with URL dependencies, so this one line would have failed the next release; thescoobyproject on PyPI is an unrelated tool, so it cannot be listed by name either. Install the model package next to the extra:pip install "embpy[scooby]" "scooby @ git+https://github.com/gagneurlab/scooby.git"(README,docs/technical.mdand theScoobyWrappererror messages now say so).[tool.hatch.metadata] allow-direct-referencesis gone too, so a URL dependency now fails the build instead of the upload. Guarded bytests/embpy/test_extras_consistency.py::TestPublishableToPyPI.
-
Annotation uncertainty is preserved: unrecorded ChEMBL flags are
None, missing phase is not preclinical, and Cellosaurus sampling site no longer fills tissue of origin. Target profiles fetch complete activity evidence by default, retain every target component and expose truncation when a limit is requested. Exact-match eligibility and a parent-family identity audit make stereochemical/isotope differences explicit so callers can gate annotation transfer. The default AnnData fielddrug_coconut_memberreplaces the misleadingdrug_is_natural_product; seedocs/annotation_semantics.mdfor the changed contracts. -
PubChem CID resolution no longer puts SMILES in the URL path (every SMILES with
/or\got HTTP 400); it sendssmilesas a query parameter. KEGG lookups no longer treat a PubChem CID as a KEGGpubchem:SID (CID 5291, imatinib, used to map to C02226, citraconic acid). MyGene is no longer used as a Reactome source. -
tl.cell_eval/tl.run_cell_evalnow takemetric_configsandskip_metrics, routed toMetricsEvaluator.computerather than the constructor. All**kwargspreviously went to__init__, so passingmetric_configsraisedMetricsEvaluator.__init__() got an unexpected keyword argument 'metric_configs'-- and sincemetric_configsis the only route toembed_key, the tenANNDATA_PAIRmetrics could not be pointed at an.obsmembedding at all. They silently scored.Xinstead, which for an embedding comparison is the wrong matrix rather than a missing feature. -
The protein resolver now retries transient UniProt and MyGene failures, matching what the gene resolver has always done for Ensembl.
_fetch_fasta_sequenceturned every exception intoNone, and every caller above it readsNoneas this protein does not exist, so a connection reset part-way through a burst of per-accession FASTA requests was indistinguishable from an absent entry. All four HTTP call sites now go through aretry_with_backoff-wrapped helper that treats 4xx as permanent (not retried) and 5xx / timeouts / resets as transient. Symptom this fixes:03_compare_embeddings.ipynbfailed withValueError: 6 target .obs_names have no embeddingafter UniProt dropped six of sixteen requests; the notebook now runs unchanged. -
ProteinResolvercaches fetched sequences by accession. Accessions were already cached but sequences were not, so a layer sweep re-fetched every FASTA once per layer --03_compare_embeddings.ipynbmade 112 UniProt requests for 16 distinct proteins, which is what earned the throttling in the first place. Now 16, and the second pass over the same panel is free. -
ProteinResolver.get_canonical_sequences_batchlogs a counted warning naming the identifiers it could not resolve. A short dict was the only signal, and callers iterate what came back rather than what they asked for, so a failed fetch simply vanished and surfaced much later as a re-index error from the io layer. Same counted-drop contract asembpy.io._canon.canonicalize. -
Static embedding tables no longer claim a pooling they never applied.
geneptand the other precomputed lookup tables do no forward pass and have no tokens, but_embed_static_hf_to_resultrecorded the caller'spooling_strategy-- which defaults to"mean"whether or not it was asked for -- so provenance reportedpooling: "mean"and_default_keyput it in the key:X_emb__gene__genept__pool_mean. Both were false. Nowpooling=None, the same convention the cell path uses, and the key isX_emb__gene__genept. -
Molecule embeddings could not be attached back to an AnnData indexed by a non-canonical SMILES.
embed()canonicalises molecule ids on the way in (io/_canon.py,SCHEME["molecule"] == "canonical_smiles"), butbuild_aliaseshad nomoleculebranch, so the form the caller passed was never registered as an alias of the canonical string.to_anndatathen re-indexed the target by its own.obs_namesand raisedValueError: N target .obs_names have no embeddingfor molecules that had embedded perfectly well -- Kekule caffeine (CN1C=NC2=C1C(=O)N(C(=O)N2C)C) against its canonicalCn1c(=O)c2c(ncn2C)n(C)c1=O. The input form is now recorded under theinput_smilesalias, matching whatgene_symbolalready does for genes, so callers no longer have to canonicalise with RDKit before callingembed(). Guarded bytests/embpy/io/test_to_anndata_attach.py::{test_non_canonical_smiles_attaches_via_input_alias, test_canonical_smiles_still_attaches_directly, test_build_aliases_records_the_input_smiles}. -
MoleculeAnnotator.get_mechanism_of_actionreturned an empty list for any drug whose mechanism ChEMBL registers against a salt form. It filteredmechanismonmolecule_chembl_id, but drug-level rows hang off the parent molecule:mechanism?molecule_chembl_id=CHEMBL941(imatinib) returns zero rows because the mechanism belongs to imatinib mesylate (CHEMBL1642) and namesCHEMBL941only as its parent. Every drug-level query now resolves the molecule hierarchy and filters onparent_molecule_chembl_id, which for imatinib is the difference between 0 and 4 mechanisms, and between 52 and 134 indication rows. The failure was silent -- indistinguishable from "this compound has no curated mechanism". -
MoleculeAnnotator.get_mechanism_of_actionalways returnedtarget_nameas"". It readtarget_nameoff themechanismpayload, which carries onlytarget_chembl_id; the name is now resolved through thetargetendpoint. -
MoleculeAnnotator._resolve_to_smilesresolved every name toNone, soannotate()reported"Could not resolve identifier to SMILES"and everymol_*structural column came back empty for name-based input. PubChem renamed its SMILES properties without changing the request vocabulary: a request forCanonicalSMILESnow answers under the keyConnectivitySMILES, and one forIsomericSMILESunderSMILES, so reading back the name that was requested always missed. -
DrugResolver._extract_smilesdid not recognise PubChem's currentSMILESkey, soDrugResolver's direct name lookup always failed and fell through to the slower CID path.name_to_smiles_resolved("aspirin")reportedsource="pubchem_cid"where it should reportpubchem_name. Both the old and the current property names are now accepted. -
BioEmbedder.embed(adata, obs_column=..., output="anndata")(the README gene-perturbation Quick Start) now aligns rows on thetarget.obs[obs_column]labels instead ofobs_names, so barcoded cells each receive their perturbation's vector rather than raisingN target .obs_names have no embedding(or silently NaN-filling every row withmissing="nan").var_columndoes the same on the var axis. Unknown labels raise listing the labels (missing="error") or NaN-fill only their rows (missing="nan");obs_names/var_namesand the label column are left untouched. The column is threaded throughroute_output/to_anndata_many/to_anndataasobs_column/var_columnkeywords (defaultNone, unchanged behaviour). Guarded bytests/embpy/io/test_bioembedder_embed_standardized.py::test_embed_gene_perturbations_*obs_column*andtest_embed_genes_align_on_var_column_labels. -
molformer_basefailed to load on transformers 4.x (ImportError: cannot import name 'create_bidirectional_mask') becauseibm/MoLFormer-XL-both-10pctupdated itstrust_remote_codefiles for transformers 5. Everytrust_remote_codeload (GENA-LM, Nucleotide Transformer v1/v2, NTv3, HyenaDNA, Caduceus, MolFormer) now passesrevision=from the newembpy.models.hf_revisions.REMOTE_CODE_REVISIONSmapping ({hf_repo_id: commit_sha}, matched case-insensitively). Every repo except MolFormer is pinned to itsmainhead as of 2026-10-11, so its behaviour is frozen at that date. MolFormer is pinned to the last transformers-4-compatible commit, and on transformers 4.x only: that old commit importstransformers.onnx, which transformers 5 removed, so on transformers >= 5 (e.g. thentv3extra)molformer_baseloads frommainas before. Passrevision=to a wrapper to override the pin (BioEmbedderdoes not forward kwargs, so this only works for directly constructed wrappers). The gated NTv3 repos are left unpinned. Guarded bytests/embpy/models/test_hf_revisions.py. -
Gene sequences no longer vanish when Ensembl has a bad minute.
_ensembl_getretried only HTTP 429, so a single500 Server Errorfromrest.ensembl.orgmadeget_dna_sequencereturnNoneand the whole call fail withno embeddings were produced (all inputs failed)-- which is how01_embed_any_model.ipynbfailed twice on the HPC. 5xx responses, connection errors and timeouts are now retried with exponential backoff through the existingretry_with_backoffhelper (still honouringRetry-After); other 4xx are returned immediately.retry_with_backoffitself now honours aretry_afterattribute on the raised exception. Guarded bytests/embpy/test_gene_resolver_retry.pyandtests/embpy/test_retry.py; the exon-truncation tests now model a timeout that persists across retries. -
Llama text models (
llama3.1_8b,llama3.2_3b,llama3.2_1b) could load but never embed throughBioEmbedder.embed.LlamaEmbeddingWrapperoverrodeembed()but notembed_batch(), so the inheritedTextLLMWrapper.embed_batchreadoutputs.last_hidden_state, whichAutoModelForCausalLMoutputs do not have (AttributeError: 'CausalLMOutputWithPast' object has no attribute 'last_hidden_state'). Both now share one path that poolshidden_states, withlast_tokenpooling as the default for both. Loading no longer passesdevice_map, which on transformers 5 requiredaccelerate(in no extra); the model is moved with.to(device)on every device. Guarded bytests/embpy/models/test_api_models.py::TestLlamaEmbeddingWrapper*. -
esmc_*models without theesmSDK raisedNameError: name 'ESMC' is not defined(reported as a genericModelLoadError) instead of the typedDependencyErrornaming the install. The import guard now bindsESMC = NoneandESMCWrapper.loadraisesImportError, whichBioEmbedderturns intoDependencyError. Guarded bytests/embpy/models/test_protein_models.pyandtests/embpy/test_embedder.py.
pp.load_scperturb,pp.list_scperturb_datasets,pp.scperturb_infoandScPerturbDatasetCard. Downloading third-party perturbation datasets is not embpy's job -- it is a curated mirror of someone else's Zenodo record, which drifts independently of this package and has to be tracked by hand. It had also never worked: every filename in the registry was fabricated, so every call 404'd. Rather than maintain a dataset index alongside an embedding library, load the h5ad yourself and pass it toembed/embed_cells-- nothing else in embpy depended on this module.
ActionEmbeddingProvider.embed(symbols)-- replaced byembed_with_status(symbols). Scheduled for removal in 0.1.0.