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.
Files changed (259) hide show
  1. pen_stack/__init__.py +2 -0
  2. pen_stack/_resources.py +34 -0
  3. pen_stack/active/__init__.py +20 -0
  4. pen_stack/active/acquire.py +165 -0
  5. pen_stack/active/brains.py +74 -0
  6. pen_stack/active/campaign.py +109 -0
  7. pen_stack/active/design.py +66 -0
  8. pen_stack/active/validate.py +104 -0
  9. pen_stack/adapt/__init__.py +14 -0
  10. pen_stack/adapt/finetune.py +33 -0
  11. pen_stack/adapt/ingest.py +86 -0
  12. pen_stack/adapt/pipeline.py +101 -0
  13. pen_stack/adapt/recalibrate.py +58 -0
  14. pen_stack/adapt/report.py +130 -0
  15. pen_stack/agent/__init__.py +1 -0
  16. pen_stack/agent/cite.py +175 -0
  17. pen_stack/agent/co_scientist.py +262 -0
  18. pen_stack/agent/epistemic.py +102 -0
  19. pen_stack/agent/guardrails.py +67 -0
  20. pen_stack/agent/mcp_server.py +215 -0
  21. pen_stack/agent/orchestrator.py +112 -0
  22. pen_stack/agent/orchestrator_live.py +56 -0
  23. pen_stack/agent/pen_agent.py +242 -0
  24. pen_stack/agent/scope.py +60 -0
  25. pen_stack/agent/tools.py +130 -0
  26. pen_stack/api/__init__.py +12 -0
  27. pen_stack/api/manifest.py +160 -0
  28. pen_stack/atlas/__init__.py +1 -0
  29. pen_stack/atlas/atlas.parquet +0 -0
  30. pen_stack/atlas/build_wtkb.py +80 -0
  31. pen_stack/atlas/crosslink.py +179 -0
  32. pen_stack/atlas/expand.py +190 -0
  33. pen_stack/atlas/guide_design.py +178 -0
  34. pen_stack/atlas/schema.py +59 -0
  35. pen_stack/atlas/scorecard.py +134 -0
  36. pen_stack/atlas/scorecard_v3.parquet +0 -0
  37. pen_stack/atlas/universe.py +75 -0
  38. pen_stack/atlas/universe_v3.parquet +0 -0
  39. pen_stack/atlas/variant_propose.py +155 -0
  40. pen_stack/atlas/writer_efficiency.py +184 -0
  41. pen_stack/atlas/writer_predict.py +229 -0
  42. pen_stack/atlas/writer_recommend.py +170 -0
  43. pen_stack/atlas/writer_verify.py +167 -0
  44. pen_stack/atlas/wtkb.parquet +0 -0
  45. pen_stack/bridge/__init__.py +1 -0
  46. pen_stack/bridge/activity.py +52 -0
  47. pen_stack/bridge/cli.py +65 -0
  48. pen_stack/bridge/fold_qc.py +53 -0
  49. pen_stack/bridge/guide_qc.py +87 -0
  50. pen_stack/bridge/ingest.py +139 -0
  51. pen_stack/bridge/offtarget.py +191 -0
  52. pen_stack/bridge/offtarget_energetics.py +105 -0
  53. pen_stack/bridge/ortholog_screen.py +73 -0
  54. pen_stack/bridge/pipeline.py +83 -0
  55. pen_stack/build/__init__.py +16 -0
  56. pen_stack/build/cloudlab.py +74 -0
  57. pen_stack/build/ingest.py +47 -0
  58. pen_stack/build/protocol.py +82 -0
  59. pen_stack/build/simlab.py +30 -0
  60. pen_stack/cli.py +126 -0
  61. pen_stack/data/__init__.py +1 -0
  62. pen_stack/data/encode.py +84 -0
  63. pen_stack/data/genome.py +71 -0
  64. pen_stack/data/ingest_chromatin.py +119 -0
  65. pen_stack/data/ingest_integration.py +112 -0
  66. pen_stack/data/ingest_safety_annot.py +201 -0
  67. pen_stack/data/ingest_trip.py +76 -0
  68. pen_stack/design/__init__.py +14 -0
  69. pen_stack/design/capsid_generate.py +62 -0
  70. pen_stack/design/generate.py +70 -0
  71. pen_stack/design/pareto.py +70 -0
  72. pen_stack/design/space.py +137 -0
  73. pen_stack/design/writer_variants.py +121 -0
  74. pen_stack/env/__init__.py +1 -0
  75. pen_stack/env/genome_writing_env.py +248 -0
  76. pen_stack/env/policies.py +94 -0
  77. pen_stack/graph/__init__.py +21 -0
  78. pen_stack/graph/build.py +133 -0
  79. pen_stack/graph/cell_types.py +58 -0
  80. pen_stack/graph/ingest.py +132 -0
  81. pen_stack/graph/query.py +148 -0
  82. pen_stack/graph/schema.py +100 -0
  83. pen_stack/loop/__init__.py +15 -0
  84. pen_stack/loop/continual.py +61 -0
  85. pen_stack/loop/cycle.py +84 -0
  86. pen_stack/loop/drift.py +41 -0
  87. pen_stack/mech/__init__.py +1 -0
  88. pen_stack/mech/classify_atlas.py +71 -0
  89. pen_stack/mech/pfam_whitelist.yaml +247 -0
  90. pen_stack/mech/whitelist.py +66 -0
  91. pen_stack/monitor/__init__.py +1 -0
  92. pen_stack/monitor/europepmc.py +32 -0
  93. pen_stack/monitor/run.py +57 -0
  94. pen_stack/monitor/triage.py +63 -0
  95. pen_stack/oracles/__init__.py +65 -0
  96. pen_stack/oracles/affinity.py +116 -0
  97. pen_stack/oracles/cache.py +53 -0
  98. pen_stack/oracles/energetics.py +33 -0
  99. pen_stack/oracles/genome.py +167 -0
  100. pen_stack/oracles/protein_design.py +136 -0
  101. pen_stack/oracles/reliability.py +64 -0
  102. pen_stack/oracles/rna.py +28 -0
  103. pen_stack/oracles/schema.py +77 -0
  104. pen_stack/oracles/status.py +123 -0
  105. pen_stack/oracles/structure.py +42 -0
  106. pen_stack/oracles/structure_run.py +76 -0
  107. pen_stack/oracles/vcell.py +74 -0
  108. pen_stack/planner/__init__.py +1 -0
  109. pen_stack/planner/ada_risk.py +64 -0
  110. pen_stack/planner/antipeg_oracle.py +75 -0
  111. pen_stack/planner/capsid_epitope_oracle.py +135 -0
  112. pen_stack/planner/cargo.py +56 -0
  113. pen_stack/planner/cargo_polish.py +146 -0
  114. pen_stack/planner/chromosome.py +106 -0
  115. pen_stack/planner/delivery.py +55 -0
  116. pen_stack/planner/delivery_constraints.py +110 -0
  117. pen_stack/planner/delivery_immune.py +61 -0
  118. pen_stack/planner/delivery_immunology.py +222 -0
  119. pen_stack/planner/delivery_predict.py +196 -0
  120. pen_stack/planner/delivery_vehicles.py +37 -0
  121. pen_stack/planner/genotoxicity_oracle.py +112 -0
  122. pen_stack/planner/immune_mhc2.py +154 -0
  123. pen_stack/planner/immune_profile.py +292 -0
  124. pen_stack/planner/innate_sensing.py +135 -0
  125. pen_stack/planner/multiplex.py +110 -0
  126. pen_stack/planner/optimize.py +278 -0
  127. pen_stack/planner/pipeline.py +87 -0
  128. pen_stack/planner/report.py +26 -0
  129. pen_stack/planner/router.py +57 -0
  130. pen_stack/planner/seroprevalence_oracle.py +92 -0
  131. pen_stack/planner/target_site.py +118 -0
  132. pen_stack/rag/__init__.py +1 -0
  133. pen_stack/rag/corpus.py +133 -0
  134. pen_stack/rag/embed.py +98 -0
  135. pen_stack/rag/ground.py +131 -0
  136. pen_stack/rag/index.py +53 -0
  137. pen_stack/rag/llm.py +215 -0
  138. pen_stack/rag/qa.py +105 -0
  139. pen_stack/rag/retrieve.py +48 -0
  140. pen_stack/rules/__init__.py +9 -0
  141. pen_stack/rules/evaluators.py +318 -0
  142. pen_stack/rules/loader.py +31 -0
  143. pen_stack/rules/schema.py +99 -0
  144. pen_stack/rules/solver.py +43 -0
  145. pen_stack/rules/spec.py +78 -0
  146. pen_stack/safety/__init__.py +21 -0
  147. pen_stack/safety/audit.py +90 -0
  148. pen_stack/safety/gate.py +58 -0
  149. pen_stack/safety/pfam_scan.py +157 -0
  150. pen_stack/safety/policy.py +69 -0
  151. pen_stack/safety/redteam.py +71 -0
  152. pen_stack/safety/registry.py +255 -0
  153. pen_stack/safety/screen.py +59 -0
  154. pen_stack/safety/standards.py +141 -0
  155. pen_stack/score/__init__.py +1 -0
  156. pen_stack/score/recalibrate.py +77 -0
  157. pen_stack/score/therapeutic.py +85 -0
  158. pen_stack/server/__init__.py +1 -0
  159. pen_stack/server/api.py +647 -0
  160. pen_stack/spec/__init__.py +18 -0
  161. pen_stack/spec/clarify.py +42 -0
  162. pen_stack/spec/extract.py +406 -0
  163. pen_stack/spec/resolvers/__init__.py +17 -0
  164. pen_stack/spec/resolvers/cell.py +51 -0
  165. pen_stack/spec/resolvers/chem.py +32 -0
  166. pen_stack/spec/resolvers/feature.py +36 -0
  167. pen_stack/spec/resolvers/gene.py +43 -0
  168. pen_stack/spec/resolvers/locus.py +29 -0
  169. pen_stack/spec/resolvers/phenotype.py +37 -0
  170. pen_stack/spec/satisfy.py +114 -0
  171. pen_stack/spec/service.py +33 -0
  172. pen_stack/spec/writespec.py +252 -0
  173. pen_stack/twin/__init__.py +14 -0
  174. pen_stack/twin/calibrate.py +61 -0
  175. pen_stack/twin/data/__init__.py +12 -0
  176. pen_stack/twin/data/position_effect.py +245 -0
  177. pen_stack/twin/mechanistic.py +147 -0
  178. pen_stack/twin/outcome.py +132 -0
  179. pen_stack/twin/position_effect.py +454 -0
  180. pen_stack/ui/__init__.py +1 -0
  181. pen_stack/ui/app.py +713 -0
  182. pen_stack/validate/__init__.py +1 -0
  183. pen_stack/validate/adapt_demo.py +69 -0
  184. pen_stack/validate/agent_eval.py +117 -0
  185. pen_stack/validate/bench_adversarial_tasks.py +118 -0
  186. pen_stack/validate/bench_coscientist_tasks.py +60 -0
  187. pen_stack/validate/bench_graph_tasks.py +64 -0
  188. pen_stack/validate/bench_rule_tasks.py +84 -0
  189. pen_stack/validate/bench_trust_tasks.py +92 -0
  190. pen_stack/validate/bench_writetype_tasks.py +101 -0
  191. pen_stack/validate/blind_gsh_discovery.py +261 -0
  192. pen_stack/validate/cargo_directionality.py +57 -0
  193. pen_stack/validate/closed_loop.py +63 -0
  194. pen_stack/validate/durability_baselines.py +185 -0
  195. pen_stack/validate/experiment_design.py +65 -0
  196. pen_stack/validate/expr_controls.py +39 -0
  197. pen_stack/validate/forward_hypotheses.py +104 -0
  198. pen_stack/validate/generative_design.py +62 -0
  199. pen_stack/validate/guide_qc_demo.py +69 -0
  200. pen_stack/validate/heldout_celltype_expr.py +32 -0
  201. pen_stack/validate/immune_calibration.py +133 -0
  202. pen_stack/validate/intent_specification.py +82 -0
  203. pen_stack/validate/known_biology_expr.py +38 -0
  204. pen_stack/validate/offtarget_energetics_eval.py +144 -0
  205. pen_stack/validate/out_of_scope_refusal.py +82 -0
  206. pen_stack/validate/outcome_calibration.py +194 -0
  207. pen_stack/validate/outcome_prediction.py +76 -0
  208. pen_stack/validate/paper3_benchmark.py +165 -0
  209. pen_stack/validate/paper4_real_validation.py +144 -0
  210. pen_stack/validate/paper4_validation.py +82 -0
  211. pen_stack/validate/protocol_safety.py +62 -0
  212. pen_stack/validate/safety_screening.py +72 -0
  213. pen_stack/validate/selective_prediction.py +104 -0
  214. pen_stack/validate/seq_vs_measured.py +134 -0
  215. pen_stack/validate/target_site_controls.py +65 -0
  216. pen_stack/validate/uncertainty_eval.py +244 -0
  217. pen_stack/validate/ungrounded_baseline.py +234 -0
  218. pen_stack/validate/within_locus_ranking.py +84 -0
  219. pen_stack/validate/writer_recovery.py +91 -0
  220. pen_stack/verify/__init__.py +5 -0
  221. pen_stack/verify/proof.py +206 -0
  222. pen_stack/verify/schema.py +53 -0
  223. pen_stack/verify/service.py +191 -0
  224. pen_stack/web/__init__.py +18 -0
  225. pen_stack/web/guide.py +110 -0
  226. pen_stack/web/llm.py +393 -0
  227. pen_stack/web/llm_provider.py +119 -0
  228. pen_stack/web/router.py +119 -0
  229. pen_stack/web/server.py +96 -0
  230. pen_stack/web/tools.py +197 -0
  231. pen_stack/wgenome/__init__.py +1 -0
  232. pen_stack/wgenome/chromatin_seq.py +83 -0
  233. pen_stack/wgenome/durability.py +108 -0
  234. pen_stack/wgenome/export_tracks.py +52 -0
  235. pen_stack/wgenome/features.py +82 -0
  236. pen_stack/wgenome/genotoxic_blocklist.py +88 -0
  237. pen_stack/wgenome/gsh_baseline.py +154 -0
  238. pen_stack/wgenome/mesh_features.py +61 -0
  239. pen_stack/wgenome/offtarget_assay.py +80 -0
  240. pen_stack/wgenome/offtarget_bridge.py +47 -0
  241. pen_stack/wgenome/offtarget_cast.py +97 -0
  242. pen_stack/wgenome/offtarget_data.py +148 -0
  243. pen_stack/wgenome/offtarget_enumerate.py +274 -0
  244. pen_stack/wgenome/offtarget_integrase.py +155 -0
  245. pen_stack/wgenome/offtarget_nuclease.py +123 -0
  246. pen_stack/wgenome/offtarget_paste.py +41 -0
  247. pen_stack/wgenome/offtarget_predict.py +282 -0
  248. pen_stack/wgenome/ood.py +135 -0
  249. pen_stack/wgenome/providers.py +278 -0
  250. pen_stack/wgenome/safety.py +69 -0
  251. pen_stack/wgenome/structure3d.py +212 -0
  252. pen_stack/wgenome/uncertainty.py +250 -0
  253. pen_stack/wgenome/writability.py +72 -0
  254. pen_stack-0.1.0.dist-info/METADATA +401 -0
  255. pen_stack-0.1.0.dist-info/RECORD +259 -0
  256. pen_stack-0.1.0.dist-info/WHEEL +5 -0
  257. pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
  258. pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  259. pen_stack-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,454 @@
