pen-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pen_stack/__init__.py +2 -0
- pen_stack/_resources.py +34 -0
- pen_stack/active/__init__.py +20 -0
- pen_stack/active/acquire.py +165 -0
- pen_stack/active/brains.py +74 -0
- pen_stack/active/campaign.py +109 -0
- pen_stack/active/design.py +66 -0
- pen_stack/active/validate.py +104 -0
- pen_stack/adapt/__init__.py +14 -0
- pen_stack/adapt/finetune.py +33 -0
- pen_stack/adapt/ingest.py +86 -0
- pen_stack/adapt/pipeline.py +101 -0
- pen_stack/adapt/recalibrate.py +58 -0
- pen_stack/adapt/report.py +130 -0
- pen_stack/agent/__init__.py +1 -0
- pen_stack/agent/cite.py +175 -0
- pen_stack/agent/co_scientist.py +262 -0
- pen_stack/agent/epistemic.py +102 -0
- pen_stack/agent/guardrails.py +67 -0
- pen_stack/agent/mcp_server.py +215 -0
- pen_stack/agent/orchestrator.py +112 -0
- pen_stack/agent/orchestrator_live.py +56 -0
- pen_stack/agent/pen_agent.py +242 -0
- pen_stack/agent/scope.py +60 -0
- pen_stack/agent/tools.py +130 -0
- pen_stack/api/__init__.py +12 -0
- pen_stack/api/manifest.py +160 -0
- pen_stack/atlas/__init__.py +1 -0
- pen_stack/atlas/atlas.parquet +0 -0
- pen_stack/atlas/build_wtkb.py +80 -0
- pen_stack/atlas/crosslink.py +179 -0
- pen_stack/atlas/expand.py +190 -0
- pen_stack/atlas/guide_design.py +178 -0
- pen_stack/atlas/schema.py +59 -0
- pen_stack/atlas/scorecard.py +134 -0
- pen_stack/atlas/scorecard_v3.parquet +0 -0
- pen_stack/atlas/universe.py +75 -0
- pen_stack/atlas/universe_v3.parquet +0 -0
- pen_stack/atlas/variant_propose.py +155 -0
- pen_stack/atlas/writer_efficiency.py +184 -0
- pen_stack/atlas/writer_predict.py +229 -0
- pen_stack/atlas/writer_recommend.py +170 -0
- pen_stack/atlas/writer_verify.py +167 -0
- pen_stack/atlas/wtkb.parquet +0 -0
- pen_stack/bridge/__init__.py +1 -0
- pen_stack/bridge/activity.py +52 -0
- pen_stack/bridge/cli.py +65 -0
- pen_stack/bridge/fold_qc.py +53 -0
- pen_stack/bridge/guide_qc.py +87 -0
- pen_stack/bridge/ingest.py +139 -0
- pen_stack/bridge/offtarget.py +191 -0
- pen_stack/bridge/offtarget_energetics.py +105 -0
- pen_stack/bridge/ortholog_screen.py +73 -0
- pen_stack/bridge/pipeline.py +83 -0
- pen_stack/build/__init__.py +16 -0
- pen_stack/build/cloudlab.py +74 -0
- pen_stack/build/ingest.py +47 -0
- pen_stack/build/protocol.py +82 -0
- pen_stack/build/simlab.py +30 -0
- pen_stack/cli.py +126 -0
- pen_stack/data/__init__.py +1 -0
- pen_stack/data/encode.py +84 -0
- pen_stack/data/genome.py +71 -0
- pen_stack/data/ingest_chromatin.py +119 -0
- pen_stack/data/ingest_integration.py +112 -0
- pen_stack/data/ingest_safety_annot.py +201 -0
- pen_stack/data/ingest_trip.py +76 -0
- pen_stack/design/__init__.py +14 -0
- pen_stack/design/capsid_generate.py +62 -0
- pen_stack/design/generate.py +70 -0
- pen_stack/design/pareto.py +70 -0
- pen_stack/design/space.py +137 -0
- pen_stack/design/writer_variants.py +121 -0
- pen_stack/env/__init__.py +1 -0
- pen_stack/env/genome_writing_env.py +248 -0
- pen_stack/env/policies.py +94 -0
- pen_stack/graph/__init__.py +21 -0
- pen_stack/graph/build.py +133 -0
- pen_stack/graph/cell_types.py +58 -0
- pen_stack/graph/ingest.py +132 -0
- pen_stack/graph/query.py +148 -0
- pen_stack/graph/schema.py +100 -0
- pen_stack/loop/__init__.py +15 -0
- pen_stack/loop/continual.py +61 -0
- pen_stack/loop/cycle.py +84 -0
- pen_stack/loop/drift.py +41 -0
- pen_stack/mech/__init__.py +1 -0
- pen_stack/mech/classify_atlas.py +71 -0
- pen_stack/mech/pfam_whitelist.yaml +247 -0
- pen_stack/mech/whitelist.py +66 -0
- pen_stack/monitor/__init__.py +1 -0
- pen_stack/monitor/europepmc.py +32 -0
- pen_stack/monitor/run.py +57 -0
- pen_stack/monitor/triage.py +63 -0
- pen_stack/oracles/__init__.py +65 -0
- pen_stack/oracles/affinity.py +116 -0
- pen_stack/oracles/cache.py +53 -0
- pen_stack/oracles/energetics.py +33 -0
- pen_stack/oracles/genome.py +167 -0
- pen_stack/oracles/protein_design.py +136 -0
- pen_stack/oracles/reliability.py +64 -0
- pen_stack/oracles/rna.py +28 -0
- pen_stack/oracles/schema.py +77 -0
- pen_stack/oracles/status.py +123 -0
- pen_stack/oracles/structure.py +42 -0
- pen_stack/oracles/structure_run.py +76 -0
- pen_stack/oracles/vcell.py +74 -0
- pen_stack/planner/__init__.py +1 -0
- pen_stack/planner/ada_risk.py +64 -0
- pen_stack/planner/antipeg_oracle.py +75 -0
- pen_stack/planner/capsid_epitope_oracle.py +135 -0
- pen_stack/planner/cargo.py +56 -0
- pen_stack/planner/cargo_polish.py +146 -0
- pen_stack/planner/chromosome.py +106 -0
- pen_stack/planner/delivery.py +55 -0
- pen_stack/planner/delivery_constraints.py +110 -0
- pen_stack/planner/delivery_immune.py +61 -0
- pen_stack/planner/delivery_immunology.py +222 -0
- pen_stack/planner/delivery_predict.py +196 -0
- pen_stack/planner/delivery_vehicles.py +37 -0
- pen_stack/planner/genotoxicity_oracle.py +112 -0
- pen_stack/planner/immune_mhc2.py +154 -0
- pen_stack/planner/immune_profile.py +292 -0
- pen_stack/planner/innate_sensing.py +135 -0
- pen_stack/planner/multiplex.py +110 -0
- pen_stack/planner/optimize.py +278 -0
- pen_stack/planner/pipeline.py +87 -0
- pen_stack/planner/report.py +26 -0
- pen_stack/planner/router.py +57 -0
- pen_stack/planner/seroprevalence_oracle.py +92 -0
- pen_stack/planner/target_site.py +118 -0
- pen_stack/rag/__init__.py +1 -0
- pen_stack/rag/corpus.py +133 -0
- pen_stack/rag/embed.py +98 -0
- pen_stack/rag/ground.py +131 -0
- pen_stack/rag/index.py +53 -0
- pen_stack/rag/llm.py +215 -0
- pen_stack/rag/qa.py +105 -0
- pen_stack/rag/retrieve.py +48 -0
- pen_stack/rules/__init__.py +9 -0
- pen_stack/rules/evaluators.py +318 -0
- pen_stack/rules/loader.py +31 -0
- pen_stack/rules/schema.py +99 -0
- pen_stack/rules/solver.py +43 -0
- pen_stack/rules/spec.py +78 -0
- pen_stack/safety/__init__.py +21 -0
- pen_stack/safety/audit.py +90 -0
- pen_stack/safety/gate.py +58 -0
- pen_stack/safety/pfam_scan.py +157 -0
- pen_stack/safety/policy.py +69 -0
- pen_stack/safety/redteam.py +71 -0
- pen_stack/safety/registry.py +255 -0
- pen_stack/safety/screen.py +59 -0
- pen_stack/safety/standards.py +141 -0
- pen_stack/score/__init__.py +1 -0
- pen_stack/score/recalibrate.py +77 -0
- pen_stack/score/therapeutic.py +85 -0
- pen_stack/server/__init__.py +1 -0
- pen_stack/server/api.py +647 -0
- pen_stack/spec/__init__.py +18 -0
- pen_stack/spec/clarify.py +42 -0
- pen_stack/spec/extract.py +406 -0
- pen_stack/spec/resolvers/__init__.py +17 -0
- pen_stack/spec/resolvers/cell.py +51 -0
- pen_stack/spec/resolvers/chem.py +32 -0
- pen_stack/spec/resolvers/feature.py +36 -0
- pen_stack/spec/resolvers/gene.py +43 -0
- pen_stack/spec/resolvers/locus.py +29 -0
- pen_stack/spec/resolvers/phenotype.py +37 -0
- pen_stack/spec/satisfy.py +114 -0
- pen_stack/spec/service.py +33 -0
- pen_stack/spec/writespec.py +252 -0
- pen_stack/twin/__init__.py +14 -0
- pen_stack/twin/calibrate.py +61 -0
- pen_stack/twin/data/__init__.py +12 -0
- pen_stack/twin/data/position_effect.py +245 -0
- pen_stack/twin/mechanistic.py +147 -0
- pen_stack/twin/outcome.py +132 -0
- pen_stack/twin/position_effect.py +454 -0
- pen_stack/ui/__init__.py +1 -0
- pen_stack/ui/app.py +713 -0
- pen_stack/validate/__init__.py +1 -0
- pen_stack/validate/adapt_demo.py +69 -0
- pen_stack/validate/agent_eval.py +117 -0
- pen_stack/validate/bench_adversarial_tasks.py +118 -0
- pen_stack/validate/bench_coscientist_tasks.py +60 -0
- pen_stack/validate/bench_graph_tasks.py +64 -0
- pen_stack/validate/bench_rule_tasks.py +84 -0
- pen_stack/validate/bench_trust_tasks.py +92 -0
- pen_stack/validate/bench_writetype_tasks.py +101 -0
- pen_stack/validate/blind_gsh_discovery.py +261 -0
- pen_stack/validate/cargo_directionality.py +57 -0
- pen_stack/validate/closed_loop.py +63 -0
- pen_stack/validate/durability_baselines.py +185 -0
- pen_stack/validate/experiment_design.py +65 -0
- pen_stack/validate/expr_controls.py +39 -0
- pen_stack/validate/forward_hypotheses.py +104 -0
- pen_stack/validate/generative_design.py +62 -0
- pen_stack/validate/guide_qc_demo.py +69 -0
- pen_stack/validate/heldout_celltype_expr.py +32 -0
- pen_stack/validate/immune_calibration.py +133 -0
- pen_stack/validate/intent_specification.py +82 -0
- pen_stack/validate/known_biology_expr.py +38 -0
- pen_stack/validate/offtarget_energetics_eval.py +144 -0
- pen_stack/validate/out_of_scope_refusal.py +82 -0
- pen_stack/validate/outcome_calibration.py +194 -0
- pen_stack/validate/outcome_prediction.py +76 -0
- pen_stack/validate/paper3_benchmark.py +165 -0
- pen_stack/validate/paper4_real_validation.py +144 -0
- pen_stack/validate/paper4_validation.py +82 -0
- pen_stack/validate/protocol_safety.py +62 -0
- pen_stack/validate/safety_screening.py +72 -0
- pen_stack/validate/selective_prediction.py +104 -0
- pen_stack/validate/seq_vs_measured.py +134 -0
- pen_stack/validate/target_site_controls.py +65 -0
- pen_stack/validate/uncertainty_eval.py +244 -0
- pen_stack/validate/ungrounded_baseline.py +234 -0
- pen_stack/validate/within_locus_ranking.py +84 -0
- pen_stack/validate/writer_recovery.py +91 -0
- pen_stack/verify/__init__.py +5 -0
- pen_stack/verify/proof.py +206 -0
- pen_stack/verify/schema.py +53 -0
- pen_stack/verify/service.py +191 -0
- pen_stack/web/__init__.py +18 -0
- pen_stack/web/guide.py +110 -0
- pen_stack/web/llm.py +393 -0
- pen_stack/web/llm_provider.py +119 -0
- pen_stack/web/router.py +119 -0
- pen_stack/web/server.py +96 -0
- pen_stack/web/tools.py +197 -0
- pen_stack/wgenome/__init__.py +1 -0
- pen_stack/wgenome/chromatin_seq.py +83 -0
- pen_stack/wgenome/durability.py +108 -0
- pen_stack/wgenome/export_tracks.py +52 -0
- pen_stack/wgenome/features.py +82 -0
- pen_stack/wgenome/genotoxic_blocklist.py +88 -0
- pen_stack/wgenome/gsh_baseline.py +154 -0
- pen_stack/wgenome/mesh_features.py +61 -0
- pen_stack/wgenome/offtarget_assay.py +80 -0
- pen_stack/wgenome/offtarget_bridge.py +47 -0
- pen_stack/wgenome/offtarget_cast.py +97 -0
- pen_stack/wgenome/offtarget_data.py +148 -0
- pen_stack/wgenome/offtarget_enumerate.py +274 -0
- pen_stack/wgenome/offtarget_integrase.py +155 -0
- pen_stack/wgenome/offtarget_nuclease.py +123 -0
- pen_stack/wgenome/offtarget_paste.py +41 -0
- pen_stack/wgenome/offtarget_predict.py +282 -0
- pen_stack/wgenome/ood.py +135 -0
- pen_stack/wgenome/providers.py +278 -0
- pen_stack/wgenome/safety.py +69 -0
- pen_stack/wgenome/structure3d.py +212 -0
- pen_stack/wgenome/uncertainty.py +250 -0
- pen_stack/wgenome/writability.py +72 -0
- pen_stack-0.1.0.dist-info/METADATA +401 -0
- pen_stack-0.1.0.dist-info/RECORD +259 -0
- pen_stack-0.1.0.dist-info/WHEEL +5 -0
- pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
- pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pen_stack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Nuclease off-target FINDER: enumerate -> CRISOT -> risk -> chromatin.
|
|
2
|
+
|
|
3
|
+
Chains the genome-wide enumeration into the existing, validated nuclease scorer, so a GUIDE returns a
|
|
4
|
+
genome-wide ranked off-target set rather than a hand-supplied one. The scoring is
|
|
5
|
+
UNCHANGED from the prior release, the real CRISOT-Score, the mismatch-calibrated risk band, and the chromatin annotation
|
|
6
|
+
(validated, not a re-ranker). v2 only adds the enumeration front end. Status: **validated** (CRISOT beats
|
|
7
|
+
homology on four unbiased assays; enumeration reproduces the documented off-target set).
|
|
8
|
+
|
|
9
|
+
Enumeration runs on the VM; this surface replays the committed cache or abstains (so the finder works for the
|
|
10
|
+
cached guides and abstains for a novel one), exactly like the heavy oracles.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pen_stack.wgenome.offtarget_data import BENCH_SUMMARY, CHROMATIN_VALIDATION, calibrated_active_fraction
|
|
15
|
+
from pen_stack.wgenome.offtarget_enumerate import _ENZYME, enumerate_offtargets, resolve_enzyme
|
|
16
|
+
from pen_stack.wgenome.offtarget_predict import (
|
|
17
|
+
_chromatin_modifier,
|
|
18
|
+
_crisot_cache,
|
|
19
|
+
_ham20,
|
|
20
|
+
_risk_band,
|
|
21
|
+
locus_accessibility,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_BIN = 1000 # accessibility track bins are 1 kb (matches the off-target chromatin validation)
|
|
25
|
+
_CAS12A = {"AsCas12a", "LbCas12a"} # enumeration-supported, but the CRISOT scorer + risk calibration are SpCas9-only
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def unsupported_nuclease_abstention(enzyme: str | None, mode: str = "finder") -> dict | None:
|
|
29
|
+
"""Return an abstention when no validated off-target model covers ``enzyme``, else None.
|
|
30
|
+
|
|
31
|
+
The off-target scorer (CRISOT-Score) and the mismatch-calibrated empirical risk band are both SpCas9-specific,
|
|
32
|
+
so every nuclease path declines rather than apply a SpCas9 model to a different enzyme. Two cases decline. A
|
|
33
|
+
Cas12a variant can be ENUMERATED but not scored, so it carries the Cas12a note. Any other named enzyme the
|
|
34
|
+
registry does not carry would otherwise be substituted by SpCas9 and returned under the caller's name, with a
|
|
35
|
+
PAM the caller never asked for, so it declines with a note naming what is carried. Only an unnamed enzyme falls
|
|
36
|
+
through to the SpCas9 default.
|
|
37
|
+
|
|
38
|
+
The gate is shared by the genome-wide finder and the score-my-candidates path so the two cannot drift: a caller
|
|
39
|
+
who supplies candidate sites gets the same abstention as a caller who does not.
|
|
40
|
+
"""
|
|
41
|
+
requested = (enzyme or "").strip()
|
|
42
|
+
enz = resolve_enzyme(requested)
|
|
43
|
+
if enz in _CAS12A:
|
|
44
|
+
status, note = "unvalidated", (
|
|
45
|
+
"Cas12a genome-wide enumeration is supported (Cas-OFFinder, TTTV 5' PAM), but the validated off-target "
|
|
46
|
+
"scorer (CRISOT-Score) and the mismatch-calibrated empirical risk band are SpCas9-specific. No "
|
|
47
|
+
"validated Cas12a off-target model is mounted, so this path does not rank Cas12a sites with a SpCas9 "
|
|
48
|
+
"scorer, which would mis-rank them. AsCas12a is listed as a Tier-1 nuclease in the Writer Atlas; a "
|
|
49
|
+
"validated Cas12a off-target scorer is future work.")
|
|
50
|
+
elif requested and enz is None:
|
|
51
|
+
status, note = "unsupported_enzyme", (
|
|
52
|
+
f"No validated off-target model is mounted for {requested!r}. The enumerator carries "
|
|
53
|
+
f"{', '.join(sorted(_ENZYME))}, each with its own PAM, and the scorer (CRISOT-Score) with its "
|
|
54
|
+
f"mismatch-calibrated risk band is SpCas9-specific. Scoring this request would report SpCas9 sites "
|
|
55
|
+
f"under a different enzyme's name and PAM.")
|
|
56
|
+
else:
|
|
57
|
+
return None
|
|
58
|
+
return {"family": "nuclease", "enzyme": enz or requested or None, "available": False, "abstain": True,
|
|
59
|
+
"mode": mode, "status": status, "cached_guides": [], "nomination_is_not_clearance": True, "note": note}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_nuclease_offtargets(guide: str, enzyme: str = "SpCas9", max_mismatch: int = 5, assay: str = "guideseq",
|
|
63
|
+
cell_type: str = "k562", top: int = 50) -> dict:
|
|
64
|
+
"""Genome-wide nuclease off-target FINDER. Enumerates every genomic site within ``max_mismatch`` for the guide
|
|
65
|
+
(Cas-OFFinder over GRCh38, replayed from cache), scores each with the real CRISOT-Score + the mismatch-
|
|
66
|
+
calibrated empirical risk band, and annotates chromatin accessibility (from the accessibility track when present). Returns
|
|
67
|
+
the genome-wide ranked candidates with coordinates, or an abstention when the guide is not cached and no
|
|
68
|
+
VM scan is available. Never fabricates sites."""
|
|
69
|
+
declined = unsupported_nuclease_abstention(enzyme, mode="finder")
|
|
70
|
+
if declined is not None:
|
|
71
|
+
return declined
|
|
72
|
+
enz = resolve_enzyme(enzyme) or "SpCas9"
|
|
73
|
+
enum = enumerate_offtargets(guide, enz, max_mismatch)
|
|
74
|
+
if enum.get("abstain"):
|
|
75
|
+
return {"family": "nuclease", "enzyme": enz, "available": False, "abstain": True, "mode": "finder",
|
|
76
|
+
"status": "validated", "note": enum["note"], "cached_guides": enum.get("cached_guides", []),
|
|
77
|
+
"nomination_is_not_clearance": True}
|
|
78
|
+
g20 = enum["guide"][:20]
|
|
79
|
+
cache = _crisot_cache()
|
|
80
|
+
noms = []
|
|
81
|
+
for s in enum["sites"]:
|
|
82
|
+
seq = s["sequence"].upper()
|
|
83
|
+
nmm = int(s.get("n_mismatch", _ham20(g20, seq)))
|
|
84
|
+
af = calibrated_active_fraction(nmm, assay)
|
|
85
|
+
crisot = cache.get((g20, seq[:20]))
|
|
86
|
+
locus_acc = locus_accessibility(s["chrom"], s["position"] // _BIN, cell_type) # REAL accessibility track (or None)
|
|
87
|
+
noms.append({"chrom": s["chrom"], "position": s["position"], "strand": s["strand"], "site": seq,
|
|
88
|
+
"n_mismatch": nmm, "empirical_active_fraction": af, "risk_band": _risk_band(af),
|
|
89
|
+
"crisot_score": crisot,
|
|
90
|
+
"crisot_source": "cached_bench" if crisot is not None else "on-VM tool (not cached)",
|
|
91
|
+
"chromatin": _chromatin_modifier(None, locus_acc), "output_kind": "candidate"})
|
|
92
|
+
# rank by the real CRISOT score where cached, else by empirical risk (descending), then fewer mismatches
|
|
93
|
+
noms.sort(key=lambda n: (n["crisot_score"] if n["crisot_score"] is not None else -1.0,
|
|
94
|
+
n["empirical_active_fraction"] or 0.0, -n["n_mismatch"]), reverse=True)
|
|
95
|
+
n_on = sum(1 for n in noms if n["n_mismatch"] == 0)
|
|
96
|
+
# chromatin is a REAL accessibility annotation, populated per-site only when the accessibility track is mounted.
|
|
97
|
+
# Be explicit instead of silently returning null everywhere (which reads as if the layer were delivered).
|
|
98
|
+
chromatin_available = any(n.get("chromatin") is not None for n in noms)
|
|
99
|
+
_cv = CHROMATIN_VALIDATION["auroc_accessibility_for_activity"]["guideseq"]
|
|
100
|
+
chromatin_note = (
|
|
101
|
+
"Chromatin accessibility is a VALIDATED standalone annotation (open chromatin predicts cell-based "
|
|
102
|
+
"off-target activity: GUIDE-seq AUROC 0.671, cell-type-matched HEK293T DNase) but is ANNOTATION-ONLY, it "
|
|
103
|
+
"does NOT change the CRISOT-driven ranking. It is populated per-site only when the accessibility "
|
|
104
|
+
"track (chromatin_{ct}.parquet) is mounted; that track is not present on this deployment, so the per-site "
|
|
105
|
+
"column is omitted (ranking unaffected)." if not chromatin_available else
|
|
106
|
+
"Per-site chromatin accessibility from the mounted track, annotation-only, does not change the "
|
|
107
|
+
"CRISOT-driven ranking.")
|
|
108
|
+
return {"family": "nuclease", "enzyme": enz, "available": True, "abstain": False, "mode": "finder",
|
|
109
|
+
"guide": g20, "pam": enum["pam"], "source": enum["source"], "max_mismatch": int(max_mismatch),
|
|
110
|
+
"n_sites_genome_wide": enum["n_sites"], "n_on_target": n_on, "n_offtargets": len(noms) - n_on,
|
|
111
|
+
"assay_calibration": assay, "nominations": noms[:top], "bench": BENCH_SUMMARY.get(assay),
|
|
112
|
+
"status": "validated",
|
|
113
|
+
"chromatin_available": chromatin_available, "chromatin_note": chromatin_note,
|
|
114
|
+
"chromatin_validation": {"verdict": CHROMATIN_VALIDATION["verdict"], "guideseq_auroc": _cv["auroc"],
|
|
115
|
+
"ci95": _cv["ci95"], "matched_track": CHROMATIN_VALIDATION["matched_track"],
|
|
116
|
+
"is_reranker": False},
|
|
117
|
+
"method": ("genome-wide Cas-OFFinder enumeration over GRCh38 -> real CRISOT-Score -> mismatch-"
|
|
118
|
+
"calibrated risk band" + (" -> chromatin accessibility annotation (validated, annotation-"
|
|
119
|
+
"only, NOT a re-ranker)" if chromatin_available else
|
|
120
|
+
"; chromatin accessibility is a validated annotation surfaced when the accessibility track is "
|
|
121
|
+
"mounted (annotation-only, not a re-ranker)")),
|
|
122
|
+
"honesty": "genome-wide CANDIDATES, NOT a clearance; confirm with the recommended empirical assay",
|
|
123
|
+
"nomination_is_not_clearance": True}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""PASTE / (ee)PASSIGE off-target composition.
|
|
2
|
+
|
|
3
|
+
PASTE installs a landing-site attB with a prime editor (a Cas9 **nickase** + reverse transcriptase directed by a
|
|
4
|
+
pegRNA), then a serine **integrase** (Bxb1) inserts the cargo at the installed attB. Its off-target profile is
|
|
5
|
+
therefore the COMPOSITION of two mechanisms, each with its own status:
|
|
6
|
+
* the Cas9-nickase off-targets of the pegRNA spacer -> the validated nuclease finder, and
|
|
7
|
+
* the integrase pseudo-attP sites of the installed att -> the semi-validated integrase scan.
|
|
8
|
+
|
|
9
|
+
Returns BOTH candidate sets, labelled by component, and recommends BOTH a nuclease assay and an integrase assay.
|
|
10
|
+
Never fabricates; each component carries its own truthful status.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pen_stack.wgenome.offtarget_integrase import nominate_integrase
|
|
15
|
+
from pen_stack.wgenome.offtarget_nuclease import find_nuclease_offtargets
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def nominate_paste(guide: str | None = None, integrase: str = "Bxb1", max_mismatch: int = 5,
|
|
19
|
+
assay: str = "guideseq", cell_type: str = "k562", top: int = 20) -> dict:
|
|
20
|
+
"""Compose the nuclease (pegRNA-directed Cas9 nickase) and integrase (installed attB) off-target components.
|
|
21
|
+
Returns both candidate sets with their own statuses + the dual confirm-assay recommendation. Abstains per
|
|
22
|
+
component when its input/cache is unavailable (never fabricates)."""
|
|
23
|
+
nuclease = (find_nuclease_offtargets(guide, "SpCas9", max_mismatch=max_mismatch, assay=assay,
|
|
24
|
+
cell_type=cell_type, top=top)
|
|
25
|
+
if guide else {"available": False, "abstain": True, "status": "validated",
|
|
26
|
+
"note": "provide the pegRNA spacer (guide) for the Cas9-nickase off-target scan"})
|
|
27
|
+
integ = nominate_integrase(integrase, top=top)
|
|
28
|
+
statuses = {"nuclease_component": nuclease.get("status", "validated"),
|
|
29
|
+
"integrase_component": integ.get("status", "mechanism_based_unvalidated")}
|
|
30
|
+
return {"family": "paste", "available": True, "abstain": False, "status": "composite",
|
|
31
|
+
"component_statuses": statuses,
|
|
32
|
+
"nuclease_component": nuclease, # Cas9-nickase off-targets (pegRNA-directed), validated finder
|
|
33
|
+
"integrase_component": integ, # installed-attB pseudo-attP, semi-validated scan
|
|
34
|
+
"confirm_assay": {"nuclease": ["GUIDE-seq", "CHANGE-seq"], "integrase": ["Cryptic-seq", "HIDE-seq"],
|
|
35
|
+
"note": "PASTE requires BOTH a nuclease off-target assay (for the nickase) AND an "
|
|
36
|
+
"integrase off-target assay (for the installed att)."},
|
|
37
|
+
"method": ("compose the validated nuclease finder (nickase, pegRNA spacer) with the semi-validated "
|
|
38
|
+
"integrase pseudo-attP scan (installed att); return both candidate sets by component"),
|
|
39
|
+
"honesty": "two component candidate sets, each with its own status; NOT a clearance. Confirm with "
|
|
40
|
+
"both a nuclease assay and an integrase assay.",
|
|
41
|
+
"nomination_is_not_clearance": True}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Cross-writer-family, chromatin-aware off-target NOMINATION engine.
|
|
2
|
+
|
|
3
|
+
One interface over nucleases, serine integrases, and bridge recombinases. By construction:
|
|
4
|
+
|
|
5
|
+
* **nuclease** (Cas9): rank candidate off-target sites by the GROUNDED empirical risk (the real-data fraction
|
|
6
|
+
of candidates at k mismatches that were validated-active, `offtarget_data.MISMATCH_ACTIVE_FRACTION`), surface
|
|
7
|
+
the REAL CRISOT-Score when the (guide, site) is in the cached bench, and annotate with a documented
|
|
8
|
+
**chromatin-accessibility signal** (a validated annotation, NOT a re-ranker: open chromatin raises realized
|
|
9
|
+
off-target activity, Lazzarotto 2020, but it added no held-out ranking gain over CRISOT in an
|
|
10
|
+
incremental analysis, so it is not folded into the risk score). The annotation
|
|
11
|
+
reads the **chromatin accessibility track** (`phase_1/features/chromatin_{ct}.parquet`) when a candidate's
|
|
12
|
+
genomic locus + cell type are supplied AND the feature store is present, or accepts a caller-supplied
|
|
13
|
+
accessibility value; it **abstains** otherwise (the bare wheel / current deployed atlas do not ship the raw
|
|
14
|
+
accessibility track). The Off-Target-Bench shows the real CRISOT predictor BEATS the homology baseline on
|
|
15
|
+
FOUR unbiased assays (GUIDE/CIRCLE/CHANGE/SITE-seq; per-guide bootstrap CI excludes 0 on each).
|
|
16
|
+
* **serine integrase** (Bxb1): nominate cryptic **pseudo-attB** sites using the REAL documented Bxb1 attB core
|
|
17
|
+
(GCGGTCTC / central GT), candidate-flagged; the validating assay is Cryptic-seq/HIDE-seq.
|
|
18
|
+
* **bridge / seek**: delegate to the existing Perry-DMS pseudosite engine (`pen_stack.bridge.offtarget`).
|
|
19
|
+
|
|
20
|
+
A nominated off-target is a CANDIDATE, never a claim, and nomination is **not** a safety clearance, every result
|
|
21
|
+
ships with the empirical assay that would confirm it (`offtarget_assay`). Abstains without inputs; never invents
|
|
22
|
+
sites. The CRISOT predictor (CC-BY-NC) runs only on the VM; only derived scores are cached.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
|
|
28
|
+
from pen_stack.wgenome.offtarget_data import (
|
|
29
|
+
BENCH_SUMMARY,
|
|
30
|
+
bench_records,
|
|
31
|
+
calibrated_active_fraction,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_NUCLEASE = {"cas9", "spcas9", "nuclease", "sacas9", "ascas12a", "cas12a", "nickase"}
|
|
35
|
+
_INTEGRASE = {"serine_integrase", "bxb1", "phic31", "pe_integrase"}
|
|
36
|
+
_BRIDGE = {"bridge_is110", "seek_is1111", "bridge", "is110", "is621", "iscro4"}
|
|
37
|
+
_PASTE = {"paste", "passige", "ee_passige", "eepassige", "prime_editing_integrase"}
|
|
38
|
+
_CAST = {"cast", "shcast", "vchcast", "evocast", "cast_vk", "cast_v-k", "cast_if", "cas12k", "type_v-k", "type_i-f"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ham20(a: str, b: str) -> int:
|
|
42
|
+
"""Mismatches over the 20-nt protospacer (Hamming; ignores the PAM)."""
|
|
43
|
+
a, b = a[:20].upper(), b[:20].upper()
|
|
44
|
+
return sum(1 for x, y in zip(a, b) if x != y)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _risk_band(active_fraction: float | None) -> str:
|
|
48
|
+
if active_fraction is None:
|
|
49
|
+
return "uncalibrated"
|
|
50
|
+
if active_fraction >= 0.5:
|
|
51
|
+
return "high"
|
|
52
|
+
if active_fraction >= 0.1:
|
|
53
|
+
return "medium"
|
|
54
|
+
if active_fraction >= 0.01:
|
|
55
|
+
return "low"
|
|
56
|
+
return "minimal"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@lru_cache(maxsize=1)
|
|
60
|
+
def _crisot_cache() -> dict:
|
|
61
|
+
"""(On20, Off20) -> cached real CRISOT score, from the committed bench fixture (VM-computed)."""
|
|
62
|
+
out: dict = {}
|
|
63
|
+
for r in bench_records():
|
|
64
|
+
out[(r["On"][:20].upper(), r["Off"][:20].upper())] = r["crisot_score"]
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@lru_cache(maxsize=8)
|
|
69
|
+
def _chromatin_store(ct: str):
|
|
70
|
+
"""The per-cell-type chromatin feature store (chrom,bin -> unified accessibility) + its median, or
|
|
71
|
+
None when the raw track is not present (bare wheel / CI / current deployed atlas). Real, data-gated."""
|
|
72
|
+
try:
|
|
73
|
+
import pandas as pd
|
|
74
|
+
|
|
75
|
+
from pen_stack._resources import project_root
|
|
76
|
+
from pen_stack.wgenome.features import add_accessibility
|
|
77
|
+
p = project_root() / "phase_1" / "features" / f"chromatin_{ct.lower()}.parquet"
|
|
78
|
+
if not p.exists():
|
|
79
|
+
return None
|
|
80
|
+
df = add_accessibility(pd.read_parquet(p))
|
|
81
|
+
if "accessibility" not in df.columns:
|
|
82
|
+
return None
|
|
83
|
+
med = float(df["accessibility"].median())
|
|
84
|
+
return df.set_index(["chrom", "bin"]), med
|
|
85
|
+
except Exception: # noqa: BLE001
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def locus_accessibility(chrom: str, bin_idx: int, ct: str = "k562") -> dict | None:
|
|
90
|
+
"""REAL chromatin accessibility for a genomic bin (ATAC/DNase), or None (abstain) when the chromatin feature
|
|
91
|
+
store is absent or the bin is off-grid. `accessible` = above the cell-type median (relative, grounded)."""
|
|
92
|
+
store = _chromatin_store(ct)
|
|
93
|
+
if store is None:
|
|
94
|
+
return None
|
|
95
|
+
df, med = store
|
|
96
|
+
try:
|
|
97
|
+
val = float(df.loc[(chrom, int(bin_idx)), "accessibility"])
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
return None
|
|
100
|
+
return {"accessibility_signal": round(val, 4), "cell_type": ct, "median": round(med, 4),
|
|
101
|
+
"accessible": bool(val >= med), "source": "chromatin_{ct}.parquet (ATAC/DNase)"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _chromatin_modifier(accessibility: float | None = None, locus_acc: dict | None = None) -> dict | None:
|
|
105
|
+
"""Documented chromatin-accessibility ANNOTATION (NOT a validated quantitative axis): open chromatin raises
|
|
106
|
+
realized off-target activity (Lazzarotto et al. 2020, CHANGE-seq, 10.1038/s41587-020-0555-7). Uses the REAL
|
|
107
|
+
chromatin accessibility (`locus_acc`) when available, else a caller-supplied 0..1 scalar; abstains (None) when
|
|
108
|
+
neither is given. It is QUALITATIVE and does NOT change the numeric risk score, a controlled test on our data
|
|
109
|
+
found the signal weak/inconsistent (see `offtarget_data.CHROMATIN_VALIDATION`)."""
|
|
110
|
+
val = {"validated": True, "effect_size": "moderate", "changes_risk_score": False,
|
|
111
|
+
"validation": "validated standalone (moderate, cell-type-matched: GUIDE-seq off-target AUROC 0.58 "
|
|
112
|
+
"cross-cell -> 0.671 matched HEK293T DNase). ANNOTATION ONLY, tested the incremental value over "
|
|
113
|
+
"CRISOT: small real conditional signal but NO held-out ranking improvement, so it does NOT change the "
|
|
114
|
+
"numeric risk score (CRISOT sequence score captures the ranking).",
|
|
115
|
+
"doi": "10.1038/s41587-020-0555-7"}
|
|
116
|
+
if locus_acc is not None:
|
|
117
|
+
raises = bool(locus_acc.get("accessible"))
|
|
118
|
+
return {**val, "raises_realized_risk": raises, "source": "stage_b_track",
|
|
119
|
+
"accessibility_signal": locus_acc.get("accessibility_signal"), "cell_type": locus_acc.get("cell_type"),
|
|
120
|
+
"effect": ("accessible (open) chromatin in this cell type -> realized off-target activity tends "
|
|
121
|
+
"HIGHER for the same sequence match (qualitative; Lazzarotto 2020)" if raises
|
|
122
|
+
else "inaccessible (closed) chromatin -> realized off-target activity tends lower")}
|
|
123
|
+
if accessibility is None:
|
|
124
|
+
return None
|
|
125
|
+
acc = max(0.0, min(1.0, float(accessibility)))
|
|
126
|
+
raises = acc >= 0.5
|
|
127
|
+
return {**val, "accessibility": round(acc, 3), "raises_realized_risk": raises, "source": "caller_supplied",
|
|
128
|
+
"effect": ("open/accessible chromatin -> realized off-target activity tends HIGHER (qualitative; "
|
|
129
|
+
"Lazzarotto 2020)" if raises else "closed/inaccessible chromatin -> realized risk tends lower")}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def nominate_nuclease(guide: str, candidate_sites: list[str] | None, accessibility: list[float] | None = None,
|
|
133
|
+
assay: str = "guideseq", loci: list | None = None, cell_type: str = "k562",
|
|
134
|
+
top: int = 20) -> dict:
|
|
135
|
+
"""Nominate + rank candidate nuclease off-target sites for a guide. ``candidate_sites`` are 20-23-nt genomic
|
|
136
|
+
protospacers (e.g. from a Cas-OFFinder scan). ``loci`` (optional) is a per-candidate (chrom, bin) for the REAL
|
|
137
|
+
chromatin-accessibility modifier; ``accessibility`` (optional) is a per-candidate 0..1 fallback scalar.
|
|
138
|
+
Abstains (no fabrication) when no candidates are supplied, genome-wide candidate ENUMERATION needs the on-VM
|
|
139
|
+
scan; this engine SCORES + RANKS + risk-bands them."""
|
|
140
|
+
g = (guide or "").upper()
|
|
141
|
+
if len(g) < 20:
|
|
142
|
+
return {"family": "nuclease", "available": False, "abstain": True,
|
|
143
|
+
"note": "guide must be >=20 nt (protospacer)"}
|
|
144
|
+
if not candidate_sites:
|
|
145
|
+
return {"family": "nuclease", "available": False, "abstain": True,
|
|
146
|
+
"note": "no candidate sites supplied, provide Cas-OFFinder/on-VM genome-scan candidates "
|
|
147
|
+
"(20-23 nt protospacers); this engine scores+ranks+risk-bands them, it does not "
|
|
148
|
+
"fabricate sites", "method": "homology + real-data mismatch calibration + cached CRISOT"}
|
|
149
|
+
cache = _crisot_cache()
|
|
150
|
+
g20 = g[:20]
|
|
151
|
+
noms = []
|
|
152
|
+
for i, site in enumerate(candidate_sites):
|
|
153
|
+
s = (site or "").upper()
|
|
154
|
+
nmm = _ham20(g20, s)
|
|
155
|
+
af = calibrated_active_fraction(nmm, assay)
|
|
156
|
+
crisot = cache.get((g20, s[:20]))
|
|
157
|
+
locus_acc = None
|
|
158
|
+
if loci and i < len(loci) and loci[i]:
|
|
159
|
+
locus_acc = locus_accessibility(loci[i][0], loci[i][1], cell_type) # REAL accessibility track (or None)
|
|
160
|
+
acc = accessibility[i] if (accessibility and i < len(accessibility)) else None
|
|
161
|
+
noms.append({"site": s, "n_mismatch": nmm, "empirical_active_fraction": af,
|
|
162
|
+
"risk_band": _risk_band(af), "crisot_score": crisot,
|
|
163
|
+
"crisot_source": ("cached_bench" if crisot is not None else "on-VM tool (not cached)"),
|
|
164
|
+
"chromatin": _chromatin_modifier(acc, locus_acc), "output_kind": "candidate"})
|
|
165
|
+
# rank by the real CRISOT score when available, else by empirical risk (descending), then fewer mismatches
|
|
166
|
+
noms.sort(key=lambda n: (n["crisot_score"] if n["crisot_score"] is not None else -1.0,
|
|
167
|
+
n["empirical_active_fraction"] or 0.0, -n["n_mismatch"]), reverse=True)
|
|
168
|
+
return {"family": "nuclease", "available": True, "abstain": False, "guide": g20,
|
|
169
|
+
"n_candidates": len(noms), "assay_calibration": assay,
|
|
170
|
+
"nominations": noms[:top],
|
|
171
|
+
"method": ("risk = empirical active fraction at k mismatches (real GUIDE-seq/CIRCLE-seq data); "
|
|
172
|
+
"ranked by the real CRISOT-Score when the (guide,site) is cached, else by empirical risk"),
|
|
173
|
+
"bench": BENCH_SUMMARY.get(assay), "output_kind": "candidate",
|
|
174
|
+
"honesty": "candidates, NOT a clearance, confirm with the recommended empirical assay"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def pseudo_attb_sites(sequence: str, integrase: str = "Bxb1", max_arm_mismatch: int = 4, top: int = 20) -> dict:
|
|
178
|
+
"""Nominate cryptic **pseudo-attB** sites in a provided sequence for a serine integrase, seeding on the REAL
|
|
179
|
+
documented attB core (e.g. Bxb1 GCGGTCTC / central GT) and tolerating arm mismatches. Candidate-flagged; the
|
|
180
|
+
validating assay is Cryptic-seq/HIDE-seq. Abstains when no documented att core is bundled for the integrase."""
|
|
181
|
+
from pen_stack.atlas.guide_design import _INTEGRASE_ATT
|
|
182
|
+
att = _INTEGRASE_ATT.get(integrase)
|
|
183
|
+
if not att:
|
|
184
|
+
return {"family": "serine_integrase", "available": False, "abstain": True,
|
|
185
|
+
"note": f"no documented att core bundled for {integrase!r}, cannot scan without fabricating"}
|
|
186
|
+
core, attb = att["core"].upper(), att["attB"].upper()
|
|
187
|
+
seq = (sequence or "").upper()
|
|
188
|
+
L = len(attb)
|
|
189
|
+
# locate the core within the full attB so we can align scanned windows to the documented site
|
|
190
|
+
c0 = attb.find(core)
|
|
191
|
+
hits = []
|
|
192
|
+
idx = seq.find(core)
|
|
193
|
+
while idx != -1:
|
|
194
|
+
start = idx - c0
|
|
195
|
+
if 0 <= start and start + L <= len(seq):
|
|
196
|
+
window = seq[start:start + L]
|
|
197
|
+
if "N" not in window:
|
|
198
|
+
arm_mm = sum(1 for a, b in zip(window, attb) if a != b)
|
|
199
|
+
if arm_mm <= max_arm_mismatch:
|
|
200
|
+
hits.append({"pos": start, "site": window, "arm_mismatch": arm_mm,
|
|
201
|
+
"core_matched": True, "output_kind": "candidate"})
|
|
202
|
+
idx = seq.find(core, idx + 1)
|
|
203
|
+
hits.sort(key=lambda h: h["arm_mismatch"])
|
|
204
|
+
return {"family": "serine_integrase", "integrase": integrase, "available": True, "abstain": False,
|
|
205
|
+
"att_core": core, "n_candidates": len(hits), "nominations": hits[:top],
|
|
206
|
+
"method": (f"cryptic pseudo-attB scan: seed on the documented {integrase} core {core} (central "
|
|
207
|
+
f"{att['central_dinucleotide']}), verify the {L}-nt attB with <= {max_arm_mismatch} arm "
|
|
208
|
+
"mismatches"),
|
|
209
|
+
"validating_assay": "Cryptic-seq / HIDE-seq (Hazelbaker et al., bioRxiv 2024.08.23.609471)",
|
|
210
|
+
"honesty": "CANDIDATE cryptic sites, recombination at a pseudo-attB is not claimed; confirm by "
|
|
211
|
+
"Cryptic-seq/HIDE-seq. Quantitative LSI off-target prediction (IntQuery) is paper-only "
|
|
212
|
+
"(no public weights), not run here."}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def nominate_offtargets(writer_family: str, guide: str | None = None, candidate_sites: list[str] | None = None,
|
|
216
|
+
sequence: str | None = None, accessibility: list[float] | None = None,
|
|
217
|
+
target_core: str | None = None, fasta=None, chroms: list[str] | None = None,
|
|
218
|
+
assay: str = "guideseq", loci: list | None = None, cell_type: str = "k562",
|
|
219
|
+
enzyme: str | None = None, max_mismatch: int = 5) -> dict:
|
|
220
|
+
"""Cross-family dispatcher. Returns a nomination dict (candidates, never claims) + the recommended validation
|
|
221
|
+
assay, or an abstention. Never fabricates off-target sites. ``loci`` (per-candidate (chrom, bin)) +
|
|
222
|
+
``cell_type`` enable the REAL chromatin-accessibility modifier for nucleases.
|
|
223
|
+
|
|
224
|
+
For a nuclease guide WITHOUT supplied ``candidate_sites``, this runs the genome-wide FINDER
|
|
225
|
+
(enumerate GRCh38 -> CRISOT -> risk -> chromatin) by default. Supplying ``candidate_sites`` keeps the
|
|
226
|
+
score-my-candidates path (backward compatible)."""
|
|
227
|
+
from pen_stack.wgenome.offtarget_assay import recommend_assay
|
|
228
|
+
fam = (writer_family or "").lower()
|
|
229
|
+
assay_rec = recommend_assay(writer_family)
|
|
230
|
+
# PASTE first (its name embeds an integrase) → compose nuclease + integrase
|
|
231
|
+
if fam in _PASTE or "paste" in fam or "passige" in fam:
|
|
232
|
+
from pen_stack.wgenome.offtarget_paste import nominate_paste
|
|
233
|
+
res = nominate_paste(guide=guide, integrase="Bxb1", max_mismatch=max_mismatch, assay=assay,
|
|
234
|
+
cell_type=cell_type)
|
|
235
|
+
elif fam in _CAST or "cast" in fam or "cas12k" in fam:
|
|
236
|
+
from pen_stack.wgenome.offtarget_cast import nominate_cast
|
|
237
|
+
res = nominate_cast(system=enzyme or writer_family or "ShCAST", spacer=guide, max_mismatch=max_mismatch)
|
|
238
|
+
elif fam in _NUCLEASE or "cas9" in fam or "nuclease" in fam or "cas12" in fam:
|
|
239
|
+
from pen_stack.wgenome.offtarget_nuclease import find_nuclease_offtargets, unsupported_nuclease_abstention
|
|
240
|
+
# The CRISOT scorer and the mismatch-calibrated risk band are SpCas9-specific, so BOTH nuclease paths are
|
|
241
|
+
# gated on the same abstention: supplying candidate_sites must not route around the finder's gate.
|
|
242
|
+
declined = unsupported_nuclease_abstention(enzyme or writer_family,
|
|
243
|
+
mode="candidate_scoring" if candidate_sites else "finder")
|
|
244
|
+
if declined is not None:
|
|
245
|
+
res = declined
|
|
246
|
+
elif candidate_sites: # score/rank the caller-supplied candidates
|
|
247
|
+
res = nominate_nuclease(guide or "", candidate_sites, accessibility, assay, loci=loci,
|
|
248
|
+
cell_type=cell_type)
|
|
249
|
+
else: # finder: enumerate genome-wide, then score (replays the VM cache or abstains)
|
|
250
|
+
res = find_nuclease_offtargets(guide or "", enzyme=enzyme or writer_family or "SpCas9",
|
|
251
|
+
max_mismatch=max_mismatch, assay=assay, cell_type=cell_type)
|
|
252
|
+
elif fam in _INTEGRASE or "integrase" in fam or "bxb1" in fam or "phic31" in fam:
|
|
253
|
+
from pen_stack.wgenome.offtarget_integrase import nominate_integrase, resolve_integrase
|
|
254
|
+
# honour the explicit integrase/system selector (``enzyme``); fall back to the family string. This is why
|
|
255
|
+
# system="phiC31" must reach the phiC31 branch instead of defaulting to Bxb1.
|
|
256
|
+
sel = enzyme or ("PhiC31" if "phic31" in fam else "Bxb1")
|
|
257
|
+
integrase = resolve_integrase(sel)
|
|
258
|
+
if integrase is None:
|
|
259
|
+
# A named integrase with no curated attachment-site record. The pseudo-att scan is driven by the att
|
|
260
|
+
# core of the SPECIFIC enzyme, so substituting another integrase's att would report a Bxb1 scan under a
|
|
261
|
+
# different enzyme's name. Abstain and name what is carried instead.
|
|
262
|
+
from pen_stack.wgenome.offtarget_integrase import integrase_att
|
|
263
|
+
known = sorted((integrase_att().get("integrases") or {}).keys())
|
|
264
|
+
res = {"family": "serine_integrase", "requested_integrase": sel, "available": False, "abstain": True,
|
|
265
|
+
"status": "unsupported_integrase", "nomination_is_not_clearance": True,
|
|
266
|
+
"note": (f"No curated attachment-site record for integrase {sel!r}, so the pseudo-att scan does "
|
|
267
|
+
f"not run: it is driven by the att core of the specific enzyme, and substituting a "
|
|
268
|
+
f"different integrase's att would misattribute the sites. Carried integrases: "
|
|
269
|
+
f"{', '.join(known)}.")}
|
|
270
|
+
elif sequence: # scan a supplied locus for cryptic pseudo-attB
|
|
271
|
+
res = pseudo_attb_sites(sequence, integrase)
|
|
272
|
+
else: # genome-wide pseudo-attP scan
|
|
273
|
+
res = nominate_integrase(integrase)
|
|
274
|
+
elif fam in _BRIDGE or "bridge" in fam or "is110" in fam or "is621" in fam or "seek" in fam:
|
|
275
|
+
from pen_stack.wgenome.offtarget_bridge import nominate_bridge
|
|
276
|
+
res = nominate_bridge(target_core=target_core, writer_family=writer_family, fasta=fasta, chroms=chroms)
|
|
277
|
+
else:
|
|
278
|
+
res = {"family": writer_family, "available": False, "abstain": True,
|
|
279
|
+
"note": f"no off-target nomination model for family {writer_family!r}"}
|
|
280
|
+
res["recommended_assay"] = assay_rec
|
|
281
|
+
res["nomination_is_not_clearance"] = True
|
|
282
|
+
return res
|
pen_stack/wgenome/ood.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Out-of-distribution / extrapolation detection.
|
|
2
|
+
|
|
3
|
+
A model's prediction is only as trustworthy as the resemblance of the query to what the model was trained
|
|
4
|
+
on. This detector scores how far a query sits from the training feature distribution and, when far,
|
|
5
|
+
**widens the conformal interval / lowers the confidence** (the hook into :mod:`wgenome.uncertainty`). It is
|
|
6
|
+
the difference between "grounded-confident" and "grounded-extrapolating" in the epistemic taxonomy.
|
|
7
|
+
|
|
8
|
+
Methods (pick per data size):
|
|
9
|
+
* ``mahalanobis`` (default) - distance to the training mean under the (shrunk) training covariance;
|
|
10
|
+
natural for the low-dimensional chromatin/distance feature spaces here.
|
|
11
|
+
* ``knn`` - mean distance to the k nearest training points (non-parametric; robust to non-Gaussianity).
|
|
12
|
+
* ``isolation_forest`` - tree-based density (sklearn), for larger feature sets.
|
|
13
|
+
|
|
14
|
+
Scope: OOD is defined **relative to the training data, not ground truth** - it flags "far from what I
|
|
15
|
+
have seen," never "wrong." The threshold is calibrated on a held-out in-distribution-vs-deliberately-OOD
|
|
16
|
+
construction (e.g. a held-out cell type or a sequence-divergent locus class) and the separation AUROC is
|
|
17
|
+
reported; it is a heuristic confidence signal, not a guarantee.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from pen_stack.adapt.report import _auroc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class OODDetector:
|
|
30
|
+
method: str = "mahalanobis"
|
|
31
|
+
k: int = 10
|
|
32
|
+
shrinkage: float = 1e-3 # ridge on the covariance (numerical + small-N stability)
|
|
33
|
+
# fitted state
|
|
34
|
+
mean_: np.ndarray | None = None
|
|
35
|
+
inv_cov_: np.ndarray | None = None
|
|
36
|
+
train_: np.ndarray | None = None
|
|
37
|
+
iforest_: object | None = None
|
|
38
|
+
threshold_: float = float("nan")
|
|
39
|
+
train_quantiles_: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
# ---- fit -------------------------------------------------------------------------------------
|
|
42
|
+
def fit(self, X_train) -> "OODDetector":
|
|
43
|
+
X = np.asarray(X_train, float)
|
|
44
|
+
X = np.nan_to_num(X, nan=0.0)
|
|
45
|
+
self.mean_ = X.mean(axis=0)
|
|
46
|
+
if self.method == "mahalanobis":
|
|
47
|
+
cov = np.cov(X, rowvar=False)
|
|
48
|
+
cov = np.atleast_2d(cov) + self.shrinkage * np.eye(X.shape[1])
|
|
49
|
+
self.inv_cov_ = np.linalg.pinv(cov)
|
|
50
|
+
elif self.method == "knn":
|
|
51
|
+
self.train_ = X
|
|
52
|
+
try:
|
|
53
|
+
from scipy.spatial import cKDTree
|
|
54
|
+
self._kdtree = cKDTree(X) # O(log n) queries instead of an O(n) row-loop
|
|
55
|
+
except Exception: # noqa: BLE001 - scipy absent -> fall back to the brute-force loop
|
|
56
|
+
self._kdtree = None
|
|
57
|
+
elif self.method == "isolation_forest":
|
|
58
|
+
from sklearn.ensemble import IsolationForest
|
|
59
|
+
self.iforest_ = IsolationForest(n_estimators=200, random_state=42).fit(X)
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f"unknown OOD method: {self.method}")
|
|
62
|
+
# store the in-distribution score distribution (for a default threshold + monotone widening)
|
|
63
|
+
s = self.score(X)
|
|
64
|
+
self.train_quantiles_ = {q: float(np.quantile(s, q)) for q in (0.5, 0.9, 0.95, 0.99)}
|
|
65
|
+
self.threshold_ = self.train_quantiles_[0.95] # default: 95th in-dist percentile
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
# ---- score -----------------------------------------------------------------------------------
|
|
69
|
+
def score(self, X) -> np.ndarray:
|
|
70
|
+
"""Higher = more out-of-distribution."""
|
|
71
|
+
X = np.nan_to_num(np.asarray(X, float), nan=0.0)
|
|
72
|
+
if X.ndim == 1:
|
|
73
|
+
X = X.reshape(1, -1)
|
|
74
|
+
if self.method == "mahalanobis":
|
|
75
|
+
d = X - self.mean_
|
|
76
|
+
m2 = np.einsum("ij,jk,ik->i", d, self.inv_cov_, d)
|
|
77
|
+
return np.sqrt(np.clip(m2, 0, None))
|
|
78
|
+
if self.method == "knn":
|
|
79
|
+
kk = min(self.k, len(self.train_))
|
|
80
|
+
if getattr(self, "_kdtree", None) is not None:
|
|
81
|
+
d, _ = self._kdtree.query(X, k=kk) # (n, kk) nearest-neighbour distances
|
|
82
|
+
d = np.atleast_2d(d)
|
|
83
|
+
return d.mean(axis=1)
|
|
84
|
+
out = []
|
|
85
|
+
for row in X:
|
|
86
|
+
dist = np.sqrt(((self.train_ - row) ** 2).sum(axis=1))
|
|
87
|
+
out.append(float(np.sort(dist)[:kk].mean()))
|
|
88
|
+
return np.asarray(out)
|
|
89
|
+
if self.method == "isolation_forest":
|
|
90
|
+
return -self.iforest_.score_samples(X) # higher = more anomalous
|
|
91
|
+
raise ValueError(self.method)
|
|
92
|
+
|
|
93
|
+
# ---- threshold calibration on a held-out in-dist vs OOD set ----------------------------------
|
|
94
|
+
def calibrate_threshold(self, X_id, X_ood) -> dict:
|
|
95
|
+
"""Pick a threshold separating held-out in-distribution from deliberately-OOD queries and report
|
|
96
|
+
the separation AUROC (acceptance: >= 0.75). Threshold = Youden-J-optimal point on the ROC."""
|
|
97
|
+
s_id = self.score(X_id)
|
|
98
|
+
s_ood = self.score(X_ood)
|
|
99
|
+
scores = np.concatenate([s_id, s_ood])
|
|
100
|
+
labels = np.concatenate([np.zeros(len(s_id)), np.ones(len(s_ood))]) # 1 = OOD
|
|
101
|
+
auroc = _auroc(list(scores), list(labels))
|
|
102
|
+
# Youden J over candidate thresholds (the unique scores), VECTORIZED via searchsorted so this is
|
|
103
|
+
# O(n log n), not O(n^2) - the naive per-threshold mean loop is prohibitive at n in the thousands.
|
|
104
|
+
thr = np.unique(scores)
|
|
105
|
+
id_sorted, ood_sorted = np.sort(s_id), np.sort(s_ood)
|
|
106
|
+
tpr = 1.0 - np.searchsorted(ood_sorted, thr, side="left") / len(ood_sorted) # P(s_ood >= t)
|
|
107
|
+
fpr = 1.0 - np.searchsorted(id_sorted, thr, side="left") / len(id_sorted) # P(s_id >= t)
|
|
108
|
+
j = tpr - fpr
|
|
109
|
+
best = int(np.argmax(j))
|
|
110
|
+
best_t, best_j = float(thr[best]), float(j[best])
|
|
111
|
+
self.threshold_ = best_t
|
|
112
|
+
return {"auroc": auroc, "threshold": best_t, "youden_j": best_j,
|
|
113
|
+
"n_id": int(len(s_id)), "n_ood": int(len(s_ood)),
|
|
114
|
+
"separates": bool(auroc >= 0.75)}
|
|
115
|
+
|
|
116
|
+
def is_ood(self, X) -> np.ndarray:
|
|
117
|
+
return self.score(X) >= self.threshold_
|
|
118
|
+
|
|
119
|
+
# ---- widening hook into the conformal interval -----------------------------------------------
|
|
120
|
+
def widen_factor(self, X, cap: float = 3.0) -> np.ndarray:
|
|
121
|
+
"""Monotone interval-widening / confidence-deflation multiplier in [1, cap].
|
|
122
|
+
|
|
123
|
+
1.0 at/below the in-distribution median; rises smoothly with the OOD score, saturating at ``cap``.
|
|
124
|
+
Monotonicity is the UQ2 acceptance property: more OOD never narrows the interval.
|
|
125
|
+
"""
|
|
126
|
+
s = self.score(X)
|
|
127
|
+
lo = self.train_quantiles_.get(0.5, float(np.median(s)))
|
|
128
|
+
hi = self.train_quantiles_.get(0.99, lo + 1.0)
|
|
129
|
+
span = max(hi - lo, 1e-9)
|
|
130
|
+
frac = np.clip((s - lo) / span, 0.0, 1.0)
|
|
131
|
+
return 1.0 + frac * (cap - 1.0)
|
|
132
|
+
|
|
133
|
+
def confidence(self, X) -> np.ndarray:
|
|
134
|
+
"""A 0-1 confidence that DECREASES monotonically with the OOD score (1 / widen_factor)."""
|
|
135
|
+
return 1.0 / self.widen_factor(X)
|