Spaces:
Sleeping
Add tight-pair bond ultrametricity scores
Browse filesFor each tight pair (a,b) β mutual nearest neighbors or the k smallest-D pairs β
average the Rammal index u over triples where (a,b) is the shortest edge; the
bond score 1-<u> separates the ultrametric backbone (high) from specific
contacts (low). Compared against a null computed the same way on a distance
matrix with shuffled off-diagonals; low-score pairs are expected to match the
most-negative D-C residual pairs, and bimodality marks the backbone-vs-contact
split.
tight_pair_scores returns the pair indices, per-bond scores, and null so they
can be cross-referenced against the R eigenvectors. Persisted to the .npz/summary
and surfaced in the dashboard with live method (MNN / smallest-D) and k controls,
a score-vs-null histogram, and a flagged low-score table tagged with residual
overlap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@@ -23,7 +23,7 @@ from scipy.cluster.hierarchy import dendrogram, linkage, cophenet
|
|
| 23 |
from scipy.spatial.distance import squareform
|
| 24 |
|
| 25 |
from transformer_analysis.ultrametricity import (
|
| 26 |
-
to_distance, synthetic_ultrametric, null_distance_samples)
|
| 27 |
|
| 28 |
METRICS = {"Frobenius cosine": "frob_cosine",
|
| 29 |
"Jensen-Shannon": "hist_jensen_shannon"}
|
|
@@ -59,6 +59,13 @@ def _null(d_head: int, d_model: int, metric: str):
|
|
| 59 |
return null_distance_samples(d_head, d_model, metric)
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def _grid(values, keys, n_layers, n_heads):
|
| 63 |
g = np.full((n_layers, n_heads), np.nan)
|
| 64 |
for v, (l, h) in zip(values, keys):
|
|
@@ -403,6 +410,51 @@ def render(exp_dir: Path):
|
|
| 403 |
"energy in a few eigenvalues β a handful of global factors on top of "
|
| 404 |
"the tree; high Gini/kurtosis β sparse specific pairs; otherwise noise.")
|
| 405 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
# ββ Flat geometry alternative: classical MDS ββ
|
| 407 |
st.subheader("Euclidean alternative (classical MDS)")
|
| 408 |
em = m.get("embedding")
|
|
|
|
| 23 |
from scipy.spatial.distance import squareform
|
| 24 |
|
| 25 |
from transformer_analysis.ultrametricity import (
|
| 26 |
+
to_distance, synthetic_ultrametric, null_distance_samples, tight_pair_scores)
|
| 27 |
|
| 28 |
METRICS = {"Frobenius cosine": "frob_cosine",
|
| 29 |
"Jensen-Shannon": "hist_jensen_shannon"}
|
|
|
|
| 59 |
return null_distance_samples(d_head, d_model, metric)
|
| 60 |
|
| 61 |
|
| 62 |
+
@st.cache_data
|
| 63 |
+
def _tight(exp_dir: str, run_key: str, metric: str, method: str, k: int):
|
| 64 |
+
_, U, D, _ = _load(exp_dir, run_key, metric)
|
| 65 |
+
keys = [tuple(x) for x in U["head_index"]]
|
| 66 |
+
return tight_pair_scores(D, keys, method=method, k_smallest=k)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
def _grid(values, keys, n_layers, n_heads):
|
| 70 |
g = np.full((n_layers, n_heads), np.nan)
|
| 71 |
for v, (l, h) in zip(values, keys):
|
|
|
|
| 410 |
"energy in a few eigenvalues β a handful of global factors on top of "
|
| 411 |
"the tree; high Gini/kurtosis β sparse specific pairs; otherwise noise.")
|
| 412 |
|
| 413 |
+
# ββ Tight-pair bond scores (backbone vs contact) ββ
|
| 414 |
+
st.subheader("Tight-pair bond scores")
|
| 415 |
+
tcol = st.columns(2)
|
| 416 |
+
method_label = tcol[0].selectbox("Tight pairs", ["smallest-D", "mutual NN"],
|
| 417 |
+
key="tight_method")
|
| 418 |
+
method = "smallest" if method_label == "smallest-D" else "mnn"
|
| 419 |
+
kk = tcol[1].slider("k (smallest-D)", 20, 500, 200, 20, key="tight_k",
|
| 420 |
+
disabled=(method != "smallest"))
|
| 421 |
+
tg = _tight(str(exp_dir), run_key, metric, method, kk)
|
| 422 |
+
sc = tg["scores"][np.isfinite(tg["scores"])]
|
| 423 |
+
nl = tg["null_scores"][np.isfinite(tg["null_scores"])]
|
| 424 |
+
if len(sc):
|
| 425 |
+
bins = np.linspace(0, 1, 41)
|
| 426 |
+
cen = 0.5 * (bins[:-1] + bins[1:])
|
| 427 |
+
figt = go.Figure()
|
| 428 |
+
for vals, name, color, fc in (
|
| 429 |
+
(sc, f"tight pairs ({tg['method']})", "#19D3F3", "rgba(25,211,243,0.40)"),
|
| 430 |
+
(nl, "null (shuffled D)", "#FF6692", "rgba(255,102,146,0.35)")):
|
| 431 |
+
if len(vals):
|
| 432 |
+
h, _ = np.histogram(vals, bins=bins, density=True)
|
| 433 |
+
figt.add_scatter(x=cen, y=h, name=name, line_shape="hv",
|
| 434 |
+
fill="tozeroy", line=dict(color=color), fillcolor=fc)
|
| 435 |
+
figt.update_layout(title="bond score 1ββ¨uβ© over tight pairs", height=340,
|
| 436 |
+
xaxis_title="1ββ¨uβ© (high = backbone, low = contact)",
|
| 437 |
+
yaxis_title="density", legend=dict(x=0.01, y=0.99))
|
| 438 |
+
st.plotly_chart(figt, use_container_width=True)
|
| 439 |
+
st.caption("Per tight pair (a,b): 1ββ¨uβ© averaged over triples where (a,b) is the "
|
| 440 |
+
"shortest edge. High β clean ultrametric sibling (backbone); low β a "
|
| 441 |
+
"close pair whose triples break ultrametricity (a specific contact). "
|
| 442 |
+
"Bimodality is the backbone-vs-contact split. Null = same procedure on "
|
| 443 |
+
"a distance matrix with shuffled off-diagonals.")
|
| 444 |
+
|
| 445 |
+
low = pd.DataFrame(tg.get("low_flagged", []))
|
| 446 |
+
if len(low):
|
| 447 |
+
respairs = {frozenset((rp_["i"], rp_["j"]))
|
| 448 |
+
for rp_ in m.get("residual_pairs", []) if "i" in rp_}
|
| 449 |
+
low["in_R_residual_top"] = [frozenset((r.i, r.j)) in respairs
|
| 450 |
+
for r in low.itertuples()]
|
| 451 |
+
st.caption(f"Lowest-score tight pairs (median {tg['score_median']:.3f} vs "
|
| 452 |
+
f"null {tg['null_median']:.3f}, {tg['n_pairs']} pairs) β these "
|
| 453 |
+
"should match the most-negative R residual pairs.")
|
| 454 |
+
st.dataframe(low[["layer_1", "head_1", "layer_2", "head_2", "delta_layer",
|
| 455 |
+
"distance", "score", "in_R_residual_top"]],
|
| 456 |
+
use_container_width=True, height=320)
|
| 457 |
+
|
| 458 |
# ββ Flat geometry alternative: classical MDS ββ
|
| 459 |
st.subheader("Euclidean alternative (classical MDS)")
|
| 460 |
em = m.get("embedding")
|
|
@@ -28,6 +28,7 @@ from transformer_analysis.ultrametricity import (
|
|
| 28 |
residual_decomposition,
|
| 29 |
embedding_analysis,
|
| 30 |
linkage_comparison,
|
|
|
|
| 31 |
)
|
| 32 |
|
| 33 |
|
|
@@ -107,6 +108,8 @@ def main():
|
|
| 107 |
quad = quadruple_statistics(D, sample_cap=args.quad_sample_cap,
|
| 108 |
seed=args.seed, tol=args.tol)
|
| 109 |
lk = linkage_comparison(D)
|
|
|
|
|
|
|
| 110 |
|
| 111 |
dense = D.shape[0] <= args.max_dense
|
| 112 |
resid = residual_decomposition(D, clust["linkage"]) if dense else None
|
|
@@ -131,6 +134,9 @@ def main():
|
|
| 131 |
mds_eigs=emb["eigenvalues"] if emb else np.array([]),
|
| 132 |
mds_top_eigvecs=emb["top_eigvecs"] if emb else np.zeros((0, 0)),
|
| 133 |
mds_top_eigs=emb["top_eigs"] if emb else np.array([]),
|
|
|
|
|
|
|
|
|
|
| 134 |
)
|
| 135 |
|
| 136 |
summary["metrics"][metric] = {
|
|
@@ -155,6 +161,8 @@ def main():
|
|
| 155 |
"gini", "kurtosis", "verdict")},
|
| 156 |
"embedding": emb and {k: emb[k] for k in
|
| 157 |
("neg_energy_frac", "var_frac_2d", "var_frac_3d", "eff_dim")},
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
log = {
|
| 160 |
f"{metric}.mean_u": stats["mean_u"],
|
|
@@ -163,6 +171,7 @@ def main():
|
|
| 163 |
f"{metric}.triangle_violation_frac": stats["triangle_violation_frac"],
|
| 164 |
f"{metric}.mean_u4": quad["mean_u4"],
|
| 165 |
f"{metric}.frac_additive": quad["frac_additive"],
|
|
|
|
| 166 |
}
|
| 167 |
if resid:
|
| 168 |
log[f"{metric}.res_energy_frac"] = resid["res_energy_frac"]
|
|
|
|
| 28 |
residual_decomposition,
|
| 29 |
embedding_analysis,
|
| 30 |
linkage_comparison,
|
| 31 |
+
tight_pair_scores,
|
| 32 |
)
|
| 33 |
|
| 34 |
|
|
|
|
| 108 |
quad = quadruple_statistics(D, sample_cap=args.quad_sample_cap,
|
| 109 |
seed=args.seed, tol=args.tol)
|
| 110 |
lk = linkage_comparison(D)
|
| 111 |
+
tight = tight_pair_scores(D, keys, method="smallest", k_smallest=300,
|
| 112 |
+
seed=args.seed)
|
| 113 |
|
| 114 |
dense = D.shape[0] <= args.max_dense
|
| 115 |
resid = residual_decomposition(D, clust["linkage"]) if dense else None
|
|
|
|
| 134 |
mds_eigs=emb["eigenvalues"] if emb else np.array([]),
|
| 135 |
mds_top_eigvecs=emb["top_eigvecs"] if emb else np.zeros((0, 0)),
|
| 136 |
mds_top_eigs=emb["top_eigs"] if emb else np.array([]),
|
| 137 |
+
tight_scores=tight["scores"],
|
| 138 |
+
tight_null_scores=tight["null_scores"],
|
| 139 |
+
tight_pair_idx=tight["pair_idx"],
|
| 140 |
)
|
| 141 |
|
| 142 |
summary["metrics"][metric] = {
|
|
|
|
| 161 |
"gini", "kurtosis", "verdict")},
|
| 162 |
"embedding": emb and {k: emb[k] for k in
|
| 163 |
("neg_energy_frac", "var_frac_2d", "var_frac_3d", "eff_dim")},
|
| 164 |
+
"tight": {k: tight[k] for k in
|
| 165 |
+
("method", "n_pairs", "score_median", "null_median", "low_flagged")},
|
| 166 |
}
|
| 167 |
log = {
|
| 168 |
f"{metric}.mean_u": stats["mean_u"],
|
|
|
|
| 171 |
f"{metric}.triangle_violation_frac": stats["triangle_violation_frac"],
|
| 172 |
f"{metric}.mean_u4": quad["mean_u4"],
|
| 173 |
f"{metric}.frac_additive": quad["frac_additive"],
|
| 174 |
+
f"{metric}.tight_score_median": tight["score_median"],
|
| 175 |
}
|
| 176 |
if resid:
|
| 177 |
log[f"{metric}.res_energy_frac"] = resid["res_energy_frac"]
|
|
@@ -229,6 +229,91 @@ def cluster_analysis(D, keys, top_k=50):
|
|
| 229 |
}
|
| 230 |
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
# ββ Is it the wrong tree, or extra structure on top? ββββββββββββββββββ
|
| 233 |
|
| 234 |
def _gini(x):
|
|
|
|
| 229 |
}
|
| 230 |
|
| 231 |
|
| 232 |
+
# ββ Tight-pair bond scores (backbone vs contact) ββββββββββββββββββββββ
|
| 233 |
+
|
| 234 |
+
def _candidate_pairs(M, method, k_smallest):
|
| 235 |
+
n = M.shape[0]
|
| 236 |
+
if method == "smallest":
|
| 237 |
+
iu = np.triu_indices(n, k=1)
|
| 238 |
+
order = np.argsort(M[iu])[:k_smallest]
|
| 239 |
+
return [(int(iu[0][o]), int(iu[1][o])) for o in order]
|
| 240 |
+
# mutual nearest neighbors
|
| 241 |
+
nn = (M + np.eye(n) * (M.max() + 1.0)).argmin(axis=1)
|
| 242 |
+
return [(i, int(nn[i])) for i in range(n) if nn[nn[i]] == i and i < nn[i]]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _bond_scores(M, pairs):
|
| 246 |
+
scores = np.full(len(pairs), np.nan)
|
| 247 |
+
counts = np.zeros(len(pairs), dtype=int)
|
| 248 |
+
for idx, (a, b) in enumerate(pairs):
|
| 249 |
+
dab = M[a, b]
|
| 250 |
+
dac, dbc = M[a], M[b]
|
| 251 |
+
mask = (dac >= dab) & (dbc >= dab) # triples where (a,b) is shortest edge
|
| 252 |
+
mask[a] = mask[b] = False
|
| 253 |
+
if mask.any():
|
| 254 |
+
d2 = np.minimum(dac, dbc)[mask]
|
| 255 |
+
d3 = np.maximum(dac, dbc)[mask]
|
| 256 |
+
denom = d3 - dab
|
| 257 |
+
u = np.where(denom > 0, (d3 - d2) / denom, 0.0)
|
| 258 |
+
scores[idx] = 1.0 - float(u.mean())
|
| 259 |
+
counts[idx] = int(mask.sum())
|
| 260 |
+
return scores, counts
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def tight_pair_scores(D, keys, method="mnn", k_smallest=300, seed=0, top_flag=25):
|
| 264 |
+
"""Per-bond ultrametricity score for tight (close) head pairs.
|
| 265 |
+
|
| 266 |
+
For each tight pair (a,b) β mutual nearest neighbors, or the k smallest-D
|
| 267 |
+
pairs β average the Rammal index u over all triples (a,b,c) in which (a,b)
|
| 268 |
+
is the shortest edge; the bond score is 1 - <u>. High = a clean ultrametric
|
| 269 |
+
sibling (backbone); low = a close pair whose triples break ultrametricity
|
| 270 |
+
(a specific contact, matching the most-negative D-C residuals). A null is
|
| 271 |
+
computed the same way on a distance matrix with shuffled off-diagonals.
|
| 272 |
+
Bimodality in the score distribution is the backbone-vs-contact split.
|
| 273 |
+
"""
|
| 274 |
+
n = D.shape[0]
|
| 275 |
+
layers = np.array([k[0] for k in keys])
|
| 276 |
+
heads = np.array([k[1] for k in keys])
|
| 277 |
+
|
| 278 |
+
pairs = _candidate_pairs(D, method, k_smallest)
|
| 279 |
+
scores, counts = _bond_scores(D, pairs)
|
| 280 |
+
|
| 281 |
+
rng = np.random.default_rng(seed)
|
| 282 |
+
iu = np.triu_indices(n, k=1)
|
| 283 |
+
vals = D[iu].copy()
|
| 284 |
+
rng.shuffle(vals)
|
| 285 |
+
Dn = np.zeros_like(D)
|
| 286 |
+
Dn[iu] = vals
|
| 287 |
+
Dn[(iu[1], iu[0])] = vals
|
| 288 |
+
null_scores, _ = _bond_scores(Dn, _candidate_pairs(Dn, method, k_smallest))
|
| 289 |
+
|
| 290 |
+
recs = []
|
| 291 |
+
for (a, b), s, c in zip(pairs, scores, counts):
|
| 292 |
+
recs.append({
|
| 293 |
+
"i": int(a), "j": int(b),
|
| 294 |
+
"layer_1": int(layers[a]), "head_1": int(heads[a]),
|
| 295 |
+
"layer_2": int(layers[b]), "head_2": int(heads[b]),
|
| 296 |
+
"delta_layer": int(abs(layers[a] - layers[b])),
|
| 297 |
+
"distance": float(D[a, b]),
|
| 298 |
+
"score": float(s) if np.isfinite(s) else None,
|
| 299 |
+
"n_triples": int(c),
|
| 300 |
+
})
|
| 301 |
+
valid = [r for r in recs if r["score"] is not None]
|
| 302 |
+
low_flagged = sorted(valid, key=lambda r: r["score"])[:top_flag]
|
| 303 |
+
|
| 304 |
+
return {
|
| 305 |
+
"method": method,
|
| 306 |
+
"n_pairs": len(pairs),
|
| 307 |
+
"pair_idx": np.array([[r["i"], r["j"]] for r in recs], dtype=int)
|
| 308 |
+
if recs else np.zeros((0, 2), dtype=int),
|
| 309 |
+
"scores": scores,
|
| 310 |
+
"null_scores": null_scores,
|
| 311 |
+
"score_median": float(np.nanmedian(scores)) if len(scores) else float("nan"),
|
| 312 |
+
"null_median": float(np.nanmedian(null_scores)) if len(null_scores) else float("nan"),
|
| 313 |
+
"low_flagged": low_flagged,
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
# ββ Is it the wrong tree, or extra structure on top? ββββββββββββββββββ
|
| 318 |
|
| 319 |
def _gini(x):
|