1
+ """Learned cassette x context position-effect model.
2
+
3
+ Replaces the closed-form expression heuristic with a learned, **trained-conformal** model. Factored, decomposable
4
+ (not opaque):
5
+
6
+ E_raw ~= f_cassette(cassette) # the cassette's intrinsic strength (per-cassette mean)
7
+ + g_context(chromatin features) # the position effect, a LightGBM on local chromatin
8
+ (+ h_interaction, reported) # does the context function differ by cassette? (separability)
9
+
10
+ `g_context` is supervised on the residual `E_raw - f_cassette`, so it learns the *position* effect on a scale
11
+ comparable across cassettes. A `silenced` classifier shares the same features. Both are wrapped with the EXISTING
12
+ `wgenome.uncertainty.ConformalRegressor` (chromosome-Mondrian) and `wgenome.ood.OODDetector`, so a prediction is a
13
+ *calibrated interval that widens out of distribution*, which is exactly the "trained conformal" property this model
14
+ previously lacked (it was a heuristic +/-0.20 band).
15
+
16
+ Pre-registered acceptance gate: the learned model ships ONLY if it beats the prior durability head
17
+ (context-only) AND the heuristic on chromosome-blocked CV; otherwise the baseline is retained and the negative is
18
+ reported. Never tune to the test. No fabrication: every metric here comes from a real CV run on real supervision.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import pickle
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+ from scipy.stats import spearmanr
29
+ from sklearn.metrics import roc_auc_score
30
+ from sklearn.model_selection import GroupKFold
31
+
32
+ from pen_stack.twin.data.position_effect import FEATURE_COLS
33
+ from pen_stack.wgenome.ood import OODDetector
34
+ from pen_stack.wgenome.uncertainty import ConformalRegressor
35
+
36
+ _LGB = dict(n_estimators=400, learning_rate=0.03, num_leaves=31, subsample=0.8,
37
+ random_state=42, n_jobs=-1, verbosity=-1)
38
+
39
+
40
+ def _lgb_reg(seed: int = 42):
41
+ import lightgbm as lgb
42
+ return lgb.LGBMRegressor(**{**_LGB, "random_state": seed})
43
+
44
+
45
+ def _lgb_clf(seed: int = 42):
46
+ import lightgbm as lgb
47
+ return lgb.LGBMClassifier(**{**_LGB, "random_state": seed})
48
+
49
+
50
+ def _feats(df: pd.DataFrame) -> list[str]:
51
+ return [c for c in FEATURE_COLS if c in df.columns]
52
+
53
+
54
+ def _groups(df: pd.DataFrame) -> np.ndarray:
55
+ return df["chrom"].astype("category").cat.codes.to_numpy()
56
+
57
+
58
+ def _boot_delta_ci(a_pred, b_pred, y, kind: str, groups=None, reps: int = 2000, seed: int = 20260619):
59
+ """Paired bootstrap 95% CI on (metric(a) - metric(b)). kind in {'spearman','auroc'}. Resamples whole
60
+ chromosome groups when `groups` is given (respects the blocked structure), else rows."""
61
+ rng = np.random.default_rng(seed)
62
+ a_pred, b_pred, y = map(lambda x: np.asarray(x, float), (a_pred, b_pred, y))
63
+ n = len(y)
64
+
65
+ def _metric(p, yy):
66
+ if kind == "spearman":
67
+ return spearmanr(p, yy).statistic
68
+ return roc_auc_score(yy.astype(int), p) if len(np.unique(yy)) > 1 else np.nan
69
+
70
+ if groups is not None:
71
+ groups = np.asarray(groups)
72
+ uniq = np.unique(groups)
73
+ members = {g: np.where(groups == g)[0] for g in uniq}
74
+ deltas = []
75
+ for _ in range(reps):
76
+ if groups is not None:
77
+ pick = rng.choice(uniq, size=len(uniq), replace=True)
78
+ idx = np.concatenate([members[g] for g in pick])
79
+ else:
80
+ idx = rng.integers(0, n, n)
81
+ if len(np.unique(y[idx])) < 2 and kind == "auroc":
82
+ continue
83
+ deltas.append(_metric(a_pred[idx], y[idx]) - _metric(b_pred[idx], y[idx]))
84
+ deltas = np.asarray([d for d in deltas if np.isfinite(d)])
85
+ if not len(deltas):
86
+ return {"delta_mean": float("nan"), "ci95": [float("nan"), float("nan")], "excludes_zero": False}
87
+ lo, hi = np.percentile(deltas, [2.5, 97.5])
88
+ return {"delta_mean": float(np.mean(deltas)), "ci95": [float(lo), float(hi)],
89
+ "excludes_zero": bool(lo > 0 or hi < 0)}
90
+
91
+
92
+ @dataclass
93
+ class PositionEffectModel:
94
+ """The learned, factored, trained-conformal position-effect model. Decomposable: `cassette_means`
95
+ (f_cassette), `reg` (g_context on the residual), `clf` (silenced), `conformal` (interval), `ood` (widening)."""
96
+ features: list[str] = field(default_factory=list)
97
+ cassette_means: dict = field(default_factory=dict)
98
+ global_mean: float = 0.0
99
+ reg: object | None = None # g_context: chromatin -> residual expression
100
+ clf: object | None = None # silenced classifier
101
+ conformal: ConformalRegressor | None = None
102
+ ood: OODDetector | None = None
103
+ meta: dict = field(default_factory=dict)
104
+
105
+ # ---- fit ------------------------------------------------------------------------------------
106
+ def fit(self, df: pd.DataFrame, seed: int = 42) -> "PositionEffectModel":
107
+ self.features = _feats(df)
108
+ self.global_mean = float(df["expression_raw"].mean())
109
+ self.cassette_means = {str(c): float(g["expression_raw"].mean())
110
+ for c, g in df.groupby("cassette")}
111
+ resid = df["expression_raw"].to_numpy() - self._f_cassette(df["cassette"])
112
+ X = df[self.features].astype("float32").fillna(0.0)
113
+ self.reg = _lgb_reg(seed).fit(X, resid)
114
+ self.clf = _lgb_clf(seed).fit(X, df["silenced"].astype(int).to_numpy())
115
+ self.ood = OODDetector(method="mahalanobis").fit(X.to_numpy())
116
+ self.meta = {"n": int(len(df)), "features": self.features,
117
+ "cassettes": list(self.cassette_means), "global_mean": self.global_mean,
118
+ "expr_min": float(df["expression_raw"].min()),
119
+ "expr_max": float(df["expression_raw"].max()),
120
+ "datasets": sorted(df["dataset"].unique()), "cell_types": sorted(df["cell_type"].unique())}
121
+ return self
122
+
123
+ def relative(self, yhat: float) -> float | None:
124
+ """Monotone min-max rescale of the native (log2) prediction to [0,1] over the training range, for
125
+ UI comparability with the mechanistic relative_expression. Clearly a rescale, not a new quantity."""
126
+ lo, hi = self.meta.get("expr_min"), self.meta.get("expr_max")
127
+ if lo is None or hi is None or hi <= lo:
128
+ return None
129
+ return float(min(1.0, max(0.0, (float(yhat) - lo) / (hi - lo))))
130
+
131
+ def _f_cassette(self, cassette: pd.Series | list) -> np.ndarray:
132
+ return np.array([self.cassette_means.get(str(c), self.global_mean) for c in cassette], float)
133
+
134
+ # ---- point prediction (decomposed) ----------------------------------------------------------
135
+ def predict_expression(self, df: pd.DataFrame) -> np.ndarray:
136
+ X = df[self.features].astype("float32").fillna(0.0)
137
+ return self._f_cassette(df["cassette"]) + self.reg.predict(X)
138
+
139
+ def predict_silenced(self, df: pd.DataFrame) -> np.ndarray:
140
+ X = df[self.features].astype("float32").fillna(0.0)
141
+ return self.clf.predict_proba(X)[:, 1]
142
+
143
+ # ---- interval (trained conformal, OOD-widened) ----------------------------------------------
144
+ def predict_interval(self, df: pd.DataFrame) -> dict:
145
+ yhat = self.predict_expression(df)
146
+ if self.conformal is None or not np.isfinite(self.conformal.qhat):
147
+ return {"yhat": yhat, "lo": None, "hi": None, "interval_kind": "uncalibrated (no conformal qhat)"}
148
+ X = df[self.features].astype("float32").fillna(0.0).to_numpy()
149
+ widen = self.ood.widen_factor(X) if self.ood is not None else np.ones(len(df))
150
+ lo, hi = self.conformal.interval(yhat, sigma=widen)
151
+ return {"yhat": yhat, "lo": lo, "hi": hi, "ood_widen": widen,
152
+ "nominal_coverage": 1 - self.conformal.alpha,
153
+ "interval_kind": "trained split-conformal (chromosome-blocked OOF; per-chromosome Mondrian qhats "
154
+ "computed, GLOBAL qhat served, a query has no chromosome at serve time), OOD-widened"}
155
+
156
+ # ---- persistence ----------------------------------------------------------------------------
157
+ def save(self, path: str | Path) -> str:
158
+ path = Path(path)
159
+ path.parent.mkdir(parents=True, exist_ok=True)
160
+ with open(path, "wb") as fh:
161
+ pickle.dump({"features": self.features, "cassette_means": self.cassette_means,
162
+ "global_mean": self.global_mean, "reg": self.reg, "clf": self.clf,
163
+ "conformal": self.conformal, "ood": self.ood, "meta": self.meta}, fh)
164
+ return str(path)
165
+
166
+ @classmethod
167
+ def load(cls, path: str | Path) -> "PositionEffectModel":
168
+ with open(path, "rb") as fh:
169
+ d = pickle.load(fh)
170
+ return cls(**d)
171
+
172
+
173
+ # --------------------------------------------------------------------------------------------------
174
+ # evaluation: chromosome-blocked CV, learned model vs context-only durability-head baseline
175
+ # --------------------------------------------------------------------------------------------------
176
+ def evaluate(df: pd.DataFrame, seed: int = 42, n_splits: int = 5) -> dict:
177
+ """Chromosome-blocked GroupKFold OOF. Compares the FACTORED model (f_cassette + g_context) to the
178
+ CONTEXT-ONLY baseline (the prior durability head: chromatin -> expression, no cassette term) and to a
179
+ cassette-only baseline. Reports expression Spearman, silenced AUROC, paired bootstrap CIs on the deltas,
180
+ and the interaction (separability) variance share. Returns OOF arrays for conformal calibration.
181
+ """
182
+ feats = _feats(df)
183
+ X = df[feats].astype("float32").fillna(0.0)
184
+ y = df["expression_raw"].to_numpy()
185
+ ysil = df["silenced"].astype(int).to_numpy()
186
+ cassette = df["cassette"]
187
+ g = _groups(df)
188
+ k = min(n_splits, len(np.unique(g)))
189
+ gkf = GroupKFold(n_splits=k)
190
+
191
+ oof_factored = np.zeros(len(df))
192
+ oof_context = np.zeros(len(df)) # durability head = chromatin-only
193
+ oof_cassette = np.zeros(len(df)) # cassette-only (f_cassette)
194
+ oof_sil_factored = np.zeros(len(df))
195
+ oof_sil_context = np.zeros(len(df))
196
+ oof_resid = np.full(len(df), np.nan)
197
+
198
+ for tr, te in gkf.split(X, y, g):
199
+ cm = {str(c): float(y[tr][cassette.iloc[tr] == c].mean()) for c in cassette.iloc[tr].unique()}
200
+ gm = float(y[tr].mean())
201
+ f_tr = np.array([cm.get(str(c), gm) for c in cassette.iloc[tr]])
202
+ f_te = np.array([cm.get(str(c), gm) for c in cassette.iloc[te]])
203
+ # factored: cassette mean + g_context on residual
204
+ reg_ctx = _lgb_reg(seed).fit(X.iloc[tr], y[tr] - f_tr)
205
+ oof_factored[te] = f_te + reg_ctx.predict(X.iloc[te])
206
+ oof_resid[te] = y[te] - oof_factored[te]
207
+ # context-only baseline (durability head): chromatin -> expression directly
208
+ reg_base = _lgb_reg(seed).fit(X.iloc[tr], y[tr])
209
+ oof_context[te] = reg_base.predict(X.iloc[te])
210
+ oof_cassette[te] = f_te
211
+ # silenced: factored (cassette one-hot + chromatin) vs context-only
212
+ cas_oh_tr = pd.get_dummies(cassette.iloc[tr]).astype("float32")
213
+ cas_oh_te = pd.get_dummies(cassette.iloc[te]).reindex(columns=cas_oh_tr.columns, fill_value=0).astype("float32")
214
+ Xf_tr = pd.concat([X.iloc[tr].reset_index(drop=True), cas_oh_tr.reset_index(drop=True)], axis=1)
215
+ Xf_te = pd.concat([X.iloc[te].reset_index(drop=True), cas_oh_te.reset_index(drop=True)], axis=1)
216
+ oof_sil_factored[te] = _lgb_clf(seed).fit(Xf_tr, ysil[tr]).predict_proba(Xf_te)[:, 1]
217
+ oof_sil_context[te] = _lgb_clf(seed).fit(X.iloc[tr], ysil[tr]).predict_proba(X.iloc[te])[:, 1]
218
+
219
+ rho_factored = float(spearmanr(oof_factored, y).statistic)
220
+ rho_context = float(spearmanr(oof_context, y).statistic)
221
+ rho_cassette = float(spearmanr(oof_cassette, y).statistic)
222
+ auroc_factored = float(roc_auc_score(ysil, oof_sil_factored))
223
+ auroc_context = float(roc_auc_score(ysil, oof_sil_context))
224
+
225
+ # separability: variance share of the interaction term, fit g per cassette vs pooled on OOF residual scale
226
+ inter = _interaction_share(df, feats, g, seed)
227
+
228
+ return {
229
+ "n": int(len(df)), "n_chrom_folds": int(k), "features": feats,
230
+ "expression": {
231
+ "rho_factored": rho_factored, "rho_context_only_durability_head": rho_context,
232
+ "rho_cassette_only": rho_cassette,
233
+ "delta_factored_vs_context": _boot_delta_ci(oof_factored, oof_context, y, "spearman", groups=g),
234
+ "delta_factored_vs_cassette": _boot_delta_ci(oof_factored, oof_cassette, y, "spearman", groups=g),
235
+ },
236
+ "silenced": {
237
+ "auroc_factored": auroc_factored, "auroc_context_only_durability_head": auroc_context,
238
+ "delta_factored_vs_context": _boot_delta_ci(oof_sil_factored, oof_sil_context, ysil, "auroc", groups=g),
239
+ },
240
+ "separability": inter,
241
+ "_oof": {"expression_pred": oof_factored, "expression_true": y, "residual": oof_resid,
242
+ "silenced_pred": oof_sil_factored, "silenced_true": ysil, "groups": g},
243
+ }
244
+
245
+
246
+ def _interaction_share(df: pd.DataFrame, feats: list[str], g: np.ndarray, seed: int) -> dict:
247
+ """How much does the context function differ BY cassette (the h_interaction term)? Compare a pooled
248
+ g_context to per-cassette g_context on chromosome-blocked OOF; report the extra variance the interaction
249
+ explains. With 1 cassette dominating, expect ~0 (additive suffices), reported either way."""
250
+ X = df[feats].astype("float32").fillna(0.0)
251
+ y = df["expression_raw"].to_numpy()
252
+ cassette = df["cassette"]
253
+ cms = {str(c): float(v["expression_raw"].mean()) for c, v in df.groupby("cassette")}
254
+ resid = y - np.array([cms.get(str(c), y.mean()) for c in cassette])
255
+ gkf = GroupKFold(n_splits=min(5, len(np.unique(g))))
256
+ oof_pool = np.zeros(len(df))
257
+ oof_inter = np.zeros(len(df))
258
+ for tr, te in gkf.split(X, resid, g):
259
+ oof_pool[te] = _lgb_reg(seed).fit(X.iloc[tr], resid[tr]).predict(X.iloc[te])
260
+ # per-cassette model (interaction); fall back to pooled where a cassette is absent in train
261
+ pred = oof_pool[te].copy()
262
+ for c in cassette.iloc[te].unique():
263
+ m_tr = (cassette.iloc[tr] == c).to_numpy()
264
+ m_te = (cassette.iloc[te] == c).to_numpy()
265
+ if m_tr.sum() >= 50:
266
+ pred[m_te] = _lgb_reg(seed).fit(X.iloc[tr][m_tr], resid[tr][m_tr]).predict(X.iloc[te][m_te])
267
+ oof_inter[te] = pred
268
+ ss_tot = float(np.var(resid) * len(resid))
269
+ r2_pool = 1 - float(np.sum((resid - oof_pool) ** 2)) / ss_tot if ss_tot else 0.0
270
+ r2_inter = 1 - float(np.sum((resid - oof_inter) ** 2)) / ss_tot if ss_tot else 0.0
271
+ return {"r2_pooled_context": round(r2_pool, 4), "r2_with_interaction": round(r2_inter, 4),
272
+ "interaction_extra_r2": round(r2_inter - r2_pool, 4),
273
+ "interpretation": ("interaction adds signal (cassette x context separability is real)"
274
+ if (r2_inter - r2_pool) > 0.01 else
275
+ "additive f_cassette + g_context suffices (no material interaction at this N)")}
276
+
277
+
278
+ def calibrate_conformal(oof: dict, alpha: float = 0.10) -> ConformalRegressor:
279
+ """Trained split-conformal on the chromosome-blocked OOF residuals (Mondrian per chromosome group), the
280
+ finite-sample (1-alpha) interval the EXISTING ConformalRegressor provides. This is what makes the model
281
+ 'trained conformal'. The shipped qhat is calibrated on ALL OOF residuals; true *held-out* coverage is
282
+ reported separately by `conformal_heldout_coverage` (calibrating and evaluating on the same set would
283
+ trivially hit nominal)."""
284
+ y, yhat, groups = oof["expression_true"], oof["expression_pred"], oof["groups"]
285
+ return ConformalRegressor(alpha=alpha).calibrate(y, yhat, groups=groups)
286
+
287
+
288
+ # --------------------------------------------------------------------------------------------------
289
+ # serving seam, used by twin.outcome when a chromatin context is supplied
290
+ # --------------------------------------------------------------------------------------------------
291
+ _MODEL_CACHE: dict = {}
292
+
293
+
294
+ def load_cached_model(root=None) -> "PositionEffectModel | None":
295
+ """Load the trained position-effect model if its artifact is present (VM/checkout); else None (installed
296
+ library / CI -> falls back to the heuristic). Cached per root."""
297
+ from pen_stack._resources import project_root
298
+ root = root or project_root()
299
+ key = str(root)
300
+ if key not in _MODEL_CACHE:
301
+ p = root / "models/position_effect.pkl"
302
+ try:
303
+ _MODEL_CACHE[key] = PositionEffectModel.load(p) if p.exists() else None
304
+ except Exception: # noqa: BLE001 - corrupt/absent artifact -> heuristic fallback, never crash the serving path
305
+ _MODEL_CACHE[key] = None
306
+ return _MODEL_CACHE[key]
307
+
308
+
309
+ def load_human_k562_model(root=None) -> "PositionEffectModel | None":
310
+ """Load the human-K562 position-effect head (Leemans 2019) if its artifact is present, else None:
311
+ a cell-type-matched head so a human K562 design is served by human supervision instead of the mESC model
312
+ applied cross-species (the cross-cell-type transfer axis is now fetched, not data-gated). Cached."""
313
+ from pen_stack._resources import project_root
314
+ root = root or project_root()
315
+ key = "human_k562::" + str(root)
316
+ if key not in _MODEL_CACHE:
317
+ p = root / "models/position_effect_human_k562.pkl"
318
+ try:
319
+ _MODEL_CACHE[key] = PositionEffectModel.load(p) if p.exists() else None
320
+ except Exception: # noqa: BLE001 - corrupt/absent artifact -> fall back, never crash the serving path
321
+ _MODEL_CACHE[key] = None
322
+ return _MODEL_CACHE[key]
323
+
324
+
325
+ def _is_human_k562(design: dict, cell_type=None) -> bool:
326
+ ct = str(cell_type or design.get("cell_type") or design.get("cell_state") or "").lower()
327
+ return "k562" in ct
328
+
329
+
330
+ # The human K562 head's held-out validation (Leemans 2019; grounded_human_headroom, rho~0.57, +0.065 over a
331
+ # learned-heterochromatin baseline, CI[0.026,0.103]) was measured with EXACT-SITE per-integration chromatin
332
+ # features. On coarse 1 kb-binned features even a human-trained model realizes only rho~0.16 (P2_MATCHED_TRANSFER).
333
+ # So the `outcome_validated` scope is earned ONLY when the served features are declared exact-site; otherwise the
334
+ # provenance is downgraded. Undeclared -> treated as non-exact (under-claim, never over-claim).
335
+ _EXACT_SITE_RES = {"exact_site", "exact-site", "per_integration", "per-integration",
336
+ "single_bp", "single-bp", "point", "site", "basepair", "bp"}
337
+
338
+ # The silenced/escaper classifier (`p_silenced`) shares the expression head's features but is NOT validated:
339
+ # on the Leemans human K562 held-out set it scores AUROC 0.50 (chance) -- the escaper/repressed label is not
340
+ # predictable from chromatin marks. We ship the expression head (predicted_log2_expression / relative) and
341
+ # label p_silenced as a not-validated candidate, never a claim, so a chance-level number is never presented as
342
+ # a platform result. (benchmarks/position_effect_human/metrics.json: silencing_classifier.auroc = 0.50.)
343
+ _SILENCING_NOT_VALIDATED = (
344
+ "silencing_classifier_not_validated (p_silenced is reported as a candidate only: the escaper/repressed "
345
+ "label is not predictable from chromatin marks -- AUROC 0.50, chance, on the Leemans human K562 held-out "
346
+ "set; use predicted_log2_expression / relative_expression_learned, not p_silenced)")
347
+
348
+
349
+ def _served_feature_resolution(design: dict) -> str:
350
+ """The caller-declared resolution of the supplied chromatin features ('undeclared' if absent). The caller
351
+ must assert exact-site resolution to earn the `outcome_validated` scope; we never infer it from the data."""
352
+ ctx = design.get("chromatin_ctx")
353
+ res = (design.get("chromatin_features_resolution") or design.get("feature_resolution")
354
+ or (ctx.get("resolution") if isinstance(ctx, dict) else None))
355
+ return str(res).lower().strip() if res else "undeclared"
356
+
357
+
358
+ def _is_exact_site(res: str) -> bool:
359
+ return res in _EXACT_SITE_RES
360
+
361
+
362
+ def predict_stage_h(design: dict, root=None, cell_type=None) -> dict | None:
363
+ """The learned, trained-conformal position-effect prediction, returned ONLY when a chromatin
364
+ context (the model's marks) is supplied AND a model artifact is present; else None and the caller falls
365
+ back to the closed-form heuristic. No fabrication: missing context -> no learned claim.
366
+
367
+ A human K562 design is served by the human K562 head (Leemans 2019) when its artifact is present,
368
+ falling back to the mESC model otherwise -- so the served provenance/scope reflect which
369
+ supervision was used (cross-species transfer no longer silently asserted)."""
370
+ hmodel = load_human_k562_model(root) if _is_human_k562(design, cell_type) else None
371
+ model = hmodel or load_cached_model(root)
372
+ served_human = hmodel is not None
373
+ if model is None or not model.features:
374
+ return None
375
+ cf = design.get("chromatin_features")
376
+ if not isinstance(cf, dict) and isinstance(design.get("chromatin_ctx"), dict):
377
+ cf = design["chromatin_ctx"].get("features")
378
+ if not isinstance(cf, dict):
379
+ return None
380
+ present = [f for f in model.features if f in cf]
381
+ if len(present) < max(3, len(model.features) // 2): # need enough real context to be meaningful
382
+ return None
383
+ row = {f: float(cf.get(f, 0.0)) for f in model.features}
384
+ cassette = str(design.get("cassette") or design.get("promoter")
385
+ or (next(iter(model.cassette_means), "_")))
386
+ one = pd.DataFrame([{**row, "cassette": cassette}])
387
+ iv = model.predict_interval(one)
388
+ yhat = float(iv["yhat"][0])
389
+ lo = float(iv["lo"][0]) if iv.get("lo") is not None else None
390
+ hi = float(iv["hi"][0]) if iv.get("hi") is not None else None
391
+
392
+ # Resolution-aware provenance: the human head's held-out validation holds at EXACT-SITE feature resolution;
393
+ # on coarse features the realized performance is far lower (rho~0.16). Only stamp `outcome_validated` when the
394
+ # caller declares exact-site features; otherwise downgrade (never claim more than the served resolution
395
+ # delivers). The mESC head makes no cross-cell-type outcome claim regardless of resolution.
396
+ res = _served_feature_resolution(design)
397
+ validated = bool(served_human and _is_exact_site(res))
398
+ if served_human and validated:
399
+ ct_flag = ("human_K562_outcome_validated (Leemans 2019; chromosome-blocked held-out; exact-site features "
400
+ "rho~0.57, +0.065 over a learned-heterochromatin baseline, CI[0.026,0.103])")
401
+ prov = ("twin.position_effect, human K562 head (Leemans 2019, cassette x context + split-conformal); "
402
+ "served at exact-site feature resolution -> the validated regime")
403
+ elif served_human:
404
+ ct_flag = (f"human_K562_head_served_UNVALIDATED_RESOLUTION (validated ONLY at exact-site resolution: "
405
+ f"rho~0.57, +0.065 over a learned-heterochromatin baseline, CI[0.026,0.103]; served-feature "
406
+ f"resolution '{res}' is not exact-site -> realized rho~0.16 (P2_MATCHED_TRANSFER); supply "
407
+ f"exact-site per-locus chromatin features to realize the validated performance)")
408
+ prov = ("twin.position_effect, human K562 head (Leemans 2019, cassette x context + split-conformal); "
409
+ f"served at '{res}' feature resolution -- BELOW the exact-site regime the head was validated at "
410
+ "(realized rho~0.16; not outcome_validated at this resolution)")
411
+ else:
412
+ ct_flag = "single_context_supervision (mESC) - cross-cell-type transfer data-gated"
413
+ prov = "twin.position_effect, learned cassette x context + split-conformal (TRIP mESC supervision)"
414
+
415
+ return {
416
+ "predicted_log2_expression": round(yhat, 4),
417
+ "interval_log2": [round(lo, 4), round(hi, 4)] if lo is not None else None,
418
+ "relative_expression_learned": (round(model.relative(yhat), 4) if model.relative(yhat) is not None else None),
419
+ "p_silenced": round(float(model.predict_silenced(one)[0]), 4),
420
+ "interval_kind": iv["interval_kind"],
421
+ "ood_widen": (round(float(iv["ood_widen"][0]), 3) if iv.get("ood_widen") is not None else None),
422
+ "n_context_features_used": len(present),
423
+ "served_feature_resolution": res,
424
+ "outcome_validated": validated,
425
+ "cassette": cassette,
426
+ "trained_on": {"n": model.meta.get("n"), "datasets": model.meta.get("datasets"),
427
+ "cell_types": model.meta.get("cell_types")},
428
+ "scope_flags": ["learned_position_effect", "trained_conformal", ct_flag,
429
+ _SILENCING_NOT_VALIDATED],
430
+ "provenance": prov,
431
+ "output_kind": "candidate",
432
+ }
433
+
434
+
435
+ def conformal_heldout_coverage(oof: dict, alpha: float = 0.10, seed: int = 20260619) -> dict:
436
+ """Conformal coverage: calibrate qhat on a RANDOM HALF of the chromosomes and evaluate coverage on
437
+ the held-out half (exchangeable split). Evaluating on the calibration set itself would trivially report
438
+ nominal coverage, this does not. This is the validation number; the shipped qhat uses all OOF residuals."""
439
+ from pen_stack.wgenome.uncertainty import conformal_quantile
440
+ y = np.asarray(oof["expression_true"], float)
441
+ yhat = np.asarray(oof["expression_pred"], float)
442
+ groups = np.asarray(oof["groups"])
443
+ rng = np.random.default_rng(seed)
444
+ uniq = np.unique(groups)
445
+ rng.shuffle(uniq)
446
+ calib_g = set(uniq[: len(uniq) // 2])
447
+ cm = np.array([g in calib_g for g in groups])
448
+ tm = ~cm
449
+ qhat = conformal_quantile(np.abs(y[cm] - yhat[cm]), alpha)
450
+ covered = (y[tm] >= yhat[tm] - qhat) & (y[tm] <= yhat[tm] + qhat)
451
+ return {"nominal": round(1 - alpha, 3), "heldout_coverage": float(np.mean(covered)),
452
+ "qhat_calib": float(qhat), "mean_width": float(2 * qhat),
453
+ "n_calib": int(cm.sum()), "n_test": int(tm.sum()),
454
+ "within_tol": bool(abs(float(np.mean(covered)) - (1 - alpha)) <= 0.03)}
@@ -0,0 +1 @@
1
+ """pen_stack.ui - see the PEN-STACK program doc."""