File size: 10,885 Bytes
1bb570d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | #!/usr/bin/env python3
"""Freeze molecule-disjoint genre splits and export PIMT representations.
The prepare phase is label-aware only for the preregistered coverage check. The
export phase never runs the benchmark and writes embeddings in manifest order.
"""
from __future__ import annotations
import argparse
from collections import Counter
import hashlib
import json
import random
import subprocess
from pathlib import Path
from typing import Any
import numpy as np
from pino.genre_benchmark import SOLVENTS, active_compounds, formula_fingerprint
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def genre(row: dict[str, Any]) -> str:
return str(row.get("genre") or row.get("metadata", {}).get("generation_strategy") or "wildcard")
def lean_row(row: dict[str, Any]) -> dict[str, Any]:
"""Retain exactly the fields consumed by the benchmark and row alignment."""
return {
"source_index": row["source_index"],
"formula_id": row.get("formula_id") or row.get("metadata", {}).get("formula_id"),
"genre": genre(row),
"formula": row.get("formula", []),
}
def load_index(dataset: Path) -> tuple[list[dict[str, Any]], list[str]]:
rows: list[dict[str, Any]] = []
molecules: set[str] = set()
with dataset.open(encoding="utf-8") as handle:
for index, line in enumerate(handle):
row = json.loads(line)
row["source_index"] = index
compounds = active_compounds(row)
rows.append({
"source_index": index,
"genre": genre(row),
"compounds": compounds,
"fingerprint": formula_fingerprint(row),
})
molecules.update(compounds)
return rows, sorted(molecules)
def split_indices(rows: list[dict[str, Any]], molecules: list[str], seed: int, train_ratio: float):
shuffled = molecules.copy()
random.Random(seed).shuffle(shuffled)
cut = min(max(int(len(shuffled) * train_ratio), 1), len(shuffled) - 1)
train_molecules = set(shuffled[:cut])
test_molecules = set(shuffled[cut:])
train, test, excluded = [], [], []
for row in rows:
compounds = row["compounds"]
if not compounds:
excluded.append(row["source_index"])
elif compounds <= train_molecules:
train.append(row["source_index"])
elif compounds <= test_molecules:
test.append(row["source_index"])
else:
excluded.append(row["source_index"])
return train, test, excluded
def counts(rows_by_index: dict[int, dict[str, Any]], indices: list[int]) -> dict[str, int]:
return dict(sorted(Counter(rows_by_index[i]["genre"] for i in indices).items()))
def prepare(args: argparse.Namespace) -> None:
dataset, checkpoint, output = Path(args.dataset), Path(args.checkpoint), Path(args.output_dir)
output.mkdir(parents=True, exist_ok=True)
rows, molecules = load_index(dataset)
by_index = {row["source_index"]: row for row in rows}
split_specs = []
wanted: dict[int, tuple[str, str]] = {}
for seed in args.seeds:
train, test, excluded = split_indices(rows, molecules, seed, args.train_ratio)
train_counts, test_counts = counts(by_index, train), counts(by_index, test)
genres = sorted(set(train_counts) | set(test_counts))
if any(train_counts.get(g, 0) < args.minimum_per_genre or test_counts.get(g, 0) < args.minimum_per_genre for g in genres):
raise SystemExit(f"seed {seed} fails minimum-per-genre={args.minimum_per_genre}: train={train_counts}, test={test_counts}")
name = f"seed_{seed}"
train_path, test_path = output / f"{name}_train.jsonl", output / f"{name}_test.jsonl"
for index in train:
wanted[index] = wanted.get(index, ("", ""))
split_specs.append((name, seed, train, test, excluded, train_path, test_path, train_counts, test_counts))
# Stream the large source once and write compact benchmark records.
handles = {}
memberships: dict[int, list[tuple[Path, str]]] = {}
for _name, _seed, train, test, _excluded, train_path, test_path, _tc, _vc in split_specs:
handles[train_path] = train_path.open("w", encoding="utf-8")
handles[test_path] = test_path.open("w", encoding="utf-8")
for i in train:
memberships.setdefault(i, []).append((train_path, "train"))
for i in test:
memberships.setdefault(i, []).append((test_path, "test"))
with dataset.open(encoding="utf-8") as source:
for index, line in enumerate(source):
if index not in memberships:
continue
row = json.loads(line)
row["source_index"] = index
encoded = json.dumps(lean_row(row), sort_keys=True, separators=(",", ":")) + "\n"
for path, _partition in memberships[index]:
handles[path].write(encoded)
for handle in handles.values():
handle.close()
manifest, audits = [], []
for name, seed, train, test, excluded, train_path, test_path, train_counts, test_counts in split_specs:
manifest.append({
"name": name,
"seed": seed,
"train_records": str(train_path),
"test_records": str(test_path),
"learned_train": str(output / f"{name}_train_embeddings.npy"),
"learned_test": str(output / f"{name}_test_embeddings.npy"),
})
audits.append({
"name": name, "seed": seed, "train_records": len(train), "test_records": len(test),
"excluded_records": len(excluded), "train_genres": train_counts, "test_genres": test_counts,
"minimum_per_genre": args.minimum_per_genre, "coverage_passed": True,
})
manifest_path = output / "genre_split_manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
protocol = {
"protocol_version": 1,
"status": "frozen_before_embedding_export_and_benchmark",
"dataset": {"path": str(dataset), "sha256": sha256(dataset), "records": len(rows)},
"checkpoint": {"path": str(checkpoint), "sha256": sha256(checkpoint)},
"git_commit": commit,
"split": {"method": "active-molecule assignment; mixed-boundary formulas excluded", "train_ratio": args.train_ratio,
"seeds": args.seeds, "minimum_records_per_genre_per_partition": args.minimum_per_genre},
"benchmark": {"probe": "training-standardized nearest centroid", "bootstrap_samples": 10000,
"bootstrap_seed": 8675309, "minimum_valid_splits": 3, "required_margin": 0.0,
"decision": "support iff every valid split has paired CI95 lower bound > required_margin"},
"manifest": str(manifest_path),
"coverage_audit": audits,
}
(output / "frozen_protocol.json").write_text(json.dumps(protocol, indent=2) + "\n")
print(json.dumps(protocol, indent=2))
def pooled_embedding(model, item, torch):
tokens = item["tokens"].unsqueeze(0)
states = item["physics"].unsqueeze(0)
with torch.inference_mode():
latent = model(tokens, states)
return latent.mean(dim=(1, 2)).squeeze(0).cpu().numpy().astype(np.float32)
def export(args: argparse.Namespace) -> None:
import torch
from pino.pimt_model import FragranceTrajectoryDataset, PhysicsInformedMixtureTransformer
protocol_path = Path(args.protocol)
protocol = json.loads(protocol_path.read_text())
dataset, checkpoint = Path(protocol["dataset"]["path"]), Path(protocol["checkpoint"]["path"])
if sha256(dataset) != protocol["dataset"]["sha256"] or sha256(checkpoint) != protocol["checkpoint"]["sha256"]:
raise SystemExit("frozen dataset or checkpoint hash mismatch")
manifest = json.loads(Path(protocol["manifest"]).read_text())
needed = set()
for split in manifest:
for key in ("train_records", "test_records"):
with Path(split[key]).open() as handle:
needed.update(json.loads(line)["source_index"] for line in handle if line.strip())
state = torch.load(checkpoint, map_location="cpu", weights_only=False)["model_state_dict"]
hidden = state["input_proj.weight"].shape[0]
layers = len({key.split(".")[2] for key in state if key.startswith("encoder.layers.")})
model = PhysicsInformedMixtureTransformer(embedding_dim=151, state_dim=2, hidden_dim=hidden, num_heads=4, num_layers=layers)
model.load_state_dict(state)
model.eval()
embeddings: dict[int, np.ndarray] = {}
with dataset.open(encoding="utf-8") as source:
for index, line in enumerate(source):
if index not in needed:
continue
row = json.loads(line)
ds = FragranceTrajectoryDataset(data_path=None, records=[row], state_dim=2, use_embedding_fallback=True, label_noise=False)
embeddings[index] = pooled_embedding(model, ds[0], torch)
if len(embeddings) % 100 == 0:
print(f"exported {len(embeddings)}/{len(needed)} unique rows", flush=True)
if embeddings.keys() != needed:
raise SystemExit(f"missing {len(needed - embeddings.keys())} source rows")
for split in manifest:
for partition in ("train", "test"):
record_path = Path(split[f"{partition}_records"])
indices = [json.loads(line)["source_index"] for line in record_path.open() if line.strip()]
np.save(split[f"learned_{partition}"], np.stack([embeddings[i] for i in indices]))
protocol["status"] = "embeddings_exported_benchmark_not_run"
protocol["embedding_export"] = {"pooling": "unmasked mean over time and active ingredient tokens", "dimension": hidden,
"unique_records": len(embeddings)}
protocol_path.write_text(json.dumps(protocol, indent=2) + "\n")
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
prep = sub.add_parser("prepare")
prep.add_argument("--dataset", required=True); prep.add_argument("--checkpoint", required=True); prep.add_argument("--output-dir", required=True)
prep.add_argument("--seeds", type=int, nargs="+", default=[1729, 2718, 3141]); prep.add_argument("--train-ratio", type=float, default=.85)
prep.add_argument("--minimum-per-genre", type=int, default=5); prep.set_defaults(func=prepare)
exp = sub.add_parser("export"); exp.add_argument("--protocol", required=True); exp.set_defaults(func=export)
args = parser.parse_args(); args.func(args)
if __name__ == "__main__":
main()
|