Spaces:
Running
Running
Coupled regions with the model: verdicts, close links, influence on click; Findings follow the window
Browse files- pg_knock.py +1376 -0
- pgb/knock/decoder_halves.npz +3 -0
- pgb/knock/units_cache_12f2d9db55e8.pkl +3 -0
- pgb/knock/units_cache_b5ec1591b7f9.pkl +3 -0
- pgb/region_sets.json +0 -0
- pgb/region_sets_close.json +0 -0
- pgb_region.py +9 -2
- serve_live.py +297 -6
- standalone/pangramgraph.html +0 -0
- start.py +16 -6
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
|
| 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=
|
| 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= ®ion=<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
|
| 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
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 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=".",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"])
|