Spaces:
Running
Running
Commit ·
5cebbaf
1
Parent(s): 169d199
Add bucket metrics and contrast delta analysis
Browse files- README.md +6 -1
- qwip_atlas/analyze_layers.py +263 -2
- qwip_atlas/build_atlas.py +25 -0
- tests/test_analyze_layers_metrics.py +69 -0
README.md
CHANGED
|
@@ -32,11 +32,16 @@ prompts/ # your JSONL corpora
|
|
| 32 |
|
| 33 |
1. Activation census extraction (`mlp`, `gate`, `up` by default)
|
| 34 |
2. Finalize `.npz` chunks
|
| 35 |
-
3. Per-layer analysis: taxonomy, heatmap, F-stat separation, co-activation, code cross-reference
|
| 36 |
4. OV-circuit spectral analysis: SVD over `W_V @ W_O` per head
|
| 37 |
5. Compliance-behaviour axis extraction (positive vs negative corpus)
|
| 38 |
6. Atlas build + SQLite index
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
## Dev-mode setup
|
| 41 |
|
| 42 |
Inside the HF Space terminal:
|
|
|
|
| 32 |
|
| 33 |
1. Activation census extraction (`mlp`, `gate`, `up` by default)
|
| 34 |
2. Finalize `.npz` chunks
|
| 35 |
+
3. Per-layer analysis: taxonomy, heatmap, F-stat separation, bucket quality, contrast deltas, co-activation, code cross-reference
|
| 36 |
4. OV-circuit spectral analysis: SVD over `W_V @ W_O` per head
|
| 37 |
5. Compliance-behaviour axis extraction (positive vs negative corpus)
|
| 38 |
6. Atlas build + SQLite index
|
| 39 |
|
| 40 |
+
Per-component analysis now writes:
|
| 41 |
+
|
| 42 |
+
- `l<N>_<component>_bucket_metrics.json`: dominant bucket, entropy, eta-squared, and bucket-quality score per feature.
|
| 43 |
+
- `l<N>_<component>_contrast_delta.json`: feature deltas for explicit `contrast_pair_id` pairs when the corpus provides exactly two rows per pair.
|
| 44 |
+
|
| 45 |
## Dev-mode setup
|
| 46 |
|
| 47 |
Inside the HF Space terminal:
|
qwip_atlas/analyze_layers.py
CHANGED
|
@@ -423,6 +423,183 @@ def compute_separation_scores(A: np.ndarray, buckets: list[str]) -> np.ndarray:
|
|
| 423 |
return fstat.astype(np.float32)
|
| 424 |
|
| 425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
def compute_silhouette_scores(A: np.ndarray, buckets: list[str]) -> np.ndarray:
|
| 427 |
"""
|
| 428 |
Per-feature silhouette score (kept for backward comparison).
|
|
@@ -461,6 +638,44 @@ def print_top_separators(scores: np.ndarray, n: int = 20,
|
|
| 461 |
print(f" {idx:>8} {scores[idx]:>10.4f} {cls:<30} {act_rate:>9.3f}")
|
| 462 |
|
| 463 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
# ---------------------------------------------------------------------------
|
| 465 |
# Phase 6: Cross-reference with capability damage
|
| 466 |
# ---------------------------------------------------------------------------
|
|
@@ -655,11 +870,27 @@ def analyze_one(name: str, A: np.ndarray, records: list[dict],
|
|
| 655 |
np.save(out / f"{prefix}_separation_scores.npy", scores)
|
| 656 |
print_top_separators(scores, n=20, classifications=classifications, A=A)
|
| 657 |
|
| 658 |
-
# Phase 6:
|
| 659 |
-
print(f"\n--- {name}: Phase 6:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
code_analysis = analyze_code_neurons(A, buckets, scores, code_bucket=code_bucket)
|
| 661 |
write_json(out / f"{prefix}_code_analysis.json", code_analysis)
|
| 662 |
|
|
|
|
|
|
|
|
|
|
| 663 |
# Per-component summary stats
|
| 664 |
from collections import Counter
|
| 665 |
def _norm_cls(c):
|
|
@@ -672,6 +903,14 @@ def analyze_one(name: str, A: np.ndarray, records: list[dict],
|
|
| 672 |
"taxonomy": dict(tax_counts),
|
| 673 |
"top_sep_score": float(np.max(scores)),
|
| 674 |
"mean_sep_score": float(np.mean(scores)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
"n_active": int((A > ACTIVATION_THRESHOLD).any(axis=1).sum()),
|
| 676 |
"mean": float(A.mean()),
|
| 677 |
"std": float(A.std()),
|
|
@@ -719,6 +958,20 @@ def print_comparison(summaries: list[dict], layer: int):
|
|
| 719 |
row(" best feature", lambda s: f"{s['top_sep_score']:.4f}")
|
| 720 |
row(" mean (all feats)",lambda s: f"{s['mean_sep_score']:.4f}")
|
| 721 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
|
| 723 |
print(" Code cross-reference (top 30 code-preferring):")
|
| 724 |
row(" entangled", lambda s: s["code_entangled"])
|
|
@@ -733,6 +986,13 @@ def print_comparison(summaries: list[dict], layer: int):
|
|
| 733 |
f"(n={best_specific['taxonomy'].get('specific', 0)})")
|
| 734 |
print(f" Highest single-feature separation: {best_sep['name']} "
|
| 735 |
f"(score={best_sep['top_sep_score']:.4f})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
|
| 737 |
|
| 738 |
def main():
|
|
@@ -846,6 +1106,7 @@ def main():
|
|
| 846 |
prefix = f"l{args.layer}_{s['name']}"
|
| 847 |
for suffix in ["neuron_taxonomy.json", "census_heatmap.png",
|
| 848 |
"coactivation_pairs.json", "separation_scores.npy",
|
|
|
|
| 849 |
"code_analysis.json"]:
|
| 850 |
p = out / f"{prefix}_{suffix}"
|
| 851 |
if p.exists():
|
|
|
|
| 423 |
return fstat.astype(np.float32)
|
| 424 |
|
| 425 |
|
| 426 |
+
def compute_feature_bucket_metrics(A: np.ndarray, buckets: list[str]) -> list[dict]:
|
| 427 |
+
"""
|
| 428 |
+
Per-feature bucket quality metrics.
|
| 429 |
+
|
| 430 |
+
These make bucket behavior visible instead of hiding it behind a single
|
| 431 |
+
F-statistic:
|
| 432 |
+
- dominant_bucket: bucket with highest mean activation
|
| 433 |
+
- dominance_margin: top bucket mean minus runner-up
|
| 434 |
+
- bucket_entropy: normalized entropy over positive bucket means
|
| 435 |
+
- eta_squared: fraction of activation variance explained by buckets
|
| 436 |
+
- bucket_quality: eta_squared weighted by low entropy
|
| 437 |
+
"""
|
| 438 |
+
bucket_arr = np.asarray(buckets)
|
| 439 |
+
unique = sorted(set(buckets))
|
| 440 |
+
n_features, n_prompts = A.shape
|
| 441 |
+
if n_prompts != len(buckets):
|
| 442 |
+
raise ValueError(f"A has {n_prompts} prompts, but buckets has {len(buckets)} entries")
|
| 443 |
+
|
| 444 |
+
overall_mean = A.mean(axis=1, keepdims=True)
|
| 445 |
+
bucket_means = np.zeros((n_features, len(unique)), dtype=np.float64)
|
| 446 |
+
bucket_counts = np.zeros(len(unique), dtype=np.int64)
|
| 447 |
+
|
| 448 |
+
for j, bucket in enumerate(unique):
|
| 449 |
+
mask = bucket_arr == bucket
|
| 450 |
+
bucket_counts[j] = int(mask.sum())
|
| 451 |
+
if bucket_counts[j]:
|
| 452 |
+
bucket_means[:, j] = A[:, mask].mean(axis=1)
|
| 453 |
+
|
| 454 |
+
between_var = np.zeros(n_features, dtype=np.float64)
|
| 455 |
+
for j in range(len(unique)):
|
| 456 |
+
if bucket_counts[j] < 1:
|
| 457 |
+
continue
|
| 458 |
+
delta = bucket_means[:, j] - overall_mean.squeeze(-1)
|
| 459 |
+
between_var += bucket_counts[j] * delta**2
|
| 460 |
+
|
| 461 |
+
centered = A - overall_mean
|
| 462 |
+
total_var = np.sum(centered**2, axis=1)
|
| 463 |
+
eta_squared = np.divide(
|
| 464 |
+
between_var,
|
| 465 |
+
total_var,
|
| 466 |
+
out=np.zeros_like(between_var),
|
| 467 |
+
where=total_var > 1e-10,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
# Entropy over positive bucket means. If all bucket means are non-positive,
|
| 471 |
+
# fall back to absolute means so inhibitory/signed features still get shape.
|
| 472 |
+
positive = np.clip(bucket_means, 0, None)
|
| 473 |
+
fallback = np.abs(bucket_means)
|
| 474 |
+
weights = np.where(positive.sum(axis=1, keepdims=True) > 1e-10, positive, fallback)
|
| 475 |
+
weight_sums = weights.sum(axis=1, keepdims=True)
|
| 476 |
+
probs = np.divide(weights, weight_sums, out=np.zeros_like(weights), where=weight_sums > 1e-10)
|
| 477 |
+
log_probs = np.zeros_like(probs)
|
| 478 |
+
nz = probs > 0
|
| 479 |
+
log_probs[nz] = np.log(probs[nz])
|
| 480 |
+
raw_entropy = -(probs * log_probs).sum(axis=1)
|
| 481 |
+
norm = np.log(len(unique)) if len(unique) > 1 else 1.0
|
| 482 |
+
bucket_entropy = raw_entropy / norm if norm > 0 else raw_entropy
|
| 483 |
+
|
| 484 |
+
top_idx = np.argmax(bucket_means, axis=1)
|
| 485 |
+
order_desc = np.argsort(-bucket_means, axis=1)
|
| 486 |
+
second_idx = order_desc[:, 1] if len(unique) > 1 else top_idx
|
| 487 |
+
|
| 488 |
+
metrics = []
|
| 489 |
+
for i in range(n_features):
|
| 490 |
+
top = int(top_idx[i])
|
| 491 |
+
second = int(second_idx[i])
|
| 492 |
+
margin = float(bucket_means[i, top] - bucket_means[i, second]) if len(unique) > 1 else 0.0
|
| 493 |
+
entropy = float(bucket_entropy[i])
|
| 494 |
+
eta = float(eta_squared[i])
|
| 495 |
+
metrics.append({
|
| 496 |
+
"feature": i,
|
| 497 |
+
"dominant_bucket": unique[top],
|
| 498 |
+
"dominant_bucket_mean": float(bucket_means[i, top]),
|
| 499 |
+
"runner_up_bucket": unique[second],
|
| 500 |
+
"runner_up_bucket_mean": float(bucket_means[i, second]),
|
| 501 |
+
"dominance_margin": margin,
|
| 502 |
+
"bucket_entropy": entropy,
|
| 503 |
+
"eta_squared": eta,
|
| 504 |
+
"bucket_quality": float(eta * (1.0 - entropy)),
|
| 505 |
+
"bucket_means": {bucket: float(bucket_means[i, j]) for j, bucket in enumerate(unique)},
|
| 506 |
+
})
|
| 507 |
+
return metrics
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def compute_contrast_delta_scores(A: np.ndarray, records: list[dict]) -> dict:
|
| 511 |
+
"""
|
| 512 |
+
Rank features by consistent activation change across explicit contrast pairs.
|
| 513 |
+
|
| 514 |
+
Pairs are not inferred from prompt text. The corpus must provide
|
| 515 |
+
``contrast_pair_id`` metadata; exactly two rows per pair are required.
|
| 516 |
+
Within each pair, rows are ordered by bucket name and the delta is:
|
| 517 |
+
|
| 518 |
+
activation(second bucket lexically) - activation(first bucket lexically)
|
| 519 |
+
|
| 520 |
+
The report includes the direction labels so downstream readers can see what
|
| 521 |
+
a positive delta means.
|
| 522 |
+
"""
|
| 523 |
+
from collections import defaultdict
|
| 524 |
+
|
| 525 |
+
by_pair: dict[str, list[int]] = defaultdict(list)
|
| 526 |
+
for i, record in enumerate(records):
|
| 527 |
+
pair_id = record.get("contrast_pair_id")
|
| 528 |
+
if pair_id:
|
| 529 |
+
by_pair[str(pair_id)].append(i)
|
| 530 |
+
|
| 531 |
+
pair_rows = []
|
| 532 |
+
skipped = []
|
| 533 |
+
directions = []
|
| 534 |
+
for pair_id in sorted(by_pair):
|
| 535 |
+
idxs = by_pair[pair_id]
|
| 536 |
+
if len(idxs) != 2:
|
| 537 |
+
skipped.append(pair_id)
|
| 538 |
+
continue
|
| 539 |
+
ordered = sorted(idxs, key=lambda idx: (
|
| 540 |
+
str(records[idx].get("bucket", "")),
|
| 541 |
+
str(records[idx].get("id", idx)),
|
| 542 |
+
))
|
| 543 |
+
left, right = ordered
|
| 544 |
+
left_bucket = str(records[left].get("bucket", ""))
|
| 545 |
+
right_bucket = str(records[right].get("bucket", ""))
|
| 546 |
+
directions.append((left_bucket, right_bucket))
|
| 547 |
+
pair_rows.append({
|
| 548 |
+
"pair_id": pair_id,
|
| 549 |
+
"left_index": int(left),
|
| 550 |
+
"right_index": int(right),
|
| 551 |
+
"left_bucket": left_bucket,
|
| 552 |
+
"right_bucket": right_bucket,
|
| 553 |
+
"left_id": records[left].get("id"),
|
| 554 |
+
"right_id": records[right].get("id"),
|
| 555 |
+
})
|
| 556 |
+
|
| 557 |
+
if not pair_rows:
|
| 558 |
+
return {
|
| 559 |
+
"n_pairs": 0,
|
| 560 |
+
"direction": "lexical_bucket_order",
|
| 561 |
+
"direction_counts": {},
|
| 562 |
+
"skipped_pair_ids": skipped,
|
| 563 |
+
"pairs": [],
|
| 564 |
+
"features": [],
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
left_idx = np.array([p["left_index"] for p in pair_rows], dtype=np.int64)
|
| 568 |
+
right_idx = np.array([p["right_index"] for p in pair_rows], dtype=np.int64)
|
| 569 |
+
deltas = A[:, right_idx] - A[:, left_idx]
|
| 570 |
+
mean_delta = deltas.mean(axis=1)
|
| 571 |
+
abs_mean_delta = np.abs(mean_delta)
|
| 572 |
+
std_delta = deltas.std(axis=1)
|
| 573 |
+
positive_fraction = (deltas > 0).mean(axis=1)
|
| 574 |
+
consistency = np.maximum(positive_fraction, 1.0 - positive_fraction)
|
| 575 |
+
effect_score = abs_mean_delta * consistency
|
| 576 |
+
|
| 577 |
+
from collections import Counter
|
| 578 |
+
direction_counts = Counter(f"{left}->{right}" for left, right in directions)
|
| 579 |
+
|
| 580 |
+
order = np.argsort(effect_score)[::-1]
|
| 581 |
+
features = []
|
| 582 |
+
for idx in order:
|
| 583 |
+
features.append({
|
| 584 |
+
"feature": int(idx),
|
| 585 |
+
"mean_delta": float(mean_delta[idx]),
|
| 586 |
+
"abs_mean_delta": float(abs_mean_delta[idx]),
|
| 587 |
+
"std_delta": float(std_delta[idx]),
|
| 588 |
+
"positive_fraction": float(positive_fraction[idx]),
|
| 589 |
+
"consistency": float(consistency[idx]),
|
| 590 |
+
"effect_score": float(effect_score[idx]),
|
| 591 |
+
})
|
| 592 |
+
|
| 593 |
+
return {
|
| 594 |
+
"n_pairs": len(pair_rows),
|
| 595 |
+
"direction": "lexical_bucket_order",
|
| 596 |
+
"direction_counts": dict(direction_counts),
|
| 597 |
+
"skipped_pair_ids": skipped,
|
| 598 |
+
"pairs": pair_rows,
|
| 599 |
+
"features": features,
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
|
| 603 |
def compute_silhouette_scores(A: np.ndarray, buckets: list[str]) -> np.ndarray:
|
| 604 |
"""
|
| 605 |
Per-feature silhouette score (kept for backward comparison).
|
|
|
|
| 638 |
print(f" {idx:>8} {scores[idx]:>10.4f} {cls:<30} {act_rate:>9.3f}")
|
| 639 |
|
| 640 |
|
| 641 |
+
def print_top_bucket_metrics(metrics: list[dict], n: int = 20) -> None:
|
| 642 |
+
top = sorted(metrics, key=lambda row: row["bucket_quality"], reverse=True)[:n]
|
| 643 |
+
print(f"\nTop {n} bucket-clean features (eta^2 * low entropy):")
|
| 644 |
+
print(
|
| 645 |
+
f" {'feature':>8} {'quality':>10} {'eta^2':>8} "
|
| 646 |
+
f"{'entropy':>8} {'dominant':<24} {'margin':>9}"
|
| 647 |
+
)
|
| 648 |
+
for row in top:
|
| 649 |
+
print(
|
| 650 |
+
f" {row['feature']:>8} {row['bucket_quality']:>10.4f} "
|
| 651 |
+
f"{row['eta_squared']:>8.4f} {row['bucket_entropy']:>8.4f} "
|
| 652 |
+
f"{row['dominant_bucket']:<24} {row['dominance_margin']:>9.4f}"
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
def print_contrast_delta_summary(report: dict, n: int = 20) -> None:
|
| 657 |
+
print(f"\nContrast pairs: {report['n_pairs']} usable")
|
| 658 |
+
if report["skipped_pair_ids"]:
|
| 659 |
+
print(f" skipped pair ids (not exactly 2 rows): {report['skipped_pair_ids'][:10]}")
|
| 660 |
+
if report["direction_counts"]:
|
| 661 |
+
print(" positive delta direction counts:")
|
| 662 |
+
for direction, count in sorted(report["direction_counts"].items()):
|
| 663 |
+
print(f" {direction}: {count}")
|
| 664 |
+
if not report["features"]:
|
| 665 |
+
return
|
| 666 |
+
print(f"\nTop {n} contrast-shifting features:")
|
| 667 |
+
print(
|
| 668 |
+
f" {'feature':>8} {'effect':>10} {'mean_delta':>11} "
|
| 669 |
+
f"{'consistency':>11} {'pos_frac':>9}"
|
| 670 |
+
)
|
| 671 |
+
for row in report["features"][:n]:
|
| 672 |
+
print(
|
| 673 |
+
f" {row['feature']:>8} {row['effect_score']:>10.4f} "
|
| 674 |
+
f"{row['mean_delta']:>11.4f} {row['consistency']:>11.4f} "
|
| 675 |
+
f"{row['positive_fraction']:>9.4f}"
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
|
| 679 |
# ---------------------------------------------------------------------------
|
| 680 |
# Phase 6: Cross-reference with capability damage
|
| 681 |
# ---------------------------------------------------------------------------
|
|
|
|
| 870 |
np.save(out / f"{prefix}_separation_scores.npy", scores)
|
| 871 |
print_top_separators(scores, n=20, classifications=classifications, A=A)
|
| 872 |
|
| 873 |
+
# Phase 6: Bucket quality metrics
|
| 874 |
+
print(f"\n--- {name}: Phase 6: Bucket quality metrics ---")
|
| 875 |
+
bucket_metrics = compute_feature_bucket_metrics(A, buckets)
|
| 876 |
+
write_json(out / f"{prefix}_bucket_metrics.json", bucket_metrics)
|
| 877 |
+
print_top_bucket_metrics(bucket_metrics)
|
| 878 |
+
|
| 879 |
+
# Phase 7: Contrast pair deltas
|
| 880 |
+
print(f"\n--- {name}: Phase 7: Contrast pair deltas ---")
|
| 881 |
+
contrast_report = compute_contrast_delta_scores(A, records)
|
| 882 |
+
if contrast_report["n_pairs"]:
|
| 883 |
+
write_json(out / f"{prefix}_contrast_delta.json", contrast_report)
|
| 884 |
+
print_contrast_delta_summary(contrast_report)
|
| 885 |
+
|
| 886 |
+
# Phase 8: Code cross-reference
|
| 887 |
+
print(f"\n--- {name}: Phase 8: Code feature cross-reference ---")
|
| 888 |
code_analysis = analyze_code_neurons(A, buckets, scores, code_bucket=code_bucket)
|
| 889 |
write_json(out / f"{prefix}_code_analysis.json", code_analysis)
|
| 890 |
|
| 891 |
+
best_bucket = max(bucket_metrics, key=lambda row: row["bucket_quality"]) if bucket_metrics else {}
|
| 892 |
+
best_contrast = contrast_report["features"][0] if contrast_report["features"] else {}
|
| 893 |
+
|
| 894 |
# Per-component summary stats
|
| 895 |
from collections import Counter
|
| 896 |
def _norm_cls(c):
|
|
|
|
| 903 |
"taxonomy": dict(tax_counts),
|
| 904 |
"top_sep_score": float(np.max(scores)),
|
| 905 |
"mean_sep_score": float(np.mean(scores)),
|
| 906 |
+
"top_bucket_quality": float(best_bucket.get("bucket_quality", 0.0)),
|
| 907 |
+
"top_bucket_feature": int(best_bucket.get("feature", 0)) if best_bucket else None,
|
| 908 |
+
"top_bucket": best_bucket.get("dominant_bucket"),
|
| 909 |
+
"bucket_entropy_at_top": float(best_bucket.get("bucket_entropy", 0.0)) if best_bucket else None,
|
| 910 |
+
"eta_squared_at_top": float(best_bucket.get("eta_squared", 0.0)) if best_bucket else None,
|
| 911 |
+
"n_contrast_pairs": int(contrast_report["n_pairs"]),
|
| 912 |
+
"top_contrast_feature": int(best_contrast.get("feature", 0)) if best_contrast else None,
|
| 913 |
+
"top_contrast_effect": float(best_contrast.get("effect_score", 0.0)) if best_contrast else None,
|
| 914 |
"n_active": int((A > ACTIVATION_THRESHOLD).any(axis=1).sum()),
|
| 915 |
"mean": float(A.mean()),
|
| 916 |
"std": float(A.std()),
|
|
|
|
| 958 |
row(" best feature", lambda s: f"{s['top_sep_score']:.4f}")
|
| 959 |
row(" mean (all feats)",lambda s: f"{s['mean_sep_score']:.4f}")
|
| 960 |
|
| 961 |
+
print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
|
| 962 |
+
print(" Bucket quality (eta^2 * low entropy):")
|
| 963 |
+
row(" best quality", lambda s: f"{s.get('top_bucket_quality', 0.0):.4f}")
|
| 964 |
+
row(" best bucket", lambda s: str(s.get("top_bucket") or "-")[:10])
|
| 965 |
+
row(" best feature", lambda s: s.get("top_bucket_feature") if s.get("top_bucket_feature") is not None else "-")
|
| 966 |
+
row(" eta^2 at top", lambda s: f"{s.get('eta_squared_at_top'):.4f}" if s.get("eta_squared_at_top") is not None else "-")
|
| 967 |
+
row(" entropy at top", lambda s: f"{s.get('bucket_entropy_at_top'):.4f}" if s.get("bucket_entropy_at_top") is not None else "-")
|
| 968 |
+
|
| 969 |
+
print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
|
| 970 |
+
print(" Contrast pairs (explicit contrast_pair_id only):")
|
| 971 |
+
row(" usable pairs", lambda s: s.get("n_contrast_pairs", 0))
|
| 972 |
+
row(" best feature", lambda s: s.get("top_contrast_feature") if s.get("top_contrast_feature") is not None else "-")
|
| 973 |
+
row(" best effect", lambda s: f"{s.get('top_contrast_effect'):.4f}" if s.get("top_contrast_effect") is not None else "-")
|
| 974 |
+
|
| 975 |
print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
|
| 976 |
print(" Code cross-reference (top 30 code-preferring):")
|
| 977 |
row(" entangled", lambda s: s["code_entangled"])
|
|
|
|
| 986 |
f"(n={best_specific['taxonomy'].get('specific', 0)})")
|
| 987 |
print(f" Highest single-feature separation: {best_sep['name']} "
|
| 988 |
f"(score={best_sep['top_sep_score']:.4f})")
|
| 989 |
+
best_bucket = max(summaries, key=lambda s: s.get("top_bucket_quality", 0.0))
|
| 990 |
+
print(f" Cleanest bucket signal: {best_bucket['name']} "
|
| 991 |
+
f"(bucket={best_bucket.get('top_bucket')}, quality={best_bucket.get('top_bucket_quality', 0.0):.4f})")
|
| 992 |
+
if any(s.get("n_contrast_pairs", 0) for s in summaries):
|
| 993 |
+
best_contrast = max(summaries, key=lambda s: s.get("top_contrast_effect", 0.0) or 0.0)
|
| 994 |
+
print(f" Strongest contrast delta: {best_contrast['name']} "
|
| 995 |
+
f"(effect={best_contrast.get('top_contrast_effect', 0.0):.4f})")
|
| 996 |
|
| 997 |
|
| 998 |
def main():
|
|
|
|
| 1106 |
prefix = f"l{args.layer}_{s['name']}"
|
| 1107 |
for suffix in ["neuron_taxonomy.json", "census_heatmap.png",
|
| 1108 |
"coactivation_pairs.json", "separation_scores.npy",
|
| 1109 |
+
"bucket_metrics.json", "contrast_delta.json",
|
| 1110 |
"code_analysis.json"]:
|
| 1111 |
p = out / f"{prefix}_{suffix}"
|
| 1112 |
if p.exists():
|
qwip_atlas/build_atlas.py
CHANGED
|
@@ -213,6 +213,8 @@ def cmd_merge_layer(args):
|
|
| 213 |
"taxonomy.json": analysis_dir / f"{prefix}neuron_taxonomy.json",
|
| 214 |
"separation.npy": analysis_dir / f"{prefix}separation_scores.npy",
|
| 215 |
"coactivation.json": analysis_dir / f"{prefix}coactivation_pairs.json",
|
|
|
|
|
|
|
| 216 |
"code_analysis.json": analysis_dir / f"{prefix}code_analysis.json",
|
| 217 |
"census_heatmap.png": analysis_dir / f"{prefix}census_heatmap.png",
|
| 218 |
}
|
|
@@ -245,6 +247,29 @@ def cmd_merge_layer(args):
|
|
| 245 |
"code_selective":code.get("selective_count", 0),
|
| 246 |
"files": copied,
|
| 247 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
write_json(cdir / "summary.json", summary)
|
| 249 |
|
| 250 |
# 3. Per-head ingest
|
|
|
|
| 213 |
"taxonomy.json": analysis_dir / f"{prefix}neuron_taxonomy.json",
|
| 214 |
"separation.npy": analysis_dir / f"{prefix}separation_scores.npy",
|
| 215 |
"coactivation.json": analysis_dir / f"{prefix}coactivation_pairs.json",
|
| 216 |
+
"bucket_metrics.json":analysis_dir / f"{prefix}bucket_metrics.json",
|
| 217 |
+
"contrast_delta.json":analysis_dir / f"{prefix}contrast_delta.json",
|
| 218 |
"code_analysis.json": analysis_dir / f"{prefix}code_analysis.json",
|
| 219 |
"census_heatmap.png": analysis_dir / f"{prefix}census_heatmap.png",
|
| 220 |
}
|
|
|
|
| 247 |
"code_selective":code.get("selective_count", 0),
|
| 248 |
"files": copied,
|
| 249 |
}
|
| 250 |
+
bucket_metrics_path = cdir / "bucket_metrics.json"
|
| 251 |
+
if bucket_metrics_path.exists():
|
| 252 |
+
bucket_metrics = read_json(bucket_metrics_path)
|
| 253 |
+
if bucket_metrics:
|
| 254 |
+
top_bucket = max(bucket_metrics, key=lambda row: row.get("bucket_quality", 0.0))
|
| 255 |
+
summary.update({
|
| 256 |
+
"bucket_quality_top": top_bucket.get("bucket_quality"),
|
| 257 |
+
"bucket_quality_top_idx": top_bucket.get("feature"),
|
| 258 |
+
"bucket_quality_top_bucket": top_bucket.get("dominant_bucket"),
|
| 259 |
+
"bucket_quality_top_entropy": top_bucket.get("bucket_entropy"),
|
| 260 |
+
"bucket_quality_top_eta_squared": top_bucket.get("eta_squared"),
|
| 261 |
+
})
|
| 262 |
+
contrast_path = cdir / "contrast_delta.json"
|
| 263 |
+
if contrast_path.exists():
|
| 264 |
+
contrast = read_json(contrast_path)
|
| 265 |
+
features = contrast.get("features") or []
|
| 266 |
+
top_contrast = features[0] if features else {}
|
| 267 |
+
summary.update({
|
| 268 |
+
"contrast_pairs": contrast.get("n_pairs", 0),
|
| 269 |
+
"contrast_top_idx": top_contrast.get("feature"),
|
| 270 |
+
"contrast_top_effect": top_contrast.get("effect_score"),
|
| 271 |
+
"contrast_top_mean_delta": top_contrast.get("mean_delta"),
|
| 272 |
+
})
|
| 273 |
write_json(cdir / "summary.json", summary)
|
| 274 |
|
| 275 |
# 3. Per-head ingest
|
tests/test_analyze_layers_metrics.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from qwip_atlas.analyze_layers import (
|
| 6 |
+
compute_contrast_delta_scores,
|
| 7 |
+
compute_feature_bucket_metrics,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_feature_bucket_metrics_identifies_clean_dominant_bucket() -> None:
|
| 12 |
+
A = np.array(
|
| 13 |
+
[
|
| 14 |
+
[5.0, 4.0, 0.0, 0.0],
|
| 15 |
+
[1.0, 1.0, 1.0, 1.0],
|
| 16 |
+
[0.0, 0.0, 3.0, 4.0],
|
| 17 |
+
],
|
| 18 |
+
dtype=np.float32,
|
| 19 |
+
)
|
| 20 |
+
buckets = ["auth", "auth", "corp", "corp"]
|
| 21 |
+
|
| 22 |
+
metrics = compute_feature_bucket_metrics(A, buckets)
|
| 23 |
+
|
| 24 |
+
assert metrics[0]["dominant_bucket"] == "auth"
|
| 25 |
+
assert metrics[0]["dominance_margin"] > 3.0
|
| 26 |
+
assert metrics[0]["bucket_entropy"] < 0.1
|
| 27 |
+
assert metrics[0]["eta_squared"] > 0.9
|
| 28 |
+
|
| 29 |
+
assert metrics[1]["dominant_bucket"] == "auth"
|
| 30 |
+
assert metrics[1]["dominance_margin"] == 0.0
|
| 31 |
+
assert metrics[1]["bucket_entropy"] > 0.9
|
| 32 |
+
assert metrics[1]["eta_squared"] == 0.0
|
| 33 |
+
|
| 34 |
+
assert metrics[2]["dominant_bucket"] == "corp"
|
| 35 |
+
assert metrics[2]["dominance_margin"] > 3.0
|
| 36 |
+
assert metrics[2]["bucket_entropy"] < 0.1
|
| 37 |
+
assert metrics[2]["eta_squared"] > 0.9
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_contrast_delta_scores_use_exact_two_row_pairs() -> None:
|
| 41 |
+
A = np.array(
|
| 42 |
+
[
|
| 43 |
+
[1.0, 2.0, 2.0, 3.0, 100.0],
|
| 44 |
+
[5.0, 2.0, 0.0, 1.0, 100.0],
|
| 45 |
+
[2.0, 0.0, 5.0, 2.0, 100.0],
|
| 46 |
+
],
|
| 47 |
+
dtype=np.float32,
|
| 48 |
+
)
|
| 49 |
+
records = [
|
| 50 |
+
{"id": "a1", "contrast_pair_id": "p1", "bucket": "auth"},
|
| 51 |
+
{"id": "c1", "contrast_pair_id": "p1", "bucket": "corp"},
|
| 52 |
+
{"id": "a2", "contrast_pair_id": "p2", "bucket": "auth"},
|
| 53 |
+
{"id": "c2", "contrast_pair_id": "p2", "bucket": "corp"},
|
| 54 |
+
{"id": "orphan", "contrast_pair_id": "bad", "bucket": "other"},
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
report = compute_contrast_delta_scores(A, records)
|
| 58 |
+
|
| 59 |
+
assert report["n_pairs"] == 2
|
| 60 |
+
assert report["skipped_pair_ids"] == ["bad"]
|
| 61 |
+
assert report["direction"] == "lexical_bucket_order"
|
| 62 |
+
|
| 63 |
+
by_feature = {row["feature"]: row for row in report["features"]}
|
| 64 |
+
assert by_feature[0]["mean_delta"] == 1.0
|
| 65 |
+
assert by_feature[0]["positive_fraction"] == 1.0
|
| 66 |
+
assert by_feature[1]["mean_delta"] == -1.0
|
| 67 |
+
assert by_feature[1]["positive_fraction"] == 0.5
|
| 68 |
+
assert by_feature[2]["mean_delta"] == -2.5
|
| 69 |
+
assert by_feature[2]["positive_fraction"] == 0.0
|