ggautreau commited on
Commit
03fcfc5
·
verified ·
1 Parent(s): 83b999b

Coupled regions with the model: verdicts, close links, influence on click; Findings follow the window

Browse files
pg_knock.py ADDED
@@ -0,0 +1,1376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-silico knockout of the "Coupled regions" links with Bacformer (causal): the library.
2
+
3
+ pg_region_sets.py found 533 links between 290 accessory elements from presence/absence within lineages,
4
+ without the model. Here each link is put to the model: in every complete chromosome where the link can be
5
+ tested, the upstream element (dnaA reading order, decided per genome) is deleted and the model's belief in
6
+ the downstream element is read, in ONE causal fp32 pass over the whole chromosome (<= 5,584 proteins).
7
+
8
+ units (link, genome) where the link is testable; carriers are defined AT the element's spot (the
9
+ validated construction of the scouts' ko_units3.py, ported here unchanged: panRGP spot block,
10
+ consensus backbone flanks for loose regions, per-genome orientation, entry gene, insertion
11
+ point of an absent element)
12
+ co-occurrence genome carries both expected: deleting U LOWERS logit P(D entry)
13
+ avoidance carries U, not D (cleanly: expected: deleting U RAISES
14
+ no D family anywhere, none log P(D entry family) - log P(resident)
15
+ at D's spot) at D's insertion point
16
+ skipped (reason recorded): U or D overlapping, < 100 genes apart (MIN_APART; > MAX_APART when set),
17
+ wrapping the origin, resident = D's entry family, no entry family, no position.
18
+ deletion U's member genes (families of the region, never a backbone family) inside its element span
19
+ readout decoded family probability P(f | ctx) = sum_c P(c | ctx) P(f | c) over the FULL softmax at the
20
+ readout row, the decoder P(f | c) rebuilt from fp32 full-chromosome baseline passes (top 64 per
21
+ gene), cross-validated by lineage: a genome is always read with the decoder of the other half
22
+ controls per genome a panel of <= 40 natural accessory elements (runs of panRGP genes of one spot, at
23
+ spots holding none of the 290 coupled regions), sizes matched to the U deletions, spread along
24
+ the chromosome; matched null per unit + a per-genome regression
25
+ numerics every pass of a genome has the same padded length and the head is applied on fixed-size row
26
+ chunks, so identical prefixes give bit-identical readouts: deleting nothing gives delta == 0
27
+ exactly and deleting genes after a readout row leaves it exactly unchanged (asserted in every
28
+ pass, not only in the dedicated check passes)
29
+
30
+ link sets Data(sets=PATH) reads any file in the format of pgb/region_sets.json (default); the units cache
31
+ is keyed by the md5 of the whole file (for the default file, < 1 MiB, the key is unchanged)
32
+ close range (pgb/region_sets_close.json: pairs 50-500 genes apart in the genomes carrying both, two separate
33
+ insertions, replicated in lineage-disjoint halves) MAX_APART bounds a unit's U-D distance (600 for these
34
+ sets; a unit is one genome's pair in dnaA reading order and can be thousands of genes apart, across the
35
+ origin); near_controls() adds whole-element controls at about a unit's own distance upstream of D, and
36
+ MATCH_LOG = F a third matching tier (distance within x1/F..xF): at 50-100 genes a genome rarely holds 3
37
+ whole elements in the +-35 % / +-60 % windows. Defaults leave the long-range behaviour unchanged.
38
+ influence what the model itself knows, independent of any link: after a deletion, the decoded logit
39
+ P(true family) (same lineage-CV decoder, fp32 pass, float64 decoding, read through a sparse
40
+ copy of the decoder rows: Decoder.srow, Runner.decode_true) at every gene downstream of the
41
+ deleted element. Data.elements(g) lists the accessory elements of a genome (panRGP runs at
42
+ every spot, by spot id; coupled regions present at their spot, by region id) and their entry
43
+ genes. influence_readout() turns one pass into 25-gene bins of the mean delta (and mean
44
+ |delta|, and the mean over panRGP genes) out to 1,500 genes, plus the delta at every element
45
+ entry out to 2,400 genes (1,500 x 1.6, so that the matched null of an entry near 1,500 genes,
46
+ distance +-35 % widened to +-60 %, is on file too). The 25 genes before the first deleted gene
47
+ are read as well and must be exactly unchanged (causality, check (g)).
48
+ load_influence(g*.npz) reads them back as tables; influence_matrix(g*.npz) is a reference
49
+ aggregation of the element-to-element matrix against the units' matched whole-element null (same
50
+ entry, size x0.5-2, distance +-35 %). influence(g, element) does it all on demand for one genome
51
+ (baseline + deletion + 8 size-matched whole-element controls), served by serve_live.py (/influence,
52
+ /elements: the page's own server, locally and in the Space) and by serve_influence.py (a process of its
53
+ own: its own port and GPU cap).
54
+ compact decoder influence() reads only the sparse decoder rows (Decoder.srow) of the families of the genome, with
55
+ the decoder of the other lineage half. export_decoder() writes exactly those rows, both halves, to
56
+ pgb/knock/decoder_halves.npz (cluster ids uint16, P(f | c) float64: the same rows bit for bit, 1/12 of
57
+ base_top64_{f,p}.npy), and Decoder uses that file when base_top64_{f,p}.npy are absent (the Space).
58
+ python3 -c "import pg_knock as K; K.export_decoder()"
59
+
60
+ Reads the project's data read-only; writes only under pgb/knock/ (through pg_knockout.py, the units cache and
61
+ export_decoder()).
62
+ """
63
+ import os, json, time, zlib, pickle, hashlib, collections
64
+ import numpy as np
65
+
66
+ PROJ = os.path.dirname(os.path.abspath(__file__)) # the project (also the Space's app directory)
67
+ KDIR = f"{PROJ}/pgb/knock"
68
+ MODEL = "macwiatrak/bacformer-causal-complete-genomes"
69
+ NCL = 50001 # softmax width of gm_head (50,000 clusters + END)
70
+ TOPK = 64
71
+ CLS, PROT = 2, 4
72
+ SLACK, NEAR, GAP, CARRY = 5, 200, 30, 0.5 # as ko_units3.py
73
+ MIN_APART = 100 # genes between U's last gene and D's readout gene
74
+ MAX_APART = None # at most this many (None: no bound, as before); 600 for the close-range sets
75
+ FLOOR = 1e-12 # probability floor of every log
76
+ HEAD_CHUNK = 64 # rows per head call (fixed shape -> bit-identical rows across passes)
77
+ TRUE_CHUNK = HEAD_CHUNK # rows per head call of decode_true (256 was no faster and moved the logits by 2.5e-5)
78
+ PANEL_MAX = 40
79
+ ANCHOR_WIN, ANCHOR_SHARE = 40, 0.6 # where an absent element's entry would sit: carriers' flanking families
80
+ POOL_MAX = 72 # control deletions (whole accessory elements) per genome
81
+ MATCH = (0.5, 2.0, 0.35) # matched null: size x0.5-2 of U's, distance to the readout within +-35 %
82
+ WIDEN = (1 / 3.0, 3.0, 0.6) # adaptive widening when fewer than 3 controls match
83
+ MATCH_LOG = None # a third tier when still < 3: distance within x1/F..xF (log scale), size x1/3-3 (None: off,
84
+ # as before). At 50-100 genes a genome rarely holds 3 whole elements within +-60 % of the
85
+ # unit's distance (10 % of the close units); x2 reaches 21 %, x3 31 % (pg_knockout --match_log)
86
+ OFF_TOL = 0.35 # off-target readouts: within +-35 % of D's distance from U
87
+ SETS_DEFAULT = f"{PROJ}/pgb/region_sets.json"
88
+ DECODER_NPZ = f"{KDIR}/decoder_halves.npz" # the compact decoder (export_decoder), used when base_top64 is absent
89
+ INF_WIN, INF_BIN = 1500, 25 # influence profile: genes downstream of the deleted element, bin width
90
+ INF_ENT = int(INF_WIN * (1 + WIDEN[2])) # element entries read out to 2,400 genes: the widened matched null
91
+ INF_UP = 25 # genes read before the first deleted gene: must be exactly unchanged (check g)
92
+ MEM_FRAC = 0.35 # per-process GPU memory cap (two runs share the 8 GB card)
93
+
94
+
95
+ def log(*a):
96
+ print(time.strftime("%H:%M:%S"), *a, flush=True)
97
+
98
+
99
+ # ============================================================================ data and units
100
+ def units_cache_file(sets=None):
101
+ """the units cache that Data(sets) reads or writes (the same key as Data.__init__): space/assemble.py ships it so that
102
+ the Space's first /elements does not rebuild it (~5-10 s)"""
103
+ raw = open(os.path.abspath(sets) if sets else SETS_DEFAULT, "rb").read()
104
+ NP = int(np.load(f"{PROJ}/pgb/chrom.npz")["offsets"][-1])
105
+ return f"{KDIR}/units_cache_{hashlib.md5(raw + str((NP, SLACK, NEAR, GAP, CARRY)).encode()).hexdigest()[:12]}.pkl"
106
+
107
+
108
+ class Data:
109
+ """chromosomes, regions, links, and the per (region, genome) element / insertion point of ko_units3.py"""
110
+
111
+ def __init__(self, cache=True, sets=None):
112
+ t0 = time.time()
113
+ self.SETS = os.path.abspath(sets) if sets else SETS_DEFAULT
114
+ raw = open(self.SETS, "rb").read()
115
+ self.SETS_MD5 = hashlib.md5(raw).hexdigest()
116
+ self.RS = RS = json.loads(raw)
117
+ Z = np.load(f"{PROJ}/pgb/chrom.npz")
118
+ self.OFF = OFF = Z["offsets"]; self.FAM = FAM = Z["fam"]; self.SPOT = Z["spot"]; self.RGP = Z["rgp"]; self.PID = Z["pid"]
119
+ self.NG = NG = len(OFF) - 1; self.GL = np.diff(OFF); self.NP = int(OFF[-1])
120
+ CJ = json.load(open(f"{PROJ}/pgb/chrom_genomes.json"))
121
+ self.FN = FN = CJ["families"]; self.NF = len(FN); self.FIDX = {n: i for i, n in enumerate(FN)}
122
+ self.ACC = [g["acc"] for g in CJ["genomes"]]
123
+ self.gene_names = CJ.get("gene_names")
124
+ self.REG = RS["regions"]; self.LINKS = RS["links"]; self.NR = len(self.REG)
125
+ self.FAMS = [[self.FIDX[n] for n in r["families"]] for r in self.REG]
126
+ rsg = {g["acc"]: g for g in RS["genomes"]}
127
+ self.CL = np.array([rsg[a]["cl"] for a in self.ACC])
128
+ self.STRAIN = [rsg[a].get("strain", "") for a in self.ACC]
129
+ self.first = np.zeros(self.NP, bool); self.first[OFF[:-1]] = True
130
+ self.GID = np.repeat(np.arange(NG), self.GL)
131
+ self.LOC = np.arange(self.NP) - OFF[self.GID]
132
+ NPROT = os.path.getsize(f"{PROJ}/pgb/chrom_emb.f16") // (480 * 2)
133
+ self.EMB = np.memmap(f"{PROJ}/pgb/chrom_emb.f16", dtype=np.float16, mode="r", shape=(NPROT, 480))
134
+ self._lineages()
135
+ # keyed by the WHOLE sets file (the key used to hash its first MiB only: identical for files < 1 MiB,
136
+ # as the default one is, so its existing cache stays valid)
137
+ key = hashlib.md5(raw + str((self.NP, SLACK, NEAR, GAP, CARRY)).encode()).hexdigest()[:12]
138
+ cf = f"{KDIR}/units_cache_{key}.pkl"
139
+ loaded = False
140
+ if cache and os.path.exists(cf):
141
+ try: self.__dict__.update(pickle.load(open(cf, "rb"))); loaded = True
142
+ except Exception as e: log(f"units cache {os.path.basename(cf)} unreadable ({type(e).__name__}: {e}): rebuilt")
143
+ if not loaded:
144
+ self._locate()
145
+ if cache:
146
+ keep = ("BB", "PRES", "HAS", "TEST", "EL", "INS", "ENTRY", "FLANK", "how")
147
+ try:
148
+ os.makedirs(KDIR, exist_ok=True)
149
+ with open(cf + ".tmp", "wb") as fh: pickle.dump({k: self.__dict__[k] for k in keep}, fh)
150
+ os.replace(cf + ".tmp", cf)
151
+ except OSError as e: log(f"units cache {os.path.basename(cf)} not written ({e}): kept in memory")
152
+ self.COUPLED_SPOTS = {r["spot"] for r in self.REG if r["spot"] is not None}
153
+ self.cache_file = cf
154
+ log(f"data ready in {time.time()-t0:.0f}s: {NG} chromosomes, {self.NR} regions, {len(self.LINKS)} links"
155
+ + ("" if self.SETS == SETS_DEFAULT else f" (sets {os.path.relpath(self.SETS, PROJ)}, md5 {self.SETS_MD5[:12]})"))
156
+
157
+ # ---------------------------------------------------------------- lineage clusters and their halves
158
+ def _lineages(self):
159
+ """lineage = region_sets.json genomes[i]['cl'] (cut 0.4); cl <= 0 counts as its own lineage (singletons
160
+ already are). Halves: a fixed hash (crc32) of the lineage id, so a lineage is always on one side."""
161
+ lin = []
162
+ for g in range(self.NG):
163
+ c = int(self.CL[g]); lin.append(f"cl{c}" if c > 0 else f"acc:{self.ACC[g]}")
164
+ self.LIN = np.array(lin, dtype=object)
165
+ self.HALF = np.array([zlib.crc32(l.encode()) % 2 for l in lin])
166
+ self.LIN_HALF = {l: int(h) for l, h in zip(lin, self.HALF)}
167
+
168
+ def release_dates(self):
169
+ """ncbi_seq_rel_date from the PanGBank metadata in the pangenome file (pgb/genome_meta.json lacks it)"""
170
+ import tables, warnings
171
+ warnings.simplefilter("ignore")
172
+ with tables.open_file(f"{PROJ}/pgb/ecoli_11587.h5") as h:
173
+ t = h.root.metadata.genomes.pangbank_wf.read()
174
+ d = {i.decode(): r.decode() for i, r in zip(t["ID"], t["ncbi_seq_rel_date"])}
175
+ return [d.get(a, "") for a in self.ACC]
176
+
177
+ # ---------------------------------------------------------------- ko_units3.py, ported
178
+ def _circ_blocks(self, ps, L, gap=GAP):
179
+ ps = sorted(ps)
180
+ if len(ps) == 1: return [(ps[0], ps[0], 1)]
181
+ d = [(ps[(i + 1) % len(ps)] - ps[i]) % L for i in range(len(ps))]
182
+ cuts = [i for i, x in enumerate(d) if x > gap]
183
+ if not cuts: return [(ps[0], ps[-1], len(ps))]
184
+ out = []
185
+ for k in range(len(cuts)):
186
+ i = (cuts[k] + 1) % len(ps); j = cuts[(k + 1) % len(cuts)]; idx = []
187
+ while True:
188
+ idx.append(ps[i])
189
+ if i == j: break
190
+ i = (i + 1) % len(ps)
191
+ out.append((idx[0], idx[-1], len(idx)))
192
+ return out
193
+
194
+ def _locate(self):
195
+ OFF, FAM, SPOT, NG, GL, NF, REG, NR, FAMS = self.OFF, self.FAM, self.SPOT, self.NG, self.GL, self.NF, self.REG, self.NR, self.FAMS
196
+ fampos, spotpos = [], []
197
+ for g in range(NG):
198
+ a = collections.defaultdict(list); b = collections.defaultdict(list)
199
+ for p, (f, s) in enumerate(zip(FAM[OFF[g]:OFF[g + 1]].tolist(), SPOT[OFF[g]:OFF[g + 1]].tolist())):
200
+ a[f].append(p)
201
+ if s >= 0: b[s].append(p)
202
+ fampos.append(a); spotpos.append(b)
203
+ uk, cnt = np.unique(self.GID.astype(np.int64) * NF + FAM, return_counts=True)
204
+ self.BB = BB = np.bincount((uk % NF)[cnt == 1], minlength=NF) >= 0.95 * NG
205
+ share = np.zeros((NR, NG))
206
+ for ri, fs in enumerate(FAMS):
207
+ for g in range(NG): share[ri, g] = sum(1 for f in fs if fampos[g].get(f)) / len(fs)
208
+ self.PRES = PRES = share >= CARRY
209
+ leaf = self._leaf()
210
+ bad = sum(str(min(9, int(share[ri, g] * 9.999))) != REG[ri]["presence"][leaf[g]]
211
+ for ri in range(NR) for g in range(NG))
212
+ assert bad == 0, f"presence strings of region_sets.json not reproduced ({bad} digits differ)"
213
+
214
+ def bb_flanks(g, s, e):
215
+ L = int(GL[g]); sl = FAM[OFF[g]:OFF[g + 1]]; out = []
216
+ for step, p0 in ((-1, s), (+1, e)):
217
+ p = p0
218
+ for _ in range(500):
219
+ p = (p + step) % L; f = int(sl[p])
220
+ if BB[f] and len(fampos[g].get(f, ())) == 1: out.append(f); break
221
+ else: out.append(None)
222
+ return out
223
+ FLANK = []
224
+ for ri, r in enumerate(REG):
225
+ lc = collections.Counter(); rc = collections.Counter(); n = 0
226
+ for g in range(NG):
227
+ if not PRES[ri, g]: continue
228
+ ps = [p for f in FAMS[ri] for p in fampos[g].get(f, ())]
229
+ if not ps: continue
230
+ ref = (min(spotpos[g][r["spot"]]) if (r["spot"] is not None and spotpos[g].get(r["spot"])) else r["center"])
231
+ bl = sorted(self._circ_blocks(ps, int(GL[g])), key=lambda b: (abs((b[0] + b[1]) / 2 - ref), -b[2]))[0]
232
+ lf, rf = bb_flanks(g, bl[0], bl[1]); n += 1
233
+ if lf is not None: lc[lf] += 1
234
+ if rf is not None: rc[rf] += 1
235
+ FLANK.append(dict(left=int(lc.most_common(1)[0][0]) if lc else None, right=int(rc.most_common(1)[0][0]) if rc else None,
236
+ lfrac=(lc.most_common(1)[0][1] / n) if lc and n else 0., n=n))
237
+ EL = {}; INS = np.full((NR, NG), -1); how = collections.Counter()
238
+ for ri, r in enumerate(REG):
239
+ s = r["spot"]
240
+ for g in range(NG):
241
+ blk = spotpos[g].get(s) if s is not None else None
242
+ member = [p for f in FAMS[ri] for p in fampos[g].get(f, ())]
243
+ if blk:
244
+ lo, hi = min(blk), max(blk); INS[ri, g] = lo; how["spot block"] += 1
245
+ inside = sorted(p for p in member if lo - SLACK <= p <= hi + SLACK)
246
+ if inside:
247
+ cl = sorted(self._circ_blocks(inside, int(GL[g])), key=lambda b: (-b[2], abs(b[0] - lo)))[0]
248
+ EL[(ri, g)] = dict(start=cl[0], end=cl[1], members=cl[2], span=(cl[1] - cl[0]) % int(GL[g]) + 1,
249
+ where="spot", spot_lo=lo, spot_hi=hi)
250
+ continue
251
+ lf = FLANK[ri]["left"]; lp = fampos[g].get(lf, ()) if lf is not None else ()
252
+ rf = FLANK[ri]["right"]; rp = fampos[g].get(rf, ()) if rf is not None else ()
253
+ pt = lp[0] + 1 if len(lp) == 1 else (rp[0] if len(rp) == 1 else None)
254
+ if pt is None: how["nowhere"] += 1; continue
255
+ INS[ri, g] = pt; how["flanks"] += 1
256
+ if member:
257
+ bl = sorted(self._circ_blocks(member, int(GL[g])), key=lambda b: (abs((b[0] + b[1]) / 2 - pt), -b[2]))[0]
258
+ if abs(bl[0] - pt) <= NEAR:
259
+ EL[(ri, g)] = dict(start=bl[0], end=bl[1], members=bl[2], span=(bl[1] - bl[0]) % int(GL[g]) + 1,
260
+ where="flanks", spot_lo=None, spot_hi=None)
261
+ HAS = np.zeros((NR, NG), bool)
262
+ for (ri, g) in EL: HAS[ri, g] = True
263
+ TEST = PRES & HAS
264
+ ENTRY = []
265
+ for ri in range(NR):
266
+ c = collections.Counter(); n = 0
267
+ for g in range(NG):
268
+ if TEST[ri, g]: c[int(FAM[OFF[g] + EL[(ri, g)]["start"]])] += 1; n += 1
269
+ ENTRY.append(dict(family=int(c.most_common(1)[0][0]) if c else None, frac=(c.most_common(1)[0][1] / n) if n else 0., n=n))
270
+ self.EL, self.INS, self.HAS, self.TEST, self.ENTRY, self.FLANK, self.how = EL, INS, HAS, TEST, ENTRY, FLANK, dict(how)
271
+
272
+ def _leaf(self):
273
+ m = {g["acc"]: i for i, g in enumerate(self.RS["genomes"])}
274
+ return [m[a] for a in self.ACC]
275
+
276
+ # ---------------------------------------------------------------- where an ABSENT element's entry would sit
277
+ def anchors(self, ri):
278
+ """The families that flank region ri in its carriers: (pred family, share, succ family, share).
279
+ pred = the family of the gene just before the element's entry, succ = just after its last gene."""
280
+ if not hasattr(self, "_anch"): self._anch = {}
281
+ if ri not in self._anch:
282
+ pc, sc, n = collections.Counter(), collections.Counter(), 0
283
+ for g in range(self.NG):
284
+ if not self.TEST[ri, g]: continue
285
+ e = self.EL[(ri, g)]; a = self.OFF[g]; L = int(self.GL[g]); n += 1
286
+ pc[int(self.FAM[a + (e["start"] - 1) % L])] += 1
287
+ sc[int(self.FAM[a + (e["end"] + 1) % L])] += 1
288
+ self._anch[ri] = ((int(pc.most_common(1)[0][0]), pc.most_common(1)[0][1] / n) if n else (None, 0.0),
289
+ (int(sc.most_common(1)[0][0]), sc.most_common(1)[0][1] / n) if n else (None, 0.0))
290
+ return self._anch[ri]
291
+
292
+ def ins_row(self, ri, g):
293
+ """Where region ri's ENTRY gene would sit in genome g if the element were inserted at its spot, read from the
294
+ CARRIERS' context. INS (the spot-block start, or the gene after the left backbone flank) is NOT where the
295
+ element starts in carriers -- for nanS it is 12 genes off, for the T6SS the preceding gene is itself absent --
296
+ so reading P(D entry) there lands in the far tail (1e-6) and measures nothing (both reviews' blocker).
297
+ 1. row = 1 + the copy nearest INS (within ANCHOR_WIN) of the family preceding the entry in >= 60 % of carriers
298
+ 2. else row = the copy nearest INS of the family following the last gene in >= 60 % of carriers
299
+ (the element would be inserted before that gene, so its entry would take that gene's index)
300
+ 3. else no insertion anchor -> the unit is skipped
301
+ Returns (row, how) or (None, why)."""
302
+ p0 = int(self.INS[ri, g])
303
+ if p0 < 0: return None, "no insertion point"
304
+ rules = self.anchor_rules(ri)
305
+ if not rules: return None, "no insertion anchor (no flanking family of D that is consensus in its carriers " \
306
+ "and reproduces D's entry when the rule is applied to a carrier)"
307
+ a, L = self.OFF[g], int(self.GL[g])
308
+ sl = self.FAM[a:a + L]
309
+ for fam, off, how in rules:
310
+ c = [p for p in range(max(0, p0 - ANCHOR_WIN), min(L, p0 + ANCHOR_WIN + 1)) if int(sl[p]) == fam]
311
+ if not c: continue
312
+ r = min(c, key=lambda p: (abs(p - p0), p)) + off
313
+ if 0 <= r < L: return int(r), how
314
+ return None, "no insertion anchor (D's flanking family is not within 40 genes of the spot here)"
315
+
316
+ def anchor_rules(self, ri):
317
+ """The flanking families that can place region ri's entry, in preference order: the family preceding the entry
318
+ in >= 60 % of carriers, then the family following the last gene, keeping only those whose rule reproduces D's
319
+ real entry in >= 50 % of the carriers. Frequency alone is not enough -- for 13 % of the regions on the absent
320
+ side of an avoidance link the most frequent neighbour does not mark where the element starts, and reading there
321
+ would repeat the error this fix is about. Each entry is (family, offset, name)."""
322
+ if not hasattr(self, "_arule"): self._arule = {}
323
+ if ri not in self._arule:
324
+ (fp, sp), (fs, ss) = self.anchors(ri)
325
+ out = []
326
+ for fam, share, off, how in ((fp, sp, 1, "pred"), (fs, ss, 0, "succ")):
327
+ if fam is None or share < ANCHOR_SHARE: continue
328
+ if self._anchor_validate(ri, fam, off) >= 0.5: out.append((int(fam), off, how))
329
+ self._arule[ri] = out
330
+ return self._arule[ri]
331
+
332
+ def _anchor_validate(self, ri, fam, off):
333
+ """share of ri's carriers in which the rule returns D's real entry gene (off = 1) / the gene after its last
334
+ gene (off = 0), which is where the entry would land if the element were inserted before that gene"""
335
+ ok = n = 0
336
+ for g in range(self.NG):
337
+ if not self.TEST[ri, g]: continue
338
+ e = self.EL[(ri, g)]; a = self.OFF[g]; L = int(self.GL[g]); sl = self.FAM[a:a + L]
339
+ p0 = int(self.INS[ri, g])
340
+ if p0 < 0: continue
341
+ want = e["start"] if off == 1 else (e["end"] + 1) % L
342
+ c = [p for p in range(max(0, p0 - ANCHOR_WIN), min(L, p0 + ANCHOR_WIN + 1)) if int(sl[p]) == fam]
343
+ n += 1
344
+ if c and min(c, key=lambda p: (abs(p - p0), p)) + off == want: ok += 1
345
+ return (ok / n) if n else 0.0
346
+
347
+ def anchor_check(self, ri):
348
+ """Validation of the rule actually used for region ri: (n carriers, share of them where it returns D's real
349
+ entry, rule name). Both candidate rules are also reported, to show what was rejected."""
350
+ rules = self.anchor_rules(ri)
351
+ n = int(self.TEST[ri].sum())
352
+ (fp, sp), (fs, ss) = self.anchors(ri)
353
+ both = {}
354
+ for fam, share, off, how in ((fp, sp, 1, "pred"), (fs, ss, 0, "succ")):
355
+ both[how] = (self._anchor_validate(ri, fam, off) if (fam is not None and share >= ANCHOR_SHARE) else np.nan)
356
+ if not rules: return n, np.nan, None, both
357
+ return n, self._anchor_validate(ri, rules[0][0], rules[0][1]), "+".join(r[2] for r in rules), both
358
+
359
+ def pos_of(self, ri, g):
360
+ if self.TEST[ri, g]: return int(self.EL[(ri, g)]["start"])
361
+ return int(self.INS[ri, g]) if self.INS[ri, g] >= 0 else None
362
+
363
+ def members(self, ri, g):
364
+ """U's member genes inside its element span (0-based positions in genome g), never a backbone family"""
365
+ e = self.EL[(ri, g)]; a = self.OFF[g]
366
+ fs = set(self.FAMS[ri])
367
+ return [p for p in range(e["start"], e["end"] + 1) if int(self.FAM[a + p]) in fs and not self.BB[self.FAM[a + p]]]
368
+
369
+ # ---------------------------------------------------------------- testable units
370
+ def units(self, links):
371
+ """one unit = one link in one genome: delete U (upstream in this genome), read D.
372
+ Returns (units, skip counter, skipped list)."""
373
+ OFF, FAM = self.OFF, self.FAM
374
+ units, skipped = [], []
375
+ skip = collections.Counter()
376
+ for li in links:
377
+ l = self.LINKS[li]; a, b, sg = l["a"], l["b"], l["sign"]
378
+ for g in range(self.NG):
379
+ ca, cb = bool(self.TEST[a, g]), bool(self.TEST[b, g])
380
+ if sg > 0 and not (ca and cb): continue # not a co-occurrence genome: not a unit
381
+ if sg < 0 and ca == cb: continue # both or neither: not an avoidance genome
382
+ pa, pb = self.pos_of(a, g), self.pos_of(b, g)
383
+ why = None
384
+ if pa is None or pb is None: why = "no position for an element"
385
+ else:
386
+ up, dn = (a, b) if pa < pb else (b, a)
387
+ if sg < 0 and not self.TEST[up, g]: why = "avoidance: the absent element is upstream (would need an insertion)"
388
+ elif sg < 0 and (self.PRES[dn, g] or self.HAS[dn, g]):
389
+ why = "avoidance: D not cleanly absent (its families elsewhere or a minority at its spot)"
390
+ if why is None:
391
+ eu = self.EL[(up, g)]
392
+ if eu["end"] < eu["start"]: why = "U wraps the origin"
393
+ if why is None and sg > 0:
394
+ ed = self.EL[(dn, g)]
395
+ if ed["end"] < ed["start"]: why = "D wraps the origin"
396
+ how = ""
397
+ if why is None:
398
+ ue = eu["end"]
399
+ if sg > 0:
400
+ rd = int(self.EL[(dn, g)]["start"]); f1 = int(FAM[OFF[g] + rd]); f2 = -1
401
+ else:
402
+ f1 = self.ENTRY[dn]["family"]
403
+ rd, how = self.ins_row(dn, g)
404
+ if f1 is None: why = "avoidance: D has no entry family (no carrier)"
405
+ elif rd is None: why = "avoidance: " + how
406
+ else:
407
+ f2 = int(FAM[OFF[g] + rd])
408
+ if f1 == f2: why = "avoidance: the resident gene is D's entry family"
409
+ if why is None:
410
+ if rd <= ue: why = "U and D overlap (D's readout gene not after U's last gene)"
411
+ elif rd - ue - 1 < MIN_APART: why = f"U and D < {MIN_APART} genes apart"
412
+ elif MAX_APART is not None and rd - ue - 1 > MAX_APART: why = f"U and D > {MAX_APART} genes apart"
413
+ if why is None:
414
+ dels = self.members(up, g)
415
+ if not dels: why = "U has no member gene outside the backbone"
416
+ if why is not None:
417
+ skip[why] += 1; skipped.append(dict(li=li, g=g, sign=sg, why=why)); continue
418
+ sec = []
419
+ if sg > 0: # D's member genes (secondary readout)
420
+ sec = self.members(dn, g)
421
+ # how complete is the knockout? U's families left elsewhere in the chromosome (multi-copy elements:
422
+ # for some links the "knockout" leaves every family in place -- mechanics review finding 2)
423
+ sl = FAM[OFF[g]:OFF[g + 1]]; left = np.delete(sl, np.asarray(dels, np.int64))
424
+ fs = set(self.FAMS[up]); pres_f = {f for f in fs if (sl == f).any()}
425
+ u_left = sum(1 for f in pres_f if (left == f).any())
426
+ units.append(dict(li=li, g=g, sign=sg, up=up, dn=dn, u_start=int(eu["start"]), u_end=int(ue),
427
+ u_dels=dels, u_size=len(dels), u_span=int(eu["span"]), row=rd, f1=int(f1), f2=int(f2),
428
+ dist=int(rd - ue), d_sec=sec, row_how=how,
429
+ u_fam=len(pres_f), u_left=int(u_left),
430
+ f1_up=int((sl[:rd] == f1).sum()), # is the readout family specific to D?
431
+ d_start=int(self.EL[(dn, g)]["start"]) if sg > 0 else rd,
432
+ d_end=int(self.EL[(dn, g)]["end"]) if sg > 0 else rd))
433
+ return units, skip, skipped
434
+
435
+ # ---------------------------------------------------------------- the exchangeable null and the off-targets
436
+ def links_of(self, ri):
437
+ """regions linked to ri in region_sets.json (either sign)"""
438
+ if not hasattr(self, "_lof"):
439
+ self._lof = collections.defaultdict(set)
440
+ for l in self.LINKS: self._lof[l["a"]].add(l["b"]); self._lof[l["b"]].add(l["a"])
441
+ return self._lof[ri]
442
+
443
+ def control_pool(self, g, want=None, sizes=(), uncapped=False):
444
+ """Whole accessory elements of genome g, each deleted by its member genes: the null for a U deletion.
445
+ The pilot's null was a contiguous SUB-RUN of a natural element, which is not the same kind of object as a
446
+ whole element (whole-vs-part shifts the readout as much as the effects being tested, inference review
447
+ finding 3), and it matched only 4 controls per unit (31 % of units unusable, finding 6). Whole COUPLED
448
+ elements are the right null: same construction as U, same size distribution (77 % are 1-2 genes), ~93 per
449
+ genome. Whole natural elements at spots holding no coupled region are kept as a second arm.
450
+ arm 'cpl': a coupled element present at its spot here; its region id is kept so that, per unit, controls
451
+ linked to U or D (or U/D themselves) are excluded
452
+ arm 'nat': a maximal run of panRGP genes of one spot at a spot with no coupled region, whole
453
+ `want`: rows that must not be deleted (readout genes). Capped at POOL_MAX, keeping a spread of sizes and
454
+ positions (uncapped=True: every candidate, for near_controls).
455
+ The 'nat' arm is small in practice: the cap keeps the controls that match most U sizes, and the natural runs
456
+ are longer than the 1-2-gene coupled elements, so ~4 % of the kept controls are natural (pilot: 1,714 of
457
+ 38,654 control passes; 0 in some genomes). The null is in effect "delete another coupled element"."""
458
+ a, b = int(self.OFF[g]), int(self.OFF[g + 1]); n = b - a
459
+ fam = self.FAM[a:b]; rgp = self.RGP[a:b]; sp = self.SPOT[a:b]
460
+ want = set() if want is None else set(int(x) for x in want)
461
+ pool = []
462
+ for ri in range(self.NR):
463
+ if not (self.HAS[ri, g] and self.PRES[ri, g]): continue
464
+ e = self.EL[(ri, g)]
465
+ if e["end"] < e["start"]: continue # wraps the origin
466
+ dels = self.members(ri, g)
467
+ if dels and not (set(dels) & want):
468
+ pool.append(dict(arm="cpl", ri=ri, start=int(e["start"]), end=int(e["end"]), dels=dels,
469
+ size=len(dels), spot=(e["spot_lo"] if e["where"] == "spot" else -1)))
470
+ i = 0
471
+ while i < n:
472
+ if rgp[i] and sp[i] >= 0:
473
+ j = i
474
+ while j + 1 < n and rgp[j + 1] and sp[j + 1] == sp[i]: j += 1
475
+ if int(sp[i]) not in self.COUPLED_SPOTS:
476
+ dels = [p for p in range(i, j + 1) if not self.BB[fam[p]]]
477
+ if dels and not (set(dels) & want):
478
+ pool.append(dict(arm="nat", ri=-1, start=i, end=j, dels=dels, size=len(dels), spot=int(sp[i])))
479
+ i = j + 1
480
+ else: i += 1
481
+ if not uncapped and len(pool) > POOL_MAX:
482
+ # keep the controls that can actually serve as a matched null here: score each by the number of this
483
+ # genome's U sizes it matches (x0.5-2), then by a spread of sizes and positions
484
+ sz = np.asarray(sizes, float)
485
+ def score(c):
486
+ n_ = int(np.sum((c["size"] >= MATCH[0] * sz) & (c["size"] <= MATCH[1] * sz))) if len(sz) else 0
487
+ return (-n_, c["size"], c["start"])
488
+ pool.sort(key=score)
489
+ head = [c for c in pool if score(c)[0] < 0][:POOL_MAX]
490
+ if len(head) < POOL_MAX:
491
+ rest = [c for c in pool if score(c)[0] == 0]
492
+ idx = np.unique(np.linspace(0, len(rest) - 1, POOL_MAX - len(head)).round().astype(int)) if rest else []
493
+ head += [rest[k] for k in idx]
494
+ pool = head
495
+ return sorted(pool, key=lambda c: c["start"])
496
+
497
+ def near_controls(self, g, units, pool, want=5):
498
+ """Extra whole-element controls for units whose matched null is thin (close range: with <= 72 controls spread
499
+ over ~5,000 genes, 0 % of the units 50-100 genes apart and 37 % of those 100-250 apart had >= 3 matched
500
+ controls in the close smoke run). For each unit of genome g, every candidate whole element (control_pool,
501
+ uncapped: coupled elements present at their spot, natural panRGP runs at spots without a coupled region) that
502
+ is usable in its null (no overlap with U or D, not U, D or a region linked to either, ending before D's
503
+ readout gene) and matches it under the widened rule (size x1/3-3, distance to the readout within +-60 %, or
504
+ within x1/F..xF with MATCH_LOG = F) is added, nearest in log distance and log size first, while it raises the unit's matched count as
505
+ match_controls() will compute it in the aggregation, until `want`. Returns the extra controls (not already in
506
+ `pool`), sorted by start."""
507
+ allc = self.control_pool(g, uncapped=True)
508
+ key = lambda c: (c["start"], c["end"], c["arm"], c["ri"])
509
+ have = {key(c) for c in pool}; extra = {}
510
+ for u in units:
511
+ banned = {u["up"], u["dn"]} | self.links_of(u["up"]) | self.links_of(u["dn"])
512
+
513
+ def usable(c):
514
+ return (c["end"] < u["row"] and not (c["start"] <= u["u_end"] and c["end"] >= u["u_start"])
515
+ and not (c["start"] <= u["d_end"] and c["end"] >= u["d_start"])
516
+ and not (c["ri"] >= 0 and c["ri"] in banned))
517
+
518
+ def nmatch(cs):
519
+ if not cs: return 0
520
+ idx, _w = match_controls(np.array([c["end"] for c in cs]), np.array([c["size"] for c in cs]),
521
+ np.ones(len(cs), bool), u["u_size"], u["dist"], u["row"])
522
+ return len(idx)
523
+ cur = [c for c in pool if usable(c)] + [c for c in extra.values() if usable(c)]
524
+ n0 = nmatch(cur)
525
+ if n0 >= want: continue
526
+ cand = [c for c in allc if key(c) not in have and key(c) not in extra and usable(c)
527
+ and WIDEN[0] * u["u_size"] <= c["size"] <= WIDEN[1] * u["u_size"]
528
+ and (abs((u["row"] - c["end"]) - u["dist"]) <= WIDEN[2] * u["dist"] or
529
+ (MATCH_LOG is not None and abs(np.log(max(u["row"] - c["end"], 1) / u["dist"])) <= np.log(MATCH_LOG)))]
530
+ cand.sort(key=lambda c: (abs(np.log((u["row"] - c["end"]) / u["dist"])), abs(np.log(c["size"] / u["u_size"])), c["start"]))
531
+ for c in cand:
532
+ n1 = nmatch(cur + [c])
533
+ if n1 > n0: cur.append(c); extra[key(c)] = c; n0 = n1
534
+ if n0 >= want: break
535
+ return sorted(extra.values(), key=lambda c: c["start"])
536
+
537
+ def offtargets(self, g, rows_needed):
538
+ """Readouts of OTHER coupled elements in genome g, to test whether a U deletion moves D specifically or moves
539
+ every element downstream (inference review blocker): a present element is read at its entry gene (co kind),
540
+ an absent element with an insertion anchor at that row (avoidance kind, resident = the gene there).
541
+ `rows_needed`: only positions in this set are built (the union of the units' distance windows)."""
542
+ a = int(self.OFF[g]); out = []
543
+ for ri in range(self.NR):
544
+ if self.TEST[ri, g]:
545
+ e = self.EL[(ri, g)]
546
+ if e["end"] < e["start"]: continue
547
+ r = int(e["start"])
548
+ if r in rows_needed: out.append(dict(ri=ri, kind=1, row=r, f1=int(self.FAM[a + r]), f2=-1))
549
+ elif not self.PRES[ri, g] and not self.HAS[ri, g]:
550
+ f1 = self.ENTRY[ri]["family"]
551
+ if f1 is None: continue
552
+ r, _how = self.ins_row(ri, g)
553
+ if r is None or r not in rows_needed: continue
554
+ f2 = int(self.FAM[a + r])
555
+ if f2 != f1: out.append(dict(ri=ri, kind=-1, row=int(r), f1=int(f1), f2=f2))
556
+ return out
557
+
558
+ def phi(self, ri, rj):
559
+ """across-genome (largely phylogenetic) correlation of two regions' presence: a region that merely marks a
560
+ strain background will move together with U without any within-lineage coupling"""
561
+ x = self.PRES[ri].astype(float); y = self.PRES[rj].astype(float)
562
+ if x.std() == 0 or y.std() == 0: return np.nan
563
+ return float(np.corrcoef(x, y)[0, 1])
564
+
565
+ # ---------------------------------------------------------------- the accessory elements of a genome (influence)
566
+ def elements(self, g):
567
+ """Every accessory element present in genome g, with its entry gene (the first gene in dnaA reading order):
568
+ kind 0 a maximal run of panRGP genes sharing one spot id (every spot, coupled or not), id = spot id;
569
+ size = its non-backbone genes (what a deletion of the whole run removes, as control_pool's 'nat')
570
+ kind 1 a coupled region present at its spot (PRES and HAS, the carriers of the units), id = region id,
571
+ element span EL, size = its member genes; elements wrapping the origin are left out
572
+ panRGP genes outside every spot are not listed (no id to follow them across genomes). A coupled element
573
+ usually lies inside a spot run, so both can share an entry gene. Sorted by entry; cached per genome."""
574
+ if not hasattr(self, "_elts"): self._elts = {}
575
+ if g in self._elts: return self._elts[g]
576
+ a, b = int(self.OFF[g]), int(self.OFF[g + 1]); n = b - a
577
+ fam = self.FAM[a:b]; rgp = self.RGP[a:b]; sp = self.SPOT[a:b]
578
+ out = []
579
+ i = 0
580
+ while i < n:
581
+ if rgp[i] and sp[i] >= 0:
582
+ j = i
583
+ while j + 1 < n and rgp[j + 1] and sp[j + 1] == sp[i]: j += 1
584
+ out.append((0, int(sp[i]), i, j, i, sum(1 for p in range(i, j + 1) if not self.BB[fam[p]])))
585
+ i = j + 1
586
+ else: i += 1
587
+ for ri in range(self.NR):
588
+ if not (self.HAS[ri, g] and self.PRES[ri, g]): continue
589
+ e = self.EL[(ri, g)]
590
+ if e["end"] < e["start"]: continue
591
+ out.append((1, ri, int(e["start"]), int(e["end"]), int(e["start"]), len(self.members(ri, g))))
592
+ out.sort(key=lambda t: (t[4], t[0], t[1]))
593
+ A = np.array(out, np.int64).reshape(-1, 6)
594
+ E = dict(kind=A[:, 0].astype(np.int8), id=A[:, 1].astype(np.int32), start=A[:, 2].astype(np.int32),
595
+ end=A[:, 3].astype(np.int32), entry=A[:, 4].astype(np.int32), size=A[:, 5].astype(np.int32))
596
+ E["fam"] = fam[E["entry"]].astype(np.int32) if len(A) else np.zeros(0, np.int32)
597
+ self._elts[g] = E
598
+ return E
599
+
600
+ def element_label(self, kind, eid):
601
+ return (self.REG[int(eid)]["label"] if int(kind) == 1 else f"spot {int(eid)}")
602
+
603
+ # ---------------------------------------------------------------- control panel
604
+ def panel(self, g, target_sizes, avoid, rng):
605
+ """<= PANEL_MAX control deletions in genome g, one per target size.
606
+ Natural accessory elements = maximal runs of panRGP genes sharing one spot id, at spots holding none of
607
+ the 290 coupled regions, not touching any coupled element here (`avoid` = positions). They are ~24 per
608
+ genome with >= 3-4 member genes each, while half the U deletions are 1-2 genes, so a control deletion is
609
+ a contiguous sub-run of t member genes (t = target size) of such an element -- itself a contiguous run of
610
+ panRGP genes of one spot, as a U element is a sub-run of its own spot's RGP. An element hosts several
611
+ non-overlapping sub-runs only when every element has been used (fewest uses first); among those,
612
+ farthest-point choice spreads the panel along the chromosome."""
613
+ a, b = self.OFF[g], self.OFF[g + 1]
614
+ rgp = self.RGP[a:b]; sp = self.SPOT[a:b]; fam = self.FAM[a:b]
615
+ cand = []
616
+ i, n = 0, b - a
617
+ while i < n:
618
+ if rgp[i] and sp[i] >= 0:
619
+ j = i
620
+ while j + 1 < n and rgp[j + 1] and sp[j + 1] == sp[i]: j += 1
621
+ if int(sp[i]) not in self.COUPLED_SPOTS:
622
+ pos = [p for p in range(i, j + 1) if not self.BB[fam[p]]]
623
+ if pos and not (set(range(i, j + 1)) & avoid):
624
+ cand.append(dict(start=i, end=j, pos=pos, spot=int(sp[i]), free=[True] * len(pos), uses=0))
625
+ i = j + 1
626
+ else: i += 1
627
+ chosen = []
628
+ order = sorted(range(len(target_sizes)), key=lambda k: -target_sizes[k]) # large first: rarer
629
+ for k in order:
630
+ t = int(target_sizes[k]); opts = []
631
+ for ci, c in enumerate(cand): # free windows of t member genes
632
+ w = [s0 for s0 in range(len(c["pos"]) - t + 1) if all(c["free"][s0:s0 + t])]
633
+ if w: opts.append((ci, w))
634
+ if not opts: continue
635
+ mu = min(cand[ci]["uses"] for ci, _ in opts); opts = [(ci, w) for ci, w in opts if cand[ci]["uses"] == mu]
636
+ if chosen:
637
+ mids = np.array([(e["start"] + e["end"]) / 2 for e in chosen])
638
+ dmin = [np.abs(mids - (cand[ci]["start"] + cand[ci]["end"]) / 2).min() for ci, _ in opts]
639
+ best = max(dmin); opts = [o for o, d in zip(opts, dmin) if d >= best - 1e-9]
640
+ ci, w = opts[int(rng.integers(len(opts)))]
641
+ s0 = w[int(rng.integers(len(w)))]; c = cand[ci]
642
+ for q in range(s0, s0 + t): c["free"][q] = False
643
+ c["uses"] += 1
644
+ dels = c["pos"][s0:s0 + t]
645
+ chosen.append(dict(start=dels[0], end=dels[-1], dels=dels, size=t, spot=c["spot"],
646
+ elem_start=c["start"], elem_end=c["end"], elem_size=len(c["pos"])))
647
+ return sorted(chosen, key=lambda e: e["start"]), len(cand)
648
+
649
+
650
+ # ============================================================================ decoder P(family | cluster)
651
+ class Decoder:
652
+ """A[f, c] = sum over genes of family f (not the first gene of a genome) of P(c | context), top 64 of the
653
+ fp32 full-chromosome baseline calls, one per lineage half; P(f | c) = A[f, c] / sum_f A[f, c].
654
+ Genome g (half h) is read with the decoder of half 1 - h, built from genomes of the other lineages only.
655
+ Two sources: the fp32 baseline calls base_top64_{f,p}.npy (~0.9 GB; every method), or, when they are absent (or
656
+ compact=True), the export of export_decoder() (DECODER_NPZ, or `path`): the sparse rows srow() of every family of
657
+ the genomes each half reads, bit for bit (float64; a float32 export moves the logit of a gene predicted at
658
+ P > 0.99 by up to 2e-4, the deltas by < 1e-6). row() is then rebuilt from srow(); TF, TP and colsum do not exist,
659
+ and a family outside those genomes raises KeyError."""
660
+
661
+ def __init__(self, data, compact=None, path=None):
662
+ self.d = data
663
+ NP = data.NP
664
+ ff, fp = f"{KDIR}/base_top64_f.npy", f"{KDIR}/base_top64_p.npy"
665
+ if compact is None: compact = not (os.path.exists(ff) and os.path.exists(fp))
666
+ self.compact = bool(compact)
667
+ half_gene = data.HALF[data.GID]
668
+ self.mask = {h: (half_gene == h) & ~data.first for h in (0, 1)}
669
+ self.lineages = {h: set(data.LIN[data.HALF == h].tolist()) for h in (0, 1)}
670
+ self._rows = {}
671
+ if self.compact:
672
+ self._load_compact(path or DECODER_NPZ); return
673
+ self.TF = np.load(ff, mmap_mode="r")
674
+ self.TP = np.load(fp, mmap_mode="r")
675
+ assert self.TF.shape == (NP, TOPK)
676
+ self.colsum = {}
677
+ for h in (0, 1):
678
+ m = np.flatnonzero(self.mask[h]); cs = np.zeros(NCL)
679
+ for s in range(0, len(m), 200000):
680
+ mm = m[s:s + 200000]
681
+ cs += np.bincount(np.asarray(self.TF[mm]).ravel(), weights=np.asarray(self.TP[mm], np.float64).ravel(), minlength=NCL)
682
+ self.colsum[h] = cs
683
+ # genes of each family, per half (sorted global indices)
684
+ order = np.argsort(data.FAM, kind="stable"); fs = data.FAM[order]
685
+ self._ord = order; self._fs = fs
686
+
687
+ def _load_compact(self, path):
688
+ """the arrays of export_decoder(), checked against this Data (same genes, same lineage halves)"""
689
+ if not os.path.exists(path):
690
+ raise FileNotFoundError(f"no decoder: neither pgb/knock/base_top64_{{f,p}}.npy nor {os.path.relpath(path, PROJ)}")
691
+ with np.load(path) as Z:
692
+ meta = json.loads(str(Z["meta"]))
693
+ assert meta["np"] == self.d.NP and meta["ncl"] == NCL, f"{path}: built for other data ({meta})"
694
+ assert np.array_equal(Z["half"], self.d.HALF), f"{path}: its lineage halves are not this Data's"
695
+ self.sp = {h: tuple(np.asarray(Z[f"{k}{h}"]) for k in ("fam", "ptr", "col", "val", "cover")) for h in (0, 1)}
696
+ self.meta = meta
697
+
698
+ def half_for(self, g):
699
+ """the decoder that reads genome g: the other half. Self-check (d): it never saw g's lineage."""
700
+ h = 1 - int(self.d.HALF[g])
701
+ assert self.d.LIN[g] not in self.lineages[h], f"genome {g}: decoder {h} saw its lineage {self.d.LIN[g]}"
702
+ assert not self.mask[h][self.d.OFF[g]:self.d.OFF[g + 1]].any(), f"genome {g}: its own genes are in decoder {h}"
703
+ return h
704
+
705
+ def row(self, f, h):
706
+ """P(f | c) for every cluster c (float64, length NCL), decoder half h"""
707
+ k = (int(f), h)
708
+ if k not in self._rows and self.compact:
709
+ c, w = self.srow(f, h); a = np.zeros(NCL); a[c] = w; self._rows[k] = a
710
+ if k not in self._rows:
711
+ lo, hi = np.searchsorted(self._fs, [f, f + 1]); idx = np.sort(self._ord[lo:hi])
712
+ idx = idx[self.mask[h][idx]]
713
+ a = np.zeros(NCL)
714
+ if len(idx):
715
+ a = np.bincount(np.asarray(self.TF[idx]).ravel(), weights=np.asarray(self.TP[idx], np.float64).ravel(), minlength=NCL)
716
+ with np.errstate(invalid="ignore", divide="ignore"):
717
+ w = np.where(self.colsum[h] > 0, a / np.maximum(self.colsum[h], 1e-300), 0.0)
718
+ self._rows[k] = np.clip(w, 0.0, 1.0)
719
+ return self._rows[k]
720
+
721
+ def srow(self, f, h):
722
+ """row(f, h) as (clusters, values): the same float64 values, only the non-zero ones (median ~380 of 50,001
723
+ clusters per family, at most ~12,700), so that the true family of EVERY gene of a genome can be decoded
724
+ (~4,500 families per genome: dense rows would take 1.8 GB). Cached (~5 kB per family and half); the dense
725
+ row() cache is not touched. Compact decoder: the exported row."""
726
+ if self.compact:
727
+ fam, ptr, col, val, cover = self.sp[h]; f = int(f)
728
+ i = int(np.searchsorted(fam, f))
729
+ if i < len(fam) and fam[i] == f:
730
+ return col[ptr[i]:ptr[i + 1]].astype(np.int64), val[ptr[i]:ptr[i + 1]].astype(np.float64)
731
+ j = int(np.searchsorted(cover, f))
732
+ if j < len(cover) and cover[j] == f: return np.zeros(0, np.int64), np.zeros(0)
733
+ raise KeyError(f"family {f}: not in the compact decoder's half {h} (only the families of the genomes it reads)")
734
+ if not hasattr(self, "_srows"): self._srows = {}
735
+ k = (int(f), h)
736
+ if k not in self._srows:
737
+ lo, hi = np.searchsorted(self._fs, [f, f + 1]); idx = np.sort(self._ord[lo:hi])
738
+ idx = idx[self.mask[h][idx]]
739
+ if len(idx):
740
+ a = np.bincount(np.asarray(self.TF[idx]).ravel(), weights=np.asarray(self.TP[idx], np.float64).ravel(), minlength=NCL)
741
+ c = np.flatnonzero(a > 0)
742
+ with np.errstate(invalid="ignore", divide="ignore"):
743
+ w = np.where(self.colsum[h][c] > 0, a[c] / np.maximum(self.colsum[h][c], 1e-300), 0.0)
744
+ w = np.clip(w, 0.0, 1.0); keep = w > 0
745
+ self._srows[k] = (c[keep].astype(np.int64), w[keep])
746
+ else:
747
+ self._srows[k] = (np.zeros(0, np.int64), np.zeros(0))
748
+ return self._srows[k]
749
+
750
+ def sparse_table(self, fams, h):
751
+ """CSR of the decoder rows of `fams` (half h): cols, vals, ptr (len(fams)+1), nnz"""
752
+ rs = [self.srow(f, h) for f in fams]
753
+ nnz = np.array([len(c) for c, _ in rs], np.int64)
754
+ ptr = np.concatenate([[0], np.cumsum(nnz)])
755
+ cols = np.concatenate([c for c, _ in rs]) if len(rs) else np.zeros(0, np.int64)
756
+ vals = np.concatenate([v for _, v in rs]) if len(rs) else np.zeros(0)
757
+ return cols, vals, ptr, nnz
758
+
759
+
760
+ def export_decoder(path=None, dtype=np.float64, data=None, compress=True):
761
+ """The compact decoder for influence() where base_top64_{f,p}.npy (~0.9 GB) cannot go (the Space): for each half h,
762
+ the sparse rows srow(f, h) of every family f of the genomes read with h (half_for: the genomes of half 1 - h), the
763
+ only rows influence() reads. Per half: cover{h} (those families, int32), fam{h} (the ones with a non-empty row),
764
+ ptr{h} (int64 CSR offsets), col{h} (cluster ids, uint16: NCL = 50,001 < 65,536), val{h} (P(f | c), `dtype`:
765
+ float64 keeps srow() bit for bit, ~75 MB; float32 ~45 MB); `half` (the lineage half of each genome, checked on
766
+ load) and `meta`. ~90 s. Returns (path, bytes)."""
767
+ assert NCL <= 65536
768
+ path = path or DECODER_NPZ
769
+ D = data if data is not None else Data(); dec = Decoder(D, compact=False)
770
+ out = dict(half=D.HALF.astype(np.int8)); info = {}
771
+ for h in (0, 1):
772
+ gs = np.flatnonzero(D.HALF == 1 - h)
773
+ cover = np.unique(np.concatenate([D.FAM[D.OFF[g]:D.OFF[g + 1]] for g in gs]))
774
+ fams, ptr, cols, vals = [], [0], [], []
775
+ for f in cover.tolist():
776
+ c, w = dec.srow(f, h)
777
+ if not len(c): continue
778
+ fams.append(f); cols.append(c.astype(np.uint16)); vals.append(w.astype(dtype)); ptr.append(ptr[-1] + len(c))
779
+ out[f"cover{h}"] = cover.astype(np.int32); out[f"fam{h}"] = np.array(fams, np.int32)
780
+ out[f"ptr{h}"] = np.array(ptr, np.int64); out[f"col{h}"] = np.concatenate(cols); out[f"val{h}"] = np.concatenate(vals)
781
+ info[h] = dict(genomes_read=int(len(gs)), families=int(len(cover)), rows=len(fams), nnz=int(ptr[-1]))
782
+ dec._srows = {}
783
+ out["meta"] = np.array(json.dumps(dict(np=int(D.NP), ncl=NCL, topk=TOPK, dtype=np.dtype(dtype).name, halves=info,
784
+ sets_md5=D.SETS_MD5, built=time.strftime("%Y-%m-%d %H:%M"),
785
+ source="pgb/knock/base_top64_{f,p}.npy (fp32 baseline calls, top 64)",
786
+ source_files={os.path.basename(f): dict(size=os.path.getsize(f), mtime=os.path.getmtime(f))
787
+ for f in (f"{KDIR}/base_top64_f.npy", f"{KDIR}/base_top64_p.npy")})))
788
+ tmp = path + ".tmp.npz"
789
+ (np.savez_compressed if compress else np.savez)(tmp, **out)
790
+ os.replace(tmp, path)
791
+ log(f"decoder -> {path}: {os.path.getsize(path) / 1e6:.1f} MB, {info}")
792
+ return path, os.path.getsize(path)
793
+
794
+
795
+ # ============================================================================ the model
796
+ class Runner:
797
+ """Bacformer causal, fp32, one chromosome per pass (batch 1), padded to a fixed length per genome.
798
+ Readouts go through the decoder in float64 on the GPU:
799
+ S[r, f] = sum_c p[r, c] W[f, c] = P(f | ctx)
800
+ C[r, f] = sum_c p[r, c] (1 - W[f, c]) = 1 - P(f | ctx), computed directly (no cancellation)"""
801
+
802
+ def __init__(self, dev="cuda:0", frac=MEM_FRAC, threads=None):
803
+ """frac: per-process GPU memory cap (0.35 so that two runs share the 8 GB card; None leaves the process's
804
+ cap alone, e.g. inside serve_live). dev='cpu' runs on the CPU with `threads` threads."""
805
+ import torch
806
+ from transformers import AutoModelForCausalLM
807
+ self.torch = torch
808
+ if str(dev).startswith("cuda") and frac is not None:
809
+ torch.cuda.set_per_process_memory_fraction(frac, torch.device(dev).index or 0)
810
+ if not str(dev).startswith("cuda") and threads: torch.set_num_threads(int(threads))
811
+ torch.backends.cuda.matmul.allow_tf32 = False; torch.backends.cudnn.allow_tf32 = False
812
+ self.dev = dev
813
+ self.cm = AutoModelForCausalLM.from_pretrained(MODEL, trust_remote_code=True).float().eval().to(dev)
814
+ self.E = None
815
+
816
+ def sync(self):
817
+ if str(self.dev).startswith("cuda"): self.torch.cuda.synchronize()
818
+
819
+ def set_genome(self, emb_rows, L):
820
+ """emb_rows: (n, 480) float array of the genome's proteins; L: padded sequence length for all passes"""
821
+ t = self.torch
822
+ self.E = t.tensor(np.asarray(emb_rows, np.float32), device=self.dev)
823
+ self.L = int(L)
824
+ self.stm = t.full((1, self.L), PROT, device=self.dev, dtype=t.long); self.stm[0, 0] = CLS
825
+ self.tt = t.zeros((1, self.L), device=self.dev, dtype=t.long)
826
+
827
+ def set_families(self, W):
828
+ """W: (F, NCL) float64 decoder rows used for this genome (fixed for all its passes)"""
829
+ t = self.torch
830
+ self.W = t.tensor(W, device=self.dev, dtype=t.float64)
831
+ self.Wc = 1.0 - self.W
832
+
833
+ def hidden(self, src, extra=None):
834
+ """src: int array, new position -> row of self.E (>= 0) or -(1 + k) for extra[k] (inserted proteins)"""
835
+ t = self.torch
836
+ m = len(src); assert m + 1 <= self.L
837
+ x = t.zeros((1, self.L, 480), device=self.dev)
838
+ s = t.as_tensor(np.asarray(src), device=self.dev)
839
+ if extra is None:
840
+ x[0, 1:1 + m] = self.E[s]
841
+ else:
842
+ X = t.cat([self.E, t.tensor(np.asarray(extra, np.float32), device=self.dev)])
843
+ s = t.where(s >= 0, s, self.E.shape[0] + (-s - 1))
844
+ x[0, 1:1 + m] = X[s]
845
+ with t.no_grad():
846
+ return self.cm.bacformer(protein_embeddings=x, special_tokens_mask=self.stm, token_type_ids=self.tt,
847
+ attention_mask=None, return_attn_weights=False, return_dict=True,
848
+ is_causal=True).last_hidden_state[0]
849
+
850
+ def decode(self, h, rows):
851
+ """rows (new positions) -> (S, C) float64 numpy arrays (len(rows), F); fixed-size head chunks"""
852
+ t = self.torch
853
+ S, C = [], []
854
+ rows = np.asarray(rows, np.int64)
855
+ with t.no_grad():
856
+ for s in range(0, len(rows), HEAD_CHUNK):
857
+ r = rows[s:s + HEAD_CHUNK]; k = len(r)
858
+ rr = np.zeros(HEAD_CHUNK, np.int64); rr[:k] = r
859
+ p = t.softmax(self.cm.gm_head(h[t.as_tensor(rr, device=self.dev)]), -1).double()
860
+ S.append((p @ self.W.T)[:k].cpu().numpy()); C.append((p @ self.Wc.T)[:k].cpu().numpy())
861
+ if not S:
862
+ F = self.W.shape[0]; return np.zeros((0, F)), np.zeros((0, F))
863
+ return np.concatenate(S), np.concatenate(C)
864
+
865
+ def set_sparse(self, cols, vals, ptr, nnz):
866
+ """the decoder rows of every family of the genome (Decoder.sparse_table), for decode_true. One fixed width per
867
+ genome (its largest row), so that every head chunk has the same shape in every pass."""
868
+ t = self.torch
869
+ self.sp_cols = t.as_tensor(np.concatenate([cols, [0]]), dtype=t.long, device=self.dev) # + a zero slot
870
+ self.sp_vals = t.as_tensor(np.concatenate([vals, [0.0]]), dtype=t.float64, device=self.dev)
871
+ self.sp_ptr = t.as_tensor(np.asarray(ptr[:-1], np.int64), device=self.dev)
872
+ self.sp_nnz = t.as_tensor(np.asarray(nnz, np.int64), device=self.dev)
873
+ self.sp_zero = len(cols)
874
+ w = max(1, int(np.max(nnz)) if len(nnz) else 1)
875
+ self.sp_ar = t.arange(-(-w // 64) * 64, device=self.dev)[None, :] # a multiple of 64: aligned rows
876
+
877
+ def decode_true(self, h, rows, fidx):
878
+ """rows (new positions), fidx (index into the set_sparse table: each row's own family) -> (S, C) float64 numpy
879
+ arrays (len(rows),): S = P(f | ctx) = sum_c p[r, c] W[f, c] over the full softmax (the same float64 sum as
880
+ decode, over the non-zero W only; agrees with decode to ~2e-7 in logit, the fp32 rounding of the softmax),
881
+ C = sum_c p[r, c] - S = sum_c p[r, c] (1 - W[f, c]). Fixed-size head chunks (TRUE_CHUNK rows)
882
+ and a fixed gather width: a row's value does not depend on the other rows of its chunk nor on its slot in it
883
+ (asserted per genome in check (g) by reading every row of the baseline again in other slots). CUDA's row
884
+ reductions (softmax, sum) take another summation path when a row does not start on an aligned address, and a
885
+ 50,001-wide row starts misaligned in half the slots, so the softmax runs on rows padded to 50,048 with -inf
886
+ (probability exactly 0) and the gather width is a multiple of 64: every row starts 256-byte aligned."""
887
+ t = self.torch
888
+ rows = np.asarray(rows, np.int64); fidx = np.asarray(fidx, np.int64)
889
+ S, C = [], []
890
+ NPAD = -(-NCL // 64) * 64; CH = TRUE_CHUNK
891
+ with t.no_grad():
892
+ zp = t.full((CH, NPAD), float("-inf"), device=self.dev)
893
+ for s in range(0, len(rows), CH):
894
+ r = rows[s:s + CH]; k = len(r)
895
+ rr = np.zeros(CH, np.int64); rr[:k] = r
896
+ ff = np.zeros(CH, np.int64); ff[:k] = fidx[s:s + CH]
897
+ zp[:, :NCL] = self.cm.gm_head(h[t.as_tensor(rr, device=self.dev)])
898
+ p = t.softmax(zp, -1).double()
899
+ ft = t.as_tensor(ff, device=self.dev)
900
+ ok = self.sp_ar < self.sp_nnz[ft][:, None]
901
+ ix = t.where(ok, self.sp_ptr[ft][:, None] + self.sp_ar, self.sp_zero)
902
+ sv = (p.gather(1, self.sp_cols[ix]) * self.sp_vals[ix]).sum(1)
903
+ S.append(sv[:k].cpu().numpy()); C.append((p.sum(1) - sv)[:k].cpu().numpy())
904
+ if not S: return np.zeros(0), np.zeros(0)
905
+ return np.concatenate(S), np.concatenate(C)
906
+
907
+ def full_calls(self, h, n, chunk=512):
908
+ """top-64 clusters, probabilities and entropy (bits) at rows 0..n-1 (row k = the call for gene k)"""
909
+ t = self.torch
910
+ TF, TPp, EN = [], [], []
911
+ with t.no_grad():
912
+ for s in range(0, n, chunk):
913
+ r = t.arange(s, min(n, s + chunk), device=self.dev)
914
+ p = t.softmax(self.cm.gm_head(h[r]), -1)
915
+ tp, tf = p.topk(TOPK, 1)
916
+ TF.append(tf.int().cpu().numpy()); TPp.append(tp.cpu().numpy())
917
+ EN.append((-(p * t.log2(p.clamp(min=1e-30))).sum(1)).cpu().numpy())
918
+ return np.concatenate(TF), np.concatenate(TPp), np.concatenate(EN)
919
+
920
+
921
+ # ============================================================================ readouts
922
+ def readout_values(kind, S, C, i1, i2):
923
+ """primary value per readout, from decoded S = P(f|ctx), C = 1 - P(f|ctx) (arrays over readouts)
924
+ co: logit P(f1) = log P(f1) - log(1 - P(f1))
925
+ av: log P(f1) - log P(f2) (f1 = D's entry family, f2 = the resident gene's family)
926
+ each probability floored at FLOOR"""
927
+ lp1 = np.log(np.maximum(S[np.arange(len(i1)), i1], FLOOR))
928
+ lc1 = np.log(np.maximum(C[np.arange(len(i1)), i1], FLOOR))
929
+ lp2 = np.log(np.maximum(S[np.arange(len(i2)), np.maximum(i2, 0)], FLOOR))
930
+ v = np.where(kind == 1, lp1 - lc1, lp1 - lp2)
931
+ return v, lp1, np.where(kind == 1, lc1, lp2)
932
+
933
+
934
+ # ============================================================================ influence readouts
935
+ def logit_true(S, C):
936
+ """logit P(true family) from decode_true, each probability floored at FLOOR (as readout_values' co kind)"""
937
+ return np.log(np.maximum(S, FLOOR)) - np.log(np.maximum(C, FLOOR))
938
+
939
+
940
+ def inf_bins(dd, dl, ok, rgp, win=INF_WIN, width=INF_BIN):
941
+ """dd: distance of each read gene from the deleted element's last gene (1 = the next gene), dl: its delta, ok: usable
942
+ (the decoder saw its family), rgp: panRGP gene. -> per bin of `width` genes out to `win`: mean delta, mean |delta|,
943
+ mean delta over panRGP genes, genes (all, panRGP). NaN where a bin is empty."""
944
+ nb = win // width
945
+ m = ok & (dd >= 1) & (dd <= win)
946
+ b = (dd[m] - 1) // width; x = dl[m]; rg = rgp[m]
947
+ n = np.bincount(b, minlength=nb); nr = np.bincount(b[rg], minlength=nb)
948
+ with np.errstate(invalid="ignore", divide="ignore"):
949
+ mean = np.bincount(b, x, nb) / n
950
+ mabs = np.bincount(b, np.abs(x), nb) / n
951
+ mr = np.bincount(b[rg], x[rg], nb) / nr
952
+ return mean, mabs, mr, n, nr
953
+
954
+
955
+ def influence_readout(R, h, keep, n, dels, pend, base_lg, tfi, seen, rgp, entry, lim=None,
956
+ up=INF_UP, win=INF_WIN, ent_max=INF_ENT):
957
+ """The influence readouts of one deletion pass, from its hidden states h.
958
+ keep original genes in the pass, in order (new position = index in keep); n genes in the chromosome
959
+ dels the deleted genes; pend = the deleted element's last gene: distances count from it, as a unit's
960
+ distance counts from U's end
961
+ base_lg baseline logit P(true family) per original gene; tfi each gene's row in the sparse decoder table;
962
+ seen: the decoder saw its family; rgp: panRGP gene; entry: entry genes of Data.elements(g)
963
+ lim genes >= lim are not in the pass (a truncated sequence, influence()); default n
964
+ Reads every gene from `up` genes before the first deleted gene to `win` genes after pend, and the entries of
965
+ elements out to `ent_max` genes. Check (g), asserted here: every gene before the first deleted gene is
966
+ bit-identical to the baseline. Returns the bins (inf_bins), the entries (index into the element table, delta)
967
+ and the check counters."""
968
+ lim = n if lim is None else lim
969
+ orig_pos = np.full(n, -1, np.int64); orig_pos[keep] = np.arange(len(keep))
970
+ d0 = int(np.min(dels)); pend = int(pend)
971
+ lo = max(0, d0 - up); hi = min(lim - 1, pend + win)
972
+ rows = np.arange(lo, hi + 1); rows = rows[orig_pos[rows] >= 0]
973
+ ent_ok = (entry > pend) & (entry - pend <= ent_max) & (entry < lim)
974
+ ent_ok[ent_ok] &= orig_pos[entry[ent_ok]] >= 0 # an entry deleted by this pass is not read
975
+ far = np.unique(entry[ent_ok & (entry > hi)])
976
+ rows = np.concatenate([rows, far])
977
+ S, C = R.decode_true(h, orig_pos[rows], tfi[rows])
978
+ dl = logit_true(S, C) - base_lg[rows]
979
+ u = rows < d0
980
+ if u.any():
981
+ assert np.all(dl[u] == 0.0), f"check (g): a gene before the deletion changed (max |delta| {np.max(np.abs(dl[u]))})"
982
+ mean, mabs, mr, nn, nr = inf_bins(rows - pend, dl, seen[rows], rgp[rows], win)
983
+ val = np.full(n, np.nan); val[rows] = dl
984
+ ei = np.flatnonzero(ent_ok)
985
+ return dict(mean=mean, abs=mabs, rgp=mr, n=nn, n_rgp=nr, ent=ei, ent_delta=val[entry[ei]],
986
+ n_rows=len(rows), n_up=int(u.sum()), up_max=float(np.max(np.abs(dl[u]))) if u.any() else 0.0)
987
+
988
+
989
+ def load_influence(path, checks=False):
990
+ """The influence readouts of one genome file written by pg_knockout.py --influence (g*.npz) as two tables:
991
+ passes one row per deletion pass: pass index, kind (U / ctrl_cpl / ctrl_nat / check_b), deleted region id
992
+ (-1 for a natural run), spot id, start, end, size (deleted genes), and the 60-bin profiles as arrays
993
+ (mean, abs, rgp, n, n_rgp; bin k = genes 25k+1 .. 25k+25 after `end`)
994
+ entries one row per (pass, element entry read): the affected element (kind 0 = panRGP run by spot id,
995
+ 1 = coupled region by region id, its entry gene), distance = entry - pass end, delta (float16 ->
996
+ float64), the baseline logit at that entry and whether the decoder saw its family
997
+ The element-to-element matrix is `entries` with the deleted element (pass columns) joined in; the matched null of
998
+ one (pass, entry) is the control passes of the same genome with size x0.5-2 of the pass's and distance to the
999
+ same entry within +-35 % (match_controls, as for the units).
1000
+ checks=False leaves out the check_b passes (10 arbitrary genes deleted after the last readout row, possibly
1001
+ backbone genes: a self-check, not an element); checks=True keeps them."""
1002
+ import pandas as pd
1003
+ Z = np.load(path, allow_pickle=False)
1004
+ assert "inf_e_off" in Z, f"{path}: no influence readouts (run with --influence)"
1005
+ pk = Z["pass_kind"]; off = Z["inf_e_off"]
1006
+ dp = np.flatnonzero((pk != "base") & (pk != "check_a") & (checks | (pk != "check_b")))
1007
+ g = int(os.path.basename(path)[1:].split(".")[0])
1008
+ P = pd.DataFrame(dict(g=g, pass_i=dp, kind=pk[dp], del_id=Z["pass_id"][dp], del_spot=Z["pass_spot"][dp],
1009
+ start=Z["pass_start"][dp], end=Z["pass_end"][dp], size=Z["pass_size"][dp]))
1010
+ for k in ("mean", "abs", "rgp"): P[k] = list(Z[f"inf_{k}"][dp].astype(np.float64))
1011
+ for k in ("n", "n_rgp"): P[k] = list(Z[f"inf_{k}"][dp].astype(np.int64))
1012
+ cnt = np.diff(off); pi = np.repeat(np.arange(len(cnt)), cnt); el = Z["inf_e_el"].astype(np.int64)
1013
+ if not checks:
1014
+ k_ = pk[pi] != "check_b"; pi = pi[k_]; el = el[k_]; de = Z["inf_e_delta"][k_]
1015
+ else:
1016
+ de = Z["inf_e_delta"]
1017
+ ent = Z["el_entry"][el].astype(np.int64)
1018
+ Ent = pd.DataFrame(dict(g=g, pass_i=pi, kind=pk[pi], del_id=Z["pass_id"][pi], del_spot=Z["pass_spot"][pi],
1019
+ del_size=Z["pass_size"][pi], del_end=Z["pass_end"][pi],
1020
+ el=el, el_kind=Z["el_kind"][el], el_id=Z["el_id"][el], el_entry=ent,
1021
+ dist=ent - Z["pass_end"][pi], delta=de.astype(np.float64),
1022
+ base_logit=Z["inf_base"][ent].astype(np.float64), seen=Z["inf_seen"][ent]))
1023
+ return P, Ent
1024
+
1025
+
1026
+ def influence_matrix(path, kinds=("U",), win=INF_WIN, floor=1e-5):
1027
+ """Reference aggregation of one genome file: the element-to-element influence matrix of the passes of `kinds`
1028
+ against the whole-element null already used for the units. One row per (deleted element, affected element entry
1029
+ within `win` genes): delta, and the matched null = the control passes (ctrl_cpl / ctrl_nat) of the same genome that
1030
+ read the SAME entry, do not overlap the deleted element, and match it in size (x0.5-2) and distance to the entry
1031
+ (+-35 %), widened to x1/3-3 / +-60 % when fewer than 3 match (match_controls); robust z floored at `floor` (the
1032
+ fp32 floor, ~1e-5). A control pass put in `kinds` is scored against the OTHER controls (a pseudo-U)."""
1033
+ import pandas as pd
1034
+ P, E = load_influence(path)
1035
+ E = E[E.seen.values]
1036
+ ctl = E[E.kind.str.startswith("ctrl_")]
1037
+ pst = dict(zip(P.pass_i, P.start)); pen = dict(zip(P.pass_i, P.end)); psz = dict(zip(P.pass_i, P["size"]))
1038
+ by_el = {e: (d.pass_i.values, d.del_end.values, d.del_size.values, d.delta.values,
1039
+ np.array([pst[i] for i in d.pass_i.values])) for e, d in ctl.groupby("el")}
1040
+ rows = []
1041
+ for r in E[E.kind.isin(kinds) & (E.dist >= 1) & (E.dist <= win)].itertuples(index=False):
1042
+ n_c, z = 0, np.nan; med = mad = np.nan
1043
+ if r.el in by_el:
1044
+ ci, ce, cs, cd, cst = by_el[r.el]
1045
+ ok = (ci != r.pass_i) & ~((cst <= pen[r.pass_i]) & (ce >= pst[r.pass_i]))
1046
+ idx, _w = match_controls(ce, cs, ok, psz[r.pass_i], r.dist, r.el_entry)
1047
+ n_c = len(idx)
1048
+ if n_c >= 3: z, med, mad = robust_z(r.delta, cd[idx], floor)
1049
+ rows.append(dict(g=r.g, pass_i=r.pass_i, kind=r.kind, del_id=r.del_id, del_spot=r.del_spot, del_size=r.del_size,
1050
+ el_kind=r.el_kind, el_id=r.el_id, el_entry=r.el_entry, dist=r.dist, delta=r.delta,
1051
+ n_ctrl=n_c, ctrl_med=med, ctrl_mad=mad, z=z))
1052
+ return pd.DataFrame(rows)
1053
+
1054
+
1055
+ # ---------------------------------------------------------------------------- on demand, for serve_live
1056
+ _CTX = {}
1057
+
1058
+
1059
+ def context(dev=None, threads=2, frac=MEM_FRAC, sets=None, data=None, compact=None):
1060
+ """(Data, Decoder, Runner) for influence(), built once per process. dev None: cuda:0 when available, else the CPU
1061
+ with `threads` threads. Loads an fp32 copy of the model (the assay's precision; serve_live's copy is bf16). `frac`
1062
+ caps the GPU memory of the WHOLE calling process (torch.cuda.set_per_process_memory_fraction): serve_influence.py
1063
+ sets it (a process of its own), serve_live.py passes None (its process is not capped here). `data`: a Data already
1064
+ loaded (serve_live's /elements), else Data(sets=sets). `compact`: Decoder's (None: the compact decoder only when
1065
+ base_top64 is absent)."""
1066
+ if "ctx" not in _CTX:
1067
+ import torch
1068
+ dev = dev or ("cuda:0" if torch.cuda.is_available() else "cpu")
1069
+ D = data if data is not None else Data(sets=sets); dec = Decoder(D, compact=compact)
1070
+ R = Runner(dev=dev, frac=frac, threads=threads)
1071
+ _CTX["ctx"] = (D, dec, R); _CTX["base"] = collections.OrderedDict()
1072
+ return _CTX["ctx"]
1073
+
1074
+
1075
+ def resolve_element(D, g, element):
1076
+ """element: a region id (int) or ('region', ri): the coupled element of region ri in genome g (must be present at
1077
+ its spot); ('spot', s): the panRGP run at spot s (the one with most non-backbone genes if the spot holds several);
1078
+ ('genes', [positions]): those genes. The deleted genes never include a backbone family."""
1079
+ if isinstance(element, (int, np.integer)): element = ("region", int(element))
1080
+ kind, x = element[0], element[1]
1081
+ a = int(D.OFF[g]); fam = D.FAM[a:int(D.OFF[g + 1])]
1082
+ if kind == "region":
1083
+ ri = int(x)
1084
+ assert D.HAS[ri, g] and D.PRES[ri, g], f"region {ri} is not present at its spot in genome {g}"
1085
+ e = D.EL[(ri, g)]; assert e["end"] >= e["start"], f"region {ri} wraps the origin in genome {g}"
1086
+ dels = D.members(ri, g)
1087
+ return dict(kind=1, id=ri, start=int(e["start"]), end=int(e["end"]), dels=dels, size=len(dels),
1088
+ label=D.REG[ri]["label"], spot=D.REG[ri]["spot"] if D.REG[ri]["spot"] is not None else -1)
1089
+ if kind == "spot":
1090
+ E = D.elements(g); m = np.flatnonzero((E["kind"] == 0) & (E["id"] == int(x)) & (E["size"] > 0))
1091
+ assert len(m), f"no panRGP run at spot {x} in genome {g}"
1092
+ k = m[np.argmax(E["size"][m])]; s, t = int(E["start"][k]), int(E["end"][k])
1093
+ dels = [p for p in range(s, t + 1) if not D.BB[fam[p]]]
1094
+ return dict(kind=0, id=int(x), start=s, end=t, dels=dels, size=len(dels), label=f"spot {int(x)}", spot=int(x))
1095
+ if kind == "genes":
1096
+ dels = sorted(int(p) for p in x if not D.BB[fam[int(p)]])
1097
+ assert dels, "('genes', ...): every gene given is of a backbone family (never deleted)"
1098
+ return dict(kind=-1, id=-1, start=dels[0], end=dels[-1], dels=dels, size=len(dels), label="genes", spot=-1)
1099
+ raise ValueError(f"element {element!r}")
1100
+
1101
+
1102
+ def influence_controls(D, g, el, n_ctrl=8):
1103
+ """n_ctrl whole accessory elements of genome g (control_pool: the null of the knockouts) not overlapping the
1104
+ element, size x0.5-2 of its (widened to x1/3-3, then any size, when fewer match), nearest to it first, so that
1105
+ their distances to the element's neighbours are close to the element's own"""
1106
+ pool = D.control_pool(g, sizes=[el["size"]])
1107
+ ok = [c for c in pool if not (c["start"] <= el["end"] and c["end"] >= el["start"])]
1108
+ for lo_, hi_ in ((MATCH[0], MATCH[1]), (WIDEN[0], WIDEN[1]), (0.0, np.inf)):
1109
+ cand = [c for c in ok if lo_ * el["size"] <= c["size"] <= hi_ * el["size"]]
1110
+ if len(cand) >= n_ctrl: break
1111
+ cand.sort(key=lambda c: (abs(c["end"] - el["end"]), c["start"]))
1112
+ return cand[:n_ctrl], (lo_, hi_)
1113
+
1114
+
1115
+ def _nan2none(x):
1116
+ if isinstance(x, dict): return {k: _nan2none(v) for k, v in x.items()}
1117
+ if isinstance(x, (list, tuple)): return [_nan2none(v) for v in x]
1118
+ if isinstance(x, np.ndarray): return _nan2none(x.tolist())
1119
+ if isinstance(x, (float, np.floating)): return None if not np.isfinite(x) else float(x)
1120
+ if isinstance(x, np.integer): return int(x)
1121
+ return x
1122
+
1123
+
1124
+ def _spec_scores(r, entry, end, win=INF_WIN, floor=1e-5):
1125
+ """Specificity of one pass at each element entry it read (1..win genes after the deleted element's end `end`):
1126
+ the entry's delta against the SAME pass's deltas at the other entries at a comparable distance (+-35 %, widened to
1127
+ +-60 % when < 3; entries sharing the gene excluded) -- the off-target logic of the verdicts. A deletion that moves
1128
+ everything downstream moves every entry alike and scores ~0 everywhere. Returns {element index: (dist, excess,
1129
+ z, n_ref)} (excess = delta - median of the others; z = excess / (1.4826 MAD), floored)."""
1130
+ ei = np.asarray(r["ent"], np.int64); dv = np.asarray(r["ent_delta"], float)
1131
+ gene = entry[ei]; d = gene - end
1132
+ m = np.isfinite(dv) & (d >= 1) & (d <= win)
1133
+ ug, first = np.unique(gene[m], return_index=True) # one value per entry gene
1134
+ gi = ei[m][first]; gd = d[m][first]; gv = dv[m][first]
1135
+ out = {}
1136
+ for k in range(len(ug)):
1137
+ oth = np.array([], float)
1138
+ for tol in (OFF_TOL, WIDEN[2]):
1139
+ sel = (np.arange(len(ug)) != k) & (np.abs(gd - gd[k]) <= tol * gd[k])
1140
+ oth = gv[sel]
1141
+ if len(oth) >= 3: break
1142
+ if len(oth) < 3: continue
1143
+ med = float(np.median(oth)); mad = 1.4826 * float(np.median(np.abs(oth - med)))
1144
+ out_v = (int(gd[k]), float(gv[k] - med), float((gv[k] - med) / max(mad, floor)), int(len(oth)))
1145
+ for i in ei[m][gene[m] == ug[k]]: out[int(i)] = out_v
1146
+ return out
1147
+
1148
+
1149
+ def influence(g, element, n_ctrl=8, truncate=None, ctx=None, z_floor=1e-5, progress=None):
1150
+ """The radius of influence of one accessory element of genome g, as the model sees it: baseline, deletion of the
1151
+ element (its member genes, never a backbone family), and n_ctrl size-matched whole-element control deletions of
1152
+ the same genome (influence_controls), one fp32 pass each at one padded length, read as in --all
1153
+ (influence_readout; check (g) asserted in every pass).
1154
+ truncate run the passes only as far as the last gene read (the element's or a control's end + 2,400 genes);
1155
+ exact for a causal model up to the fp32 floor (~1e-5, pilot check f). Default: on the CPU only.
1156
+ Returns a JSON-ready dict:
1157
+ element, controls what was deleted
1158
+ bins [lo, hi] genes after the element's last gene, 25 per bin, out to 1,500
1159
+ profile the element's mean delta, mean |delta|, mean delta over panRGP genes, genes per bin
1160
+ ctrl_mean, ctrl_abs the same per control (n_ctrl x bins)
1161
+ z_abs, z_mean robust z of the element's bin against its controls' same bin (median, 1.4826 MAD)
1162
+ radius upper edge (genes) of the last bin with z_abs >= 3; radius_contig: of the first run of
1163
+ such bins from the element on; n_bins_z3: how many of the 60 bins (with 8 controls and a
1164
+ robust z, a few bins reach 3 by chance: read the profile against ctrl_abs, not the radius)
1165
+ ctrl_abs_median the controls' median |delta| profile (the reference curve: every deletion moves the
1166
+ model's expectations downstream, decaying with distance)
1167
+ ctrl_abs_q per bin, min / median / max of the controls' mean |delta|: the band to draw the profile in
1168
+ null the same summaries with each control in turn as the element, against the other controls
1169
+ (n_bins_z3, radius, radius_contig, broad): what 8 controls give by chance. radius_p /
1170
+ n_bins_z3_p = share of these pseudo-elements with at least the element's value (+1 smoothed)
1171
+ broad median over bins of the element's mean |delta| / the controls' median: how much more (or
1172
+ less) than a typical deletion this one moves the model's expectations downstream
1173
+ neighbours every element (Data.elements) whose entry lies 1-1,500 genes downstream: its delta
1174
+ (logit P(its entry family)), and
1175
+ spec_excess, z_spec SPECIFICITY: the neighbour's delta minus the median delta of the
1176
+ element's own deletion at the other entries at a comparable distance
1177
+ (+-35 %, widened to +-60 %; _spec_scores): does the deletion move THIS
1178
+ neighbour more than it moves everything else at that distance?
1179
+ p_spec, q_spec calibrated: z_spec against the same score computed for every entry of
1180
+ every control deletion at a comparable distance (the controls scored
1181
+ as if deleted elements); two-sided, +1 smoothed; q = BH over the
1182
+ neighbours' distinct entry genes (an element and the panRGP run of
1183
+ its spot often share one: one test, listed twice)
1184
+ z_same, z_dist NOT specificity: the neighbour's delta against the controls' deltas at
1185
+ the same entry (z_same) or at any entry at that distance (z_dist). An
1186
+ element whose deletion moves everything downstream more than a typical
1187
+ deletion flags every neighbour with z_same (yidK/yidJ, genome 1: 35 of
1188
+ 39 neighbours |z_same| > 3, the linked one included, while z_dist = -0.3)
1189
+ and whether it is linked to the element in the sets file. Floors: 1e-5 (the fp32 floor).
1190
+ Close to the element (< 100 genes) entries are few, so many scores are missing there.
1191
+ seconds per pass and in total
1192
+ progress: called as progress(done, total, what) before each pass (what: the pass about to run) and at the end,
1193
+ for a server's job status (serve_live.py); total = the passes of this call (baseline included when not cached)."""
1194
+ D, dec, R = ctx or context()
1195
+ tick = progress or (lambda *a: None)
1196
+ t0 = time.time()
1197
+ a, b = int(D.OFF[g]), int(D.OFF[g + 1]); n = b - a
1198
+ FAMg = D.FAM[a:b]; rgp = D.RGP[a:b]
1199
+ E = D.elements(g); entry = E["entry"].astype(np.int64)
1200
+ el = resolve_element(D, g, element)
1201
+ ctrls, widen = influence_controls(D, g, el, n_ctrl)
1202
+ dev_cuda = str(R.dev).startswith("cuda")
1203
+ if truncate is None: truncate = not dev_cuda
1204
+ lim = n if not truncate else min(n, max([el["end"]] + [c["end"] for c in ctrls]) + INF_ENT + 1)
1205
+ hh = dec.half_for(g)
1206
+ key = (g, lim, hh)
1207
+ if key in _CTX.get("base", {}):
1208
+ fams, tfi, seen, table, base_lg, t_base = _CTX["base"][key]
1209
+ else:
1210
+ fams = np.unique(FAMg); tfi = np.searchsorted(fams, FAMg)
1211
+ table = dec.sparse_table(fams, hh); seen = table[3][tfi] > 0
1212
+ base_lg = None
1213
+ total = 1 + len(ctrls) + (base_lg is None)
1214
+ R.set_sparse(*table)
1215
+ R.set_genome(D.EMB[D.PID[a:a + lim]], lim + 1)
1216
+ if base_lg is None:
1217
+ tick(0, total, "baseline pass (the genome as it is)")
1218
+ R.sync(); t = time.time()
1219
+ h = R.hidden(np.arange(lim)); S, C = R.decode_true(h, np.arange(lim), tfi[:lim])
1220
+ base_lg = np.full(n, np.nan); base_lg[:lim] = logit_true(S, C)
1221
+ R.sync(); t_base = time.time() - t
1222
+ _CTX.setdefault("base", collections.OrderedDict())[key] = (fams, tfi, seen, table, base_lg, t_base)
1223
+ while len(_CTX["base"]) > 4: _CTX["base"].popitem(last=False)
1224
+ res = []; secs = []
1225
+ for k_, p in enumerate([el] + ctrls):
1226
+ tick(total - 1 - len(ctrls) + k_, total, "deletion of the element" if k_ == 0 else f"control deletion {k_} of {len(ctrls)}")
1227
+ R.sync(); t = time.time()
1228
+ keep = np.setdiff1d(np.arange(lim), np.asarray(p["dels"], np.int64))
1229
+ h = R.hidden(keep)
1230
+ r = influence_readout(R, h, keep, n, p["dels"], p["end"], base_lg, tfi, seen, rgp, entry, lim=lim)
1231
+ R.sync(); secs.append(time.time() - t); res.append(r)
1232
+ nb = INF_WIN // INF_BIN
1233
+ r0 = res[0]; cr = res[1:]
1234
+ cm = np.array([r["mean"] for r in cr]).reshape(-1, nb); ca = np.array([r["abs"] for r in cr]).reshape(-1, nb)
1235
+ z_abs = np.full(nb, np.nan); z_mean = np.full(nb, np.nan)
1236
+ for k in range(nb):
1237
+ x = ca[:, k][np.isfinite(ca[:, k])]
1238
+ if np.isfinite(r0["abs"][k]) and len(x) >= 3: z_abs[k] = robust_z(r0["abs"][k], x, z_floor)[0]
1239
+ x = cm[:, k][np.isfinite(cm[:, k])]
1240
+ if np.isfinite(r0["mean"][k]) and len(x) >= 3: z_mean[k] = robust_z(r0["mean"][k], x, z_floor)[0]
1241
+ def radii(z):
1242
+ sig_ = np.flatnonzero(z >= 3); k_ = 0
1243
+ while k_ < nb and np.isfinite(z[k_]) and z[k_] >= 3: k_ += 1
1244
+ return (int((sig_[-1] + 1) * INF_BIN) if len(sig_) else 0), k_ * INF_BIN, int(len(sig_))
1245
+ radius, radius_contig, n_z3 = radii(z_abs)
1246
+ sig = np.arange(n_z3)
1247
+ # the same with each control as the element, against the others: what the summaries give by chance
1248
+ cmed = np.nanmedian(ca, 0) if len(ca) else np.full(nb, np.nan)
1249
+ with np.errstate(invalid="ignore", divide="ignore"):
1250
+ broad = float(np.nanmedian(np.asarray(r0["abs"], float) / cmed))
1251
+ null = dict(radius=[], radius_contig=[], n_bins_z3=[], broad=[])
1252
+ for c in range(len(ca)):
1253
+ oth = np.delete(np.arange(len(ca)), c); zc = np.full(nb, np.nan)
1254
+ for k_ in range(nb):
1255
+ x = ca[oth, k_][np.isfinite(ca[oth, k_])]
1256
+ if np.isfinite(ca[c, k_]) and len(x) >= 3: zc[k_] = robust_z(ca[c, k_], x, z_floor)[0]
1257
+ rr = radii(zc)
1258
+ null["radius"].append(rr[0]); null["radius_contig"].append(rr[1]); null["n_bins_z3"].append(rr[2])
1259
+ with np.errstate(invalid="ignore", divide="ignore"):
1260
+ null["broad"].append(float(np.nanmedian(ca[c] / np.nanmedian(ca[oth], 0))))
1261
+ pshare = lambda v, xs: float((1 + sum(x >= v for x in xs)) / (1 + len(xs))) if xs else np.nan
1262
+ # specificity at each neighbour, and its null: every control deletion scored the same way
1263
+ spec0 = _spec_scores(r0, entry, el["end"])
1264
+ spec_null = [v for c, r in zip(ctrls, cr) for v in _spec_scores(r, entry, c["end"]).values()]
1265
+ sn_d = np.array([v[0] for v in spec_null], float); sn_z = np.array([v[2] for v in spec_null], float)
1266
+ # the neighbours: the element's entries and each control's, by element index
1267
+ ctl_ent = [dict(zip(r["ent"].tolist(), r["ent_delta"].tolist())) for r in cr]
1268
+ linked = D.links_of(el["id"]) if el["kind"] == 1 else set()
1269
+ nbrs = []
1270
+ for i, dv in zip(r0["ent"].tolist(), r0["ent_delta"].tolist()):
1271
+ d = int(entry[i] - el["end"])
1272
+ if d > INF_WIN: continue
1273
+ same = []
1274
+ for tol in (OFF_TOL, WIDEN[2]):
1275
+ same = [ce[i] for c, ce in zip(ctrls, ctl_ent)
1276
+ if i in ce and np.isfinite(ce[i]) and abs((entry[i] - c["end"]) - d) <= tol * d]
1277
+ if len(same) >= 3: break
1278
+ pooled = [v for c, ce in zip(ctrls, ctl_ent) for j, v in ce.items()
1279
+ if np.isfinite(v) and abs((entry[j] - c["end"]) - d) <= OFF_TOL * d]
1280
+ zs = robust_z(dv, same, z_floor)[0] if len(same) >= 3 else np.nan
1281
+ zd = robust_z(dv, pooled, z_floor)[0] if len(pooled) >= 3 else np.nan
1282
+ sp_ = spec0.get(int(i)); ex_ = zsp = psp = np.nan; nref = nnull = 0
1283
+ if sp_ is not None:
1284
+ ex_, zsp, nref = sp_[1], sp_[2], sp_[3]
1285
+ for tol in (OFF_TOL, WIDEN[2], np.inf):
1286
+ mm = np.abs(sn_d - d) <= tol * d
1287
+ if mm.sum() >= 20 or tol == np.inf: break
1288
+ nnull = int(mm.sum())
1289
+ if nnull: psp = float((1 + np.sum(np.abs(sn_z[mm]) >= abs(zsp))) / (1 + nnull))
1290
+ kd, idd = int(E["kind"][i]), int(E["id"][i])
1291
+ nbrs.append(dict(kind=("region" if kd == 1 else "spot"), id=idd, label=D.element_label(kd, idd)[:60],
1292
+ entry=int(entry[i]), start=int(E["start"][i]), end=int(E["end"][i]), dist=d,
1293
+ family=D.FN[int(E["fam"][i])], seen=bool(seen[int(entry[i])]),
1294
+ base_p=float(1.0 / (1.0 + np.exp(-base_lg[int(entry[i])]))), delta=dv,
1295
+ spec_excess=ex_, z_spec=zsp, n_spec_ref=nref, p_spec=psp, n_spec_null=nnull,
1296
+ z_same=zs, n_same=len(same), z_dist=zd, n_dist=len(pooled),
1297
+ linked=bool(kd == 1 and idd in linked)))
1298
+ # BH over the distinct entry genes: an element and its spot's panRGP run often share one (the same readout, the same
1299
+ # p), and counting it twice would inflate the number of tests
1300
+ pe = {}
1301
+ for x in nbrs: pe.setdefault(x["entry"], x["p_spec"])
1302
+ qe = dict(zip(pe, bh(list(pe.values()))))
1303
+ for x in nbrs: x["q_spec"] = qe[x["entry"]]
1304
+ tick(total, total, "done")
1305
+ out = dict(g=int(g), acc=D.ACC[g], n_genes=n, decoder_half=hh, device=str(R.dev), truncated_at=int(lim),
1306
+ element={k_: el[k_] for k_ in ("kind", "id", "label", "start", "end", "size", "spot")},
1307
+ controls=[dict(arm=c["arm"], ri=int(c["ri"]), spot=int(c["spot"]), start=int(c["start"]), end=int(c["end"]),
1308
+ size=int(c["size"])) for c in ctrls], control_size_window=list(widen),
1309
+ bins=[[k_ * INF_BIN + 1, (k_ + 1) * INF_BIN] for k_ in range(nb)],
1310
+ profile=dict(mean=r0["mean"], abs=r0["abs"], rgp=r0["rgp"], n=r0["n"], n_rgp=r0["n_rgp"]),
1311
+ ctrl_mean=cm, ctrl_abs=ca, z_abs=z_abs, z_mean=z_mean, radius=radius, radius_contig=radius_contig,
1312
+ n_bins_z3=int(len(sig)), ctrl_abs_median=cmed,
1313
+ ctrl_abs_q=(np.stack([np.nanmin(ca, 0), cmed, np.nanmax(ca, 0)]) if len(ca) else np.full((3, nb), np.nan)),
1314
+ broad=broad, null=null, radius_p=pshare(radius, null["radius"]), n_bins_z3_p=pshare(int(len(sig)), null["n_bins_z3"]),
1315
+ broad_p=pshare(broad, null["broad"]),
1316
+ spec_null=dict(n=len(spec_null), abs_z_q=[float(x) for x in np.nanpercentile(np.abs(sn_z), [50, 90, 99])]
1317
+ if len(sn_z) else None),
1318
+ neighbours=nbrs,
1319
+ checks=dict(upstream_rows_exact=int(sum(r["n_up"] for r in res)), rows_read=int(sum(r["n_rows"] for r in res))),
1320
+ seconds=dict(baseline=t_base, passes=secs, total=time.time() - t0))
1321
+ return _nan2none(out)
1322
+
1323
+
1324
+ # ============================================================================ statistics
1325
+ def bh(p):
1326
+ p = np.asarray(p, float); q = np.full(len(p), np.nan); ok = np.isfinite(p)
1327
+ if ok.sum():
1328
+ pp = p[ok]; o = np.argsort(pp); r = pp[o] * ok.sum() / (np.arange(ok.sum()) + 1)
1329
+ r = np.minimum.accumulate(r[::-1])[::-1]; qq = np.empty_like(pp); qq[o] = np.minimum(r, 1); q[ok] = qq
1330
+ return q
1331
+
1332
+
1333
+ def rank_share_null(n_ctrl, thr=0.95):
1334
+ """expected share of units with rank percentile >= thr among n controls, under exchangeability"""
1335
+ n = np.asarray(n_ctrl, float)
1336
+ return float(np.mean([(k - np.ceil(thr * k) + 1) / (k + 1) for k in n])) if len(n) else np.nan
1337
+
1338
+
1339
+ def match_controls(pool_end, pool_size, ok, size, dist, row, widen=True):
1340
+ """indices of the controls matched to a deletion of `size` genes lying `dist` genes before `row`.
1341
+ `ok` already excludes overlaps with U and D and controls linked to either. Returns (idx, widened).
1342
+ With MATCH_LOG = F, a third tier when the widened one still has < 3: distance within x1/F..xF of the unit's."""
1343
+ d = row - pool_end
1344
+ for (lo, hi, tol), w in ((MATCH, False), (WIDEN, True)):
1345
+ m = ok & (pool_end < row) & (pool_size >= lo * size) & (pool_size <= hi * size) & (np.abs(d - dist) <= tol * dist)
1346
+ idx = np.flatnonzero(m)
1347
+ if len(idx) >= 3 or not widen: return idx, w
1348
+ if MATCH_LOG is not None:
1349
+ with np.errstate(divide="ignore", invalid="ignore"):
1350
+ m = ok & (pool_end < row) & (pool_size >= WIDEN[0] * size) & (pool_size <= WIDEN[1] * size) & \
1351
+ (np.abs(np.log(np.maximum(d, 1) / dist)) <= np.log(MATCH_LOG))
1352
+ idx = np.flatnonzero(m)
1353
+ return idx, True
1354
+
1355
+
1356
+ def robust_z(delta, ctrl, floor):
1357
+ """(delta - median) / (1.4826 x MAD) of the control deltas, floored. The pilot used mean/sd of 3-5 skewed
1358
+ control deltas, which is what made the tails miscalibrated (inference review finding 2)."""
1359
+ if len(ctrl) < 3: return np.nan, np.nan, np.nan
1360
+ c = np.asarray(ctrl, float); med = float(np.median(c))
1361
+ mad = 1.4826 * float(np.median(np.abs(c - med)))
1362
+ return (delta - med) / max(mad, floor), med, mad
1363
+
1364
+
1365
+ def rank_pct(delta, ctrl, e_sign):
1366
+ """percentile of delta among its controls in the expected direction (0.5 = a tie)"""
1367
+ c = np.asarray(ctrl, float) * e_sign; x = delta * e_sign
1368
+ return float((np.sum(c < x) + 0.5 * np.sum(c == x)) / len(c)) if len(c) else np.nan
1369
+
1370
+
1371
+ def mde_wilcoxon(sd, n, alpha=0.05, power=0.8):
1372
+ """smallest mean shift (same units as sd) a one-sided signed-rank test on n lineage means detects with
1373
+ the given power (normal approximation, Wilcoxon ARE 3/pi)"""
1374
+ from scipy.stats import norm
1375
+ if n < 2 or not np.isfinite(sd): return np.nan
1376
+ return float((norm.ppf(1 - alpha) + norm.ppf(power)) * sd / np.sqrt(n) * np.sqrt(np.pi / 3))
pgb/knock/decoder_halves.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1cc6d74a9919eddbe4969d2939261fdf3670180d860042b7b26821596534a27b
3
+ size 72571164
pgb/knock/units_cache_12f2d9db55e8.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:26acc93ad80a54bc4cc47314ba6e58ea9b8b6982382147c963c069ac23e1a80c
3
+ size 2012782
pgb/knock/units_cache_b5ec1591b7f9.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40d1f26045814c9db3ca40c0ed3ec768454947eb2814b32c6de5faea5095a24a
3
+ size 3842324
pgb/region_sets.json ADDED
The diff for this file is too large to render. See raw diff
 
pgb/region_sets_close.json ADDED
The diff for this file is too large to render. See raw diff
 
pgb_region.py CHANGED
@@ -150,11 +150,18 @@ def build(anchor, W=80):
150
  S = (Q @ Pn).toarray(); hit[mm] = (S.argmax(1) == fam_at[mm]) & (S.max(1) > 0); ptrue[mm] = S[np.arange(len(mm)), fam_at[mm]]
151
  p_eff = np.where(ptrue > 0, ptrue, top_p[:, -1])
152
  ppl = lambda m: round(float(2 ** np.mean(-np.log2(np.maximum(p_eff[m], 1e-12)))), 3)
 
153
  per_locus = []
154
  for k in range(W):
155
  m = call_k == k
156
  if m.sum(): per_locus.append(dict(locus=k, n=int(m.sum()), entropy=round(float(ent[m].mean()), 3), ppl=ppl(m),
157
- unread=round(float((ptrue[m] == 0).mean()), 4), top1=None, top1_dec=round(float(hit[m].mean()), 3)))
 
 
 
 
 
 
158
  call_of = {(int(gg), int(kk)): i for i, (gg, kk) in enumerate(zip(call_g, call_k))}
159
  # --- families: carriers, columns, neighbours, proteins, names
160
  occ = collections.defaultdict(list); loci = collections.defaultdict(collections.Counter); rg = collections.defaultdict(list)
@@ -205,7 +212,7 @@ def build(anchor, W=80):
205
  prev=prev, next=nextw, window=W, n_genomes=G, genomes_total=NG, calls=int(len(fam_at)),
206
  top1=None, top1_dec=round(float(hit.mean()), 3), ppl=ppl(np.ones(len(fam_at), bool)), unread=round(float((ptrue == 0).mean()), 4),
207
  families=len(nodes), edges=len(E), ms=round((time.time() - t0) * 1000))
208
- return dict(meta=meta, per_locus=per_locus, nodes=nodes, edges=E, ghosts=ghosts,
209
  bflabels={str(ff): bflabel(ff) for nd in nodes for ff, *_ in nd["call"]})
210
 
211
  if __name__ == "__main__":
 
150
  S = (Q @ Pn).toarray(); hit[mm] = (S.argmax(1) == fam_at[mm]) & (S.max(1) > 0); ptrue[mm] = S[np.arange(len(mm)), fam_at[mm]]
151
  p_eff = np.where(ptrue > 0, ptrue, top_p[:, -1])
152
  ppl = lambda m: round(float(2 ** np.mean(-np.log2(np.maximum(p_eff[m], 1e-12)))), 3)
153
+ rgp_c = RGP[rows].astype(bool); part_c = np.array([PARTITION[int(f)] for f in fam_at_f])
154
  per_locus = []
155
  for k in range(W):
156
  m = call_k == k
157
  if m.sum(): per_locus.append(dict(locus=k, n=int(m.sum()), entropy=round(float(ent[m].mean()), 3), ppl=ppl(m),
158
+ unread=round(float((ptrue[m] == 0).mean()), 4), top1=None, top1_dec=round(float(hit[m].mean()), 3),
159
+ rgp=round(float(rgp_c[m].mean()), 3)))
160
+ def table(m): # the calls of the window, by the gene actually there
161
+ return dict(n=int(m.sum()), top1=None, top1_dec=round(float(hit[m].mean()), 3), med_rank=None,
162
+ entropy=round(float(ent[m].mean()), 3), ppl=ppl(m)) if m.sum() else None
163
+ by_partition = {k: table(part_c == k) for k in ("persistent", "shell", "cloud")}
164
+ by_rgp = {"outside an RGP": table(~rgp_c), "inside an RGP": table(rgp_c)}
165
  call_of = {(int(gg), int(kk)): i for i, (gg, kk) in enumerate(zip(call_g, call_k))}
166
  # --- families: carriers, columns, neighbours, proteins, names
167
  occ = collections.defaultdict(list); loci = collections.defaultdict(collections.Counter); rg = collections.defaultdict(list)
 
212
  prev=prev, next=nextw, window=W, n_genomes=G, genomes_total=NG, calls=int(len(fam_at)),
213
  top1=None, top1_dec=round(float(hit.mean()), 3), ppl=ppl(np.ones(len(fam_at), bool)), unread=round(float((ptrue == 0).mean()), 4),
214
  families=len(nodes), edges=len(E), ms=round((time.time() - t0) * 1000))
215
+ return dict(meta=meta, per_locus=per_locus, by_partition=by_partition, by_rgp=by_rgp, nodes=nodes, edges=E, ghosts=ghosts,
216
  bflabels={str(ff): bflabel(ff) for nd in nodes for ff, *_ in nd["call"]})
217
 
218
  if __name__ == "__main__":
serve_live.py CHANGED
@@ -27,6 +27,40 @@ forward pass on the genome's real prefix, cached.
27
  their mean (rows: the path's proteins; columns: the start token, the context before
28
  the path summed, the path's proteins), and for every layer and head the last
29
  protein's row, where the model looks when it calls the next family. Per mille.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  """
31
  import os, json, re, time, argparse, threading, urllib.parse, collections, numpy as np, torch
32
  from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
@@ -38,6 +72,7 @@ P.add_argument("--port",type=int,default=int(os.environ.get("PORT",8765))); A=P.
38
  dev="cuda:0" if torch.cuda.is_available() else "cpu"; CLS,PROT=2,4
39
  DT=torch.bfloat16 if dev!="cpu" else torch.float32 # bfloat16 on a GPU; float32 on a CPU, where it is faster
40
  if dev=="cpu": torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS","2")))
 
41
  MODEL="macwiatrak/bacformer-causal-complete-genomes"
42
  cm=AutoModelForCausalLM.from_pretrained(MODEL,trust_remote_code=True).to(DT).eval().to(dev)
43
  import sys, math
@@ -249,22 +284,270 @@ def prefix(a):
249
 
250
  MAXQ=int(os.environ.get("MAX_QUEUE","6")); QN=[0]; QL=threading.Lock()
251
  class Busy(Exception): pass
252
- def queued(fn,a):
253
- """at most MAXQ requests wait for the model; beyond that the page is asked to try again"""
 
254
  with QL:
255
- if QN[0]>=MAXQ: raise Busy("the model is busy, try again in a moment")
256
  QN[0]+=1
257
  try: return fn(a)
258
  finally:
259
  with QL: QN[0]-=1
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  class H(SimpleHTTPRequestHandler):
262
  extensions_map={**SimpleHTTPRequestHandler.extensions_map,".html":"text/html; charset=utf-8",
263
  ".js":"text/javascript; charset=utf-8"}
264
  def __init__(self,*a,**k): super().__init__(*a,directory="standalone",**k)
265
  def end_headers(self):
266
  o=self.headers.get("Origin") or ""
267
- if o in ("null","http://localhost:%d"%A.port,"http://127.0.0.1:%d"%A.port):
268
  self.send_header("Access-Control-Allow-Origin",o)
269
  self.send_header("Access-Control-Allow-Private-Network","true")
270
  super().end_headers()
@@ -275,12 +558,20 @@ class H(SimpleHTTPRequestHandler):
275
  self.send_header("Cache-Control","no-store"); self.end_headers(); self.wfile.write(b)
276
  def do_GET(self):
277
  u=urllib.parse.urlparse(self.path); a=dict(urllib.parse.parse_qsl(u.query))
278
- if u.path=="/health": return self.js(dict(ok=True,model=MODEL,device=dev,genomes=len(GEN)))
 
 
 
 
 
 
 
 
279
  route={"/gcall":gcall,"/path":pathcall,"/prefix":prefix,"/region":region,"/attn":attncall}.get(u.path)
280
  if route is None: return super().do_GET()
281
  try: self.js(queued(route,a))
282
  except Busy as e: self.js(dict(error=str(e)),503)
283
- except (KeyError,ValueError) as e: self.js(dict(error=str(e).strip("'\"")),400)
284
  def list_directory(self,path): self.send_error(404); return None # files only, no listings
285
  def log_message(self,format,*args): pass
286
 
 
27
  their mean (rows: the path's proteins; columns: the start token, the context before
28
  the path summed, the path's proteins), and for every layer and head the last
29
  protein's row, where the model looks when it calls the next family. Per mille.
30
+
31
+ What the model itself knows about one accessory element (the Coupled regions tab): pg_knock.influence,
32
+ the in-silico knockout of pg_knockout.py on demand, with the request and response of serve_influence.py.
33
+ pg_knock is imported at the first of these requests, never at start: without its files (pg_knock.py,
34
+ pgb/region_sets.json, and the decoder pgb/knock/decoder_halves.npz or base_top64_{f,p}.npy) the server
35
+ starts and serves the rest as before, and /health says why influence is not available.
36
+ sets=long (default) | close: the link sets the elements and their links come from, the page's two layers
37
+ (pgb/region_sets.json, pgb/region_sets_close.json, which is optional); /health lists the sets present
38
+ with the md5 of each file, and every answer names its set and md5 (the page checks it shows the same)
39
+ GET /elements?acc=<assembly> | g=<index 0-539 of the complete chromosomes> [&sets=]
40
+ the accessory elements of that chromosome (panRGP runs by spot, coupled regions present at
41
+ their spot): kind, id, label, start, end, entry gene, size
42
+ GET /influence?acc= | g= &region=<region id> | &spot=<spot id> | &genes=<p,q,...> [&sets=] [&n_ctrl=8] [&wait=<s>]
43
+ baseline, deletion of the element and n_ctrl size-matched whole-element control deletions, one
44
+ fp32 pass each (its own fp32 copy of the model): ~3 s on a GPU (~8 s for the first request), ~2-4 min
45
+ on 2 CPU threads (a Hugging Face Space). One computation at a time, in a queue; results cached.
46
+ 200 the result (serve_influence.py's JSON) with sets, sets_md5 and links: the elements linked to
47
+ the deleted one and where each is in this genome (read among the neighbours, before it, beyond
48
+ the 1,500 genes read, not located at its spot, absent)
49
+ 202 a job: {job, state: queued | running, stage, loading, done, total, position, elapsed_s, eta_s};
50
+ poll GET /influence?job=<id>[&wait=<s>] until 200 (or an error). eta_s: from the passes done
51
+ (none before the first one), for a queued job from the jobs before it
52
+ wait: hold the request until the result is ready or for that many seconds (at most 60; default
53
+ 60 on a GPU, 0 on a CPU, where a call is a background job to poll)
54
+ 400 bad request (unknown genome or set, element not present in it...), checked before any queue
55
+ 404 unknown or expired job; 410 a queued job dropped because nobody asked for it for 30 s
56
+ 500 the computation failed (also a failed numerical check)
57
+ 503 influence not available here (why in `error`; a failed load is retried after a minute), or too
58
+ many requests waiting (`retry_s`)
59
+ GPU_MEM_FRAC=<0-1> caps the GPU memory of the whole process (a second instance next to a running one);
60
+ INFLUENCE_DECODER=compact | full forces the decoder (default: the compact one when it is there, identical);
61
+ INFLUENCE_JOBS=1 makes every influence call a job on a GPU too; INFLUENCE_QUEUE caps the jobs waiting (2 on a
62
+ CPU, 4 on a GPU). On a CPU the influence passes run on 2 threads (the whole process then does): their
63
+ exactness check (genes before the deletion unchanged, bit for bit) holds at 1-2 threads, not at 4 or more.
64
  """
65
  import os, json, re, time, argparse, threading, urllib.parse, collections, numpy as np, torch
66
  from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
 
72
  dev="cuda:0" if torch.cuda.is_available() else "cpu"; CLS,PROT=2,4
73
  DT=torch.bfloat16 if dev!="cpu" else torch.float32 # bfloat16 on a GPU; float32 on a CPU, where it is faster
74
  if dev=="cpu": torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS","2")))
75
+ elif os.environ.get("GPU_MEM_FRAC"): torch.cuda.set_per_process_memory_fraction(float(os.environ["GPU_MEM_FRAC"]),0)
76
  MODEL="macwiatrak/bacformer-causal-complete-genomes"
77
  cm=AutoModelForCausalLM.from_pretrained(MODEL,trust_remote_code=True).to(DT).eval().to(dev)
78
  import sys, math
 
284
 
285
  MAXQ=int(os.environ.get("MAX_QUEUE","6")); QN=[0]; QL=threading.Lock()
286
  class Busy(Exception): pass
287
+ def queued(fn,a,admitted=False):
288
+ """at most MAXQ requests wait for the model; beyond that the page is asked to try again
289
+ (admitted: an influence job, refused or not when it was queued, only counted while it runs)"""
290
  with QL:
291
+ if QN[0]>=MAXQ and not admitted: raise Busy("the model is busy, try again in a moment")
292
  QN[0]+=1
293
  try: return fn(a)
294
  finally:
295
  with QL: QN[0]-=1
296
 
297
+ # ---------------------------------------------------------------- influence of one accessory element (Coupled regions)
298
+ # pg_knock (its data, its lineage-CV decoder and its own fp32 copy of the model) is loaded at the first request, per
299
+ # link set (long: pgb/region_sets.json, close: pgb/region_sets_close.json; one model and one decoder serve both).
300
+ # Each computation is a job run by one worker thread, first in first out, counted by queued() while it runs;
301
+ # a request waits for it (GPU, ~3 s) or gets the job to poll (CPU, ~2-4 min on 2 threads).
302
+ import queue, secrets, hashlib
303
+ class Unavailable(Exception): pass
304
+ INF_NEED=["pg_knock.py","pgb/region_sets.json","pgb/chrom.npz","pgb/chrom_genomes.json","pgb/chrom_emb.f16"]
305
+ INF_SETS={"long":"pgb/region_sets.json","close":"pgb/region_sets_close.json"} # the page's two layers; close is optional
306
+ INF_DECODERS={"full":["pgb/knock/base_top64_f.npy","pgb/knock/base_top64_p.npy"],"compact":["pgb/knock/decoder_halves.npz"]}
307
+ INF_FORCE=os.environ.get("INFLUENCE_DECODER","").strip().lower() or None # compact | full | None (auto)
308
+ INF_JOB=dev=="cpu" or os.environ.get("INFLUENCE_JOBS")=="1"
309
+ INF_MAXQ=int(os.environ.get("INFLUENCE_QUEUE","2" if dev=="cpu" else "4")) # jobs waiting at most (a CPU job is minutes)
310
+ INF_KEEP=600 # seconds a finished job stays to be fetched
311
+ INF_IDLE=30.0 # a queued job that nobody has asked about for this long is dropped (the page polls every ~4 s)
312
+ INF_RETRY=60.0 # a failed load is tried again by a request after this long
313
+ INF_THREADS=2 # CPU threads of the influence passes: its exactness check (g) holds at 1-2 threads, not at 4+
314
+ INF=dict(K=None,D={},ACC=None,ctx={},error=None,error_t=0.0,load_s=None,last_s=None)
315
+ INF_LOAD=threading.Lock(); JL=threading.Lock(); JOBS=collections.OrderedDict(); INF_CACHE=collections.OrderedDict()
316
+ INF_Q=queue.Queue(); INF_W=[]; _MD5={}
317
+ def inf_decoder():
318
+ """which decoder influence reads here, or None: the compact one (the same rows bit for bit, loaded in 1 s instead of
319
+ 3, and a genome's rows in 50 ms instead of ~9 s) unless the full one is newer (rebuilt since the export)"""
320
+ have={k:all(os.path.exists(f) for f in v) for k,v in INF_DECODERS.items()}
321
+ if INF_FORCE in have: return INF_FORCE if have[INF_FORCE] else None
322
+ if have["compact"] and have["full"]:
323
+ return "full" if max(map(os.path.getmtime,INF_DECODERS["full"]))>os.path.getmtime(INF_DECODERS["compact"][0]) else "compact"
324
+ return "full" if have["full"] else "compact" if have["compact"] else None
325
+ def inf_missing():
326
+ miss=[f for f in INF_NEED if not os.path.exists(f)]
327
+ if inf_decoder() is None: miss.append("the decoder pgb/knock/decoder_halves.npz" if INF_FORCE!="full" else "pgb/knock/base_top64_{f,p}.npy")
328
+ return miss
329
+ def inf_sets():
330
+ """{set: md5 of its file} for the link sets present here: the page checks that it shows the same elements"""
331
+ out={}
332
+ for k,p in INF_SETS.items():
333
+ try: st=os.stat(p)
334
+ except OSError: continue
335
+ c=_MD5.get(p)
336
+ if not c or c[0]!=(st.st_mtime,st.st_size): c=_MD5[p]=((st.st_mtime,st.st_size),hashlib.md5(open(p,"rb").read()).hexdigest())
337
+ out[k]=c[1]
338
+ return out
339
+ def inf_failed():
340
+ """the load error, if any; after INF_RETRY seconds the next request tries again"""
341
+ if INF["error"] and time.time()-INF["error_t"]>INF_RETRY: INF["error"]=None
342
+ return INF["error"]
343
+ def inf_fail(msg):
344
+ INF.update(error=msg,error_t=time.time()); raise Unavailable(msg)
345
+ def inf_setname(a):
346
+ s=(a.get("sets") or "long").strip().lower()
347
+ if s not in INF_SETS: raise ValueError("sets: long (the default) or close")
348
+ if s not in inf_sets(): raise ValueError(f"this server has no {s}-range link sets ({INF_SETS[s]} is missing): it reads the "+" and ".join(inf_sets()))
349
+ return s
350
+ def inf_data(sets="long"):
351
+ """pg_knock and the Data of one link set (chromosomes, accessory elements, controls): enough for /elements"""
352
+ if sets not in INF["D"]:
353
+ with INF_LOAD:
354
+ if sets not in INF["D"]:
355
+ miss=inf_missing()
356
+ if miss: raise Unavailable("influence is not available on this server: missing "+", ".join(miss))
357
+ if inf_failed(): raise Unavailable(INF["error"])
358
+ try:
359
+ t0=time.time(); import pg_knock as K
360
+ path=None if sets=="long" else INF_SETS[sets]
361
+ try: D=K.Data(sets=path)
362
+ except Exception as e: # units cache unreadable or not writable: rebuilt in memory (~5-10 s)
363
+ print(f"influence: the units cache of the {sets}-range sets is not usable ({type(e).__name__}: {e}); rebuilding it in memory",flush=True)
364
+ D=K.Data(cache=False,sets=path)
365
+ INF["K"]=K; INF["D"][sets]=D
366
+ if INF["ACC"] is None: INF["ACC"]={a:i for i,a in enumerate(D.ACC)}
367
+ INF["load_s"]=(INF["load_s"] or 0)+time.time()-t0
368
+ except Exception as e: inf_fail(f"influence could not load its data: {type(e).__name__}: {e}")
369
+ return INF["K"],INF["D"][sets]
370
+ def inf_ctx(sets="long"):
371
+ """(Data, Decoder, Runner) of pg_knock for one link set: the decoder and the fp32 model too, for /influence. One
372
+ model and one decoder serve both sets when their lineage halves agree (the same 540 genomes and clusters)"""
373
+ K,D=inf_data(sets)
374
+ if sets not in INF["ctx"]:
375
+ with INF_LOAD:
376
+ if inf_failed(): raise Unavailable(INF["error"])
377
+ if sets not in INF["ctx"]:
378
+ try:
379
+ t0=time.time(); comp={"compact":True,"full":False}.get(inf_decoder())
380
+ if not INF["ctx"]:
381
+ INF["ctx"][sets]=K.context(dev=dev,threads=min(INF_THREADS,torch.get_num_threads()),frac=None,data=D,compact=comp)
382
+ else:
383
+ D0,dec,R=next(iter(INF["ctx"].values()))
384
+ if not (D0.NP==D.NP and np.array_equal(D0.HALF,D.HALF) and list(D0.LIN)==list(D.LIN)): dec=K.Decoder(D,compact=comp)
385
+ INF["ctx"][sets]=(D,dec,R)
386
+ INF["load_s"]=(INF["load_s"] or 0)+time.time()-t0
387
+ except Exception as e: inf_fail(f"influence could not load the model or its decoder: {type(e).__name__}: {e}")
388
+ return INF["ctx"][sets]
389
+ def inf_genome(D,a):
390
+ if a.get("acc"):
391
+ if a["acc"] not in INF["ACC"]: raise ValueError(f"unknown assembly {a['acc']} (influence reads the {D.NG} complete chromosomes)")
392
+ return INF["ACC"][a["acc"]]
393
+ try: g=int(a.get("g",-1))
394
+ except ValueError: g=-1
395
+ if not 0<=g<D.NG: raise ValueError(f"give acc=<assembly> or g=<genome index 0-{D.NG-1}>")
396
+ return g
397
+ def elements(a):
398
+ sets=inf_setname(a); K,D=inf_data(sets); g=inf_genome(D,a); E=D.elements(g)
399
+ return dict(g=g,acc=D.ACC[g],strain=D.STRAIN[g],n_genes=int(D.GL[g]),sets=sets,sets_md5=D.SETS_MD5,
400
+ elements=[dict(kind="region" if int(k)==1 else "spot",id=int(i),label=D.element_label(k,i)[:60],
401
+ start=int(s),end=int(e),entry=int(en),size=int(z))
402
+ for k,i,s,e,en,z in zip(E["kind"],E["id"],E["start"],E["end"],E["entry"],E["size"])])
403
+ def inf_request(a):
404
+ """-> (cache key, genome, element, n_ctrl), the element checked against the genome (400 before any queue)"""
405
+ sets=inf_setname(a); K,D=inf_data(sets); g=inf_genome(D,a)
406
+ try:
407
+ n_ctrl=max(3,min(16,int(a.get("n_ctrl",8))))
408
+ if "region" in a: el=("region",int(a["region"]))
409
+ elif "spot" in a: el=("spot",int(a["spot"]))
410
+ elif "genes" in a:
411
+ el=("genes",tuple(sorted({int(x) for x in a["genes"].split(",") if x.strip()})))
412
+ if not el[1] or not all(0<=p<int(D.GL[g]) for p in el[1]): raise ValueError(f"genes: positions 0-{int(D.GL[g])-1} of this chromosome")
413
+ if len(el[1])>2000: raise ValueError("genes: 2,000 at most")
414
+ else: raise ValueError("give region=, spot= or genes=")
415
+ if el[0]=="region" and not 0<=el[1]<D.NR: raise ValueError(f"region: an id 0-{D.NR-1} of the {sets}-range sets")
416
+ K.resolve_element(D,g,el)
417
+ except AssertionError as e: raise ValueError(str(e))
418
+ return (sets,g,el,n_ctrl),g,el,n_ctrl
419
+ def inf_left(x,now):
420
+ """seconds left of a running job, from its passes so far (None before the first pass is done)"""
421
+ if x.get("loading") or not (x["done"] and x["total"] and x.get("t_pass0")): return None
422
+ tp=(x["t_tick"]-x["t_pass0"])/x["done"]; return max(0.5,tp*(x["total"]-x["done"])-(now-x["t_tick"]))
423
+ def inf_status(j):
424
+ """what a job is doing, for the page's progress"""
425
+ now=time.time()
426
+ with JL: js=list(JOBS.values())
427
+ run=[x for x in js if x["state"]=="running"]
428
+ ahead=sum(1 for x in js if x["state"]=="queued" and x["t"]<j["t"])
429
+ per=INF["last_s"] or (150.0 if dev=="cpu" else 4.0) # a whole call: the last one measured here
430
+ if j["state"]=="running": pos=0; eta=inf_left(j,now)
431
+ else:
432
+ pos=ahead+len(run)
433
+ lr=[inf_left(x,now) for x in run]; eta=sum(per if v is None else v for v in lr)+per*(ahead+1)
434
+ stage=j["stage"] if j["state"]!="queued" else ("starting" if pos==0 else f"waiting for {pos} computation{'s' if pos>1 else ''} before it")
435
+ return dict(job=j["id"],state=j["state"],stage=stage,loading=bool(j.get("loading")),done=j["done"],total=j["total"],position=pos,
436
+ elapsed_s=round(now-j["t"],1),eta_s=None if eta is None else round(eta,1),device=dev,poll=f"/influence?job={j['id']}")
437
+ def inf_worker():
438
+ while True:
439
+ j=INF_Q.get()
440
+ with JL: idle=j["watch"]==0 and time.time()-j["t_seen"]>INF_IDLE
441
+ if idle: # nobody is waiting for it any more
442
+ j.update(state="error",error=f"dropped: nobody asked for it for {INF_IDLE:.0f} s; ask again",code=410,t_end=time.time())
443
+ j["event"].set(); continue
444
+ try: queued(inf_run,j,admitted=True)
445
+ except Exception as e:
446
+ j.update(state="error",error=f"the computation failed: {type(e).__name__}: {e}",code=500)
447
+ if dev!="cpu": torch.cuda.empty_cache()
448
+ finally:
449
+ j["t_end"]=time.time(); j["event"].set()
450
+ def inf_links(D,g,res):
451
+ """the elements linked to the deleted one in the set read, and where each is in this genome: among the neighbours
452
+ read (where="read"), before it (the model reads from dnaA on: the deletion cannot change it), beyond the 1,500
453
+ genes read, listed but not read, carried but not located at its spot, or absent"""
454
+ el=res["element"]
455
+ if el["kind"]!=1: return []
456
+ ri=int(el["id"]); nb={x["id"] for x in res["neighbours"] if x["kind"]=="region"}
457
+ E=D.elements(g); at={int(i):(int(s),int(e)) for k,i,s,e in zip(E["kind"],E["id"],E["start"],E["end"]) if int(k)==1}
458
+ out=[]
459
+ for l in D.LINKS:
460
+ if ri not in (l["a"],l["b"]): continue
461
+ rj=int(l["b"] if l["a"]==ri else l["a"]); r=D.REG[rj]
462
+ x=dict(id=rj,label=r["label"][:60],sign=int(l["sign"]),spot=-1 if r["spot"] is None else int(r["spot"]))
463
+ if rj in at:
464
+ s,e=at[rj]; d=s-int(el["end"])
465
+ x.update(start=s,end=e,dist=d,where="read" if rj in nb else "before" if d<=0 else "beyond" if d>1500 else "unread")
466
+ else: x["where"]="unlocated" if D.PRES[rj,g] else "absent"
467
+ out.append(x)
468
+ return out
469
+ def inf_run(j):
470
+ sets,g,el,n_ctrl=j["key"]; first=sets not in INF["ctx"]
471
+ j.update(state="running",loading=first,t_run=time.time(),
472
+ stage=("loading the model and its decoder (first request)" if not INF["ctx"] else "loading the close-range elements") if first else "choosing the controls")
473
+ try: ctx=inf_ctx(sets)
474
+ except Unavailable as e: j.update(state="error",error=str(e),code=503); return
475
+ j.update(stage="choosing the controls",loading=False)
476
+ K=INF["K"]
477
+ def tick(done,total,what): j.update(done=done,total=total,stage=what,t_pass0=j.get("t_pass0") or time.time(),t_tick=time.time())
478
+ try: res=K.influence(g,el,n_ctrl=n_ctrl,ctx=ctx,progress=tick)
479
+ except AssertionError as e: # an internal check (the input was checked before queueing)
480
+ j.update(state="error",error=f"a numerical check of the computation failed ({e})",code=500); return
481
+ res=dict(res,sets=sets,sets_md5=ctx[0].SETS_MD5,links=inf_links(ctx[0],g,res))
482
+ INF["last_s"]=time.time()-(j["t_pass0"] or j["t_run"])
483
+ with JL:
484
+ INF_CACHE[j["key"]]=res
485
+ while len(INF_CACHE)>64: INF_CACHE.popitem(last=False)
486
+ j.update(state="done",stage="done",result=res,t_end=time.time())
487
+ def inf_submit(key):
488
+ """the job computing `key`: the one already queued or running, else a new one (503 when too many wait)"""
489
+ with JL:
490
+ now=time.time()
491
+ old=[k for k,x in JOBS.items() if x["state"] in ("done","error") and now-(x["t_end"] or now)>INF_KEEP]
492
+ old+=[k for k,x in JOBS.items() if x["state"] in ("done","error")][:max(0,len(JOBS)-len(old)-200)]
493
+ for k in set(old): del JOBS[k]
494
+ for x in JOBS.values():
495
+ if x["key"]==key and x["state"] in ("queued","running"): x["t_seen"]=now; return x
496
+ waiting=sum(1 for x in JOBS.values() if x["state"] in ("queued","running"))
497
+ if waiting>=INF_MAXQ: raise Busy(f"{waiting} influence computation{'s are' if waiting>1 else ' is'} already waiting, try again "
498
+ +("in a few minutes" if dev=="cpu" else "in a moment"))
499
+ if QN[0]>=MAXQ: raise Busy("the model is busy, try again in a moment")
500
+ j=dict(id=secrets.token_hex(6),key=key,state="queued",stage="",done=0,total=None,
501
+ t=now,t_seen=now,watch=0,t_pass0=None,t_end=None,event=threading.Event())
502
+ JOBS[j["id"]]=j
503
+ if not INF_W:
504
+ INF_W.append(threading.Thread(target=inf_worker,daemon=True)); INF_W[0].start()
505
+ INF_Q.put(j); return j
506
+ def inf_answer(j,wait):
507
+ with JL: j["watch"]+=1; j["t_seen"]=time.time()
508
+ try:
509
+ if wait>0: j["event"].wait(min(wait,60.0))
510
+ finally:
511
+ with JL: j["watch"]-=1; j["t_seen"]=time.time()
512
+ if j["state"]=="done": return 200,j["result"]
513
+ if j["state"]=="error": return j.get("code",500),dict(error=j["error"],job=j["id"],state="error")
514
+ return 202,inf_status(j)
515
+ def influence(a):
516
+ """-> (HTTP code, JSON) for /influence: a result (200), a job to poll (202) or an error"""
517
+ wait=a.get("wait")
518
+ try: wait=float(wait) if wait not in (None,"") else None
519
+ except ValueError: raise ValueError("wait: seconds")
520
+ if a.get("job"):
521
+ with JL: j=JOBS.get(a["job"])
522
+ if j is None: return 404,dict(error="unknown or expired job: ask again with the genome and the element")
523
+ return inf_answer(j,wait or 0)
524
+ if inf_failed(): raise Unavailable(INF["error"])
525
+ key,g,el,n_ctrl=inf_request(a)
526
+ with JL:
527
+ if key in INF_CACHE: INF_CACHE.move_to_end(key); return 200,INF_CACHE[key]
528
+ return inf_answer(inf_submit(key),(0.0 if INF_JOB else 60.0) if wait is None else wait)
529
+ def inf_health():
530
+ miss=inf_missing(); err=inf_failed(); ok=not miss and not err
531
+ with JL: waiting=sum(1 for x in JOBS.values() if x["state"] in ("queued","running"))
532
+ r1=lambda x: None if x is None else round(x,1)
533
+ out=dict(available=ok,loaded=bool(INF["ctx"]),device=dev,mode="job" if INF_JOB else "wait",decoder=inf_decoder(),sets=inf_sets(),
534
+ waiting=waiting,max_waiting=INF_MAXQ,cached=len(INF_CACHE),
535
+ seconds=dict(load=r1(INF["load_s"]),last=r1(INF["last_s"]), # measured here: loading; the last computation
536
+ expected=r1(INF["last_s"]) or (150 if dev=="cpu" else 4), # per element until one is measured
537
+ expected_first_load=15 if dev=="cpu" else 8))
538
+ if not ok: out["reason"]=err or "missing "+", ".join(miss)
539
+ return out
540
+ def emsg(e):
541
+ """the message of a KeyError without its quotes, of anything else as is"""
542
+ return str(e.args[0]) if isinstance(e,KeyError) and e.args else str(e)
543
+
544
  class H(SimpleHTTPRequestHandler):
545
  extensions_map={**SimpleHTTPRequestHandler.extensions_map,".html":"text/html; charset=utf-8",
546
  ".js":"text/javascript; charset=utf-8"}
547
  def __init__(self,*a,**k): super().__init__(*a,directory="standalone",**k)
548
  def end_headers(self):
549
  o=self.headers.get("Origin") or ""
550
+ if o=="null" or re.fullmatch(r"http://(localhost|127\.0\.0\.1)(:\d+)?",o): # a local page, or one opened as a file
551
  self.send_header("Access-Control-Allow-Origin",o)
552
  self.send_header("Access-Control-Allow-Private-Network","true")
553
  super().end_headers()
 
558
  self.send_header("Cache-Control","no-store"); self.end_headers(); self.wfile.write(b)
559
  def do_GET(self):
560
  u=urllib.parse.urlparse(self.path); a=dict(urllib.parse.parse_qsl(u.query))
561
+ if u.path=="/health": return self.js(dict(ok=True,model=MODEL,device=dev,genomes=len(GEN),influence=inf_health()))
562
+ if u.path in ("/influence","/elements"):
563
+ try:
564
+ if u.path=="/elements": return self.js(elements(a))
565
+ code,obj=influence(a); return self.js(obj,code)
566
+ except Busy as e: return self.js(dict(error=str(e),retry_s=10 if dev=="cpu" else 3),503)
567
+ except Unavailable as e: return self.js(dict(error=str(e),available=False),503)
568
+ except (KeyError,ValueError) as e: return self.js(dict(error=emsg(e)),400)
569
+ except Exception as e: return self.js(dict(error=f"{type(e).__name__}: {e}"),500)
570
  route={"/gcall":gcall,"/path":pathcall,"/prefix":prefix,"/region":region,"/attn":attncall}.get(u.path)
571
  if route is None: return super().do_GET()
572
  try: self.js(queued(route,a))
573
  except Busy as e: self.js(dict(error=str(e)),503)
574
+ except (KeyError,ValueError) as e: self.js(dict(error=emsg(e)),400)
575
  def list_directory(self,path): self.send_error(404); return None # files only, no listings
576
  def log_message(self,format,*args): pass
577
 
standalone/pangramgraph.html CHANGED
The diff for this file is too large to render. See raw diff
 
start.py CHANGED
@@ -1,17 +1,27 @@
1
  """Start the PanGramGraph Space: fetch the data if the Space does not carry them, then serve.
2
 
3
- The data (pgb/*, about 670 MB) either sit in the Space itself, or in a dataset repository
4
  named by the variable PGG_DATA_REPO; a private one is read with HF_TOKEN, a Space secret.
 
 
5
  """
6
  import os, sys
7
  DATA = ["pgb/window.json", "pgb/window_emb.npy", "pgb/model_calls.npz", "pgb/graph_pgb.json", "pgb/fam_info.json",
8
  "pgb/chrom.npz", "pgb/chrom_genomes.json", "pgb/calls_top15_f.i32", "pgb/calls_top15_p.f16",
9
  "pgb/calls_ent.f16", "pgb/chrom_emb.f16"]
 
10
  missing = [p for p in DATA if not os.path.exists(p)]
11
- if missing:
12
- repo = os.environ.get("PGG_DATA_REPO")
13
- if not repo: sys.exit(f"missing data ({', '.join(missing[:3])}...) and no PGG_DATA_REPO to fetch them from")
 
 
14
  from huggingface_hub import snapshot_download
15
- print(f"fetching {len(missing)} data files from {repo}", flush=True)
16
- snapshot_download(repo_id=repo, repo_type="dataset", local_dir=".", allow_patterns=["pgb/*"], token=os.environ.get("HF_TOKEN"))
 
 
 
 
 
17
  os.execvp(sys.executable, [sys.executable, "serve_live.py"])
 
1
  """Start the PanGramGraph Space: fetch the data if the Space does not carry them, then serve.
2
 
3
+ The data (pgb/*, about 720 MB) either sit in the Space itself, or in a dataset repository
4
  named by the variable PGG_DATA_REPO; a private one is read with HF_TOKEN, a Space secret.
5
+ DATA are needed to start; INFLUENCE (the Coupled regions tab's knockout of one element,
6
+ serve_live /influence) are not: without them the Space starts and /health says influence is off.
7
  """
8
  import os, sys
9
  DATA = ["pgb/window.json", "pgb/window_emb.npy", "pgb/model_calls.npz", "pgb/graph_pgb.json", "pgb/fam_info.json",
10
  "pgb/chrom.npz", "pgb/chrom_genomes.json", "pgb/calls_top15_f.i32", "pgb/calls_top15_p.f16",
11
  "pgb/calls_ent.f16", "pgb/chrom_emb.f16"]
12
+ INFLUENCE = ["pgb/region_sets.json", "pgb/region_sets_close.json", "pgb/knock/decoder_halves.npz"] # + the units caches, pgb/knock/units_cache_*.pkl
13
  missing = [p for p in DATA if not os.path.exists(p)]
14
+ optional = [p for p in INFLUENCE if not os.path.exists(p)]
15
+ repo = os.environ.get("PGG_DATA_REPO")
16
+ if missing and not repo:
17
+ sys.exit(f"missing data ({', '.join(missing[:3])}...) and no PGG_DATA_REPO to fetch them from")
18
+ if (missing or optional) and repo:
19
  from huggingface_hub import snapshot_download
20
+ print(f"fetching {len(missing + optional)} data files from {repo}", flush=True)
21
+ snapshot_download(repo_id=repo, repo_type="dataset", local_dir=".", token=os.environ.get("HF_TOKEN"),
22
+ allow_patterns=missing + optional + (["pgb/knock/units_cache_*.pkl"] if optional else []))
23
+ missing = [p for p in DATA if not os.path.exists(p)]
24
+ if missing: sys.exit(f"still missing after fetching from {repo}: {', '.join(missing)}")
25
+ optional = [p for p in INFLUENCE if not os.path.exists(p)]
26
+ if optional: print(f"influence off (missing {', '.join(optional)}): the rest of the page is served", flush=True)
27
  os.execvp(sys.executable, [sys.executable, "serve_live.py"])