FeatureLens / experiments /make_report.py
ArchitSharma's picture
Release FeatureLens v0.4.0
6d68f94
Raw
History Blame
18.4 kB
from __future__ import annotations
import argparse
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from experiments.common import ARTIFACT_DIR
from featurelens.stats import (
bootstrap_mean_ci,
paired_bootstrap_difference_ci,
paired_sign_flip_pvalue,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Build a truthful experiment report from saved metrics.')
parser.add_argument('--artifact-dir', type=Path, default=ARTIFACT_DIR)
return parser.parse_args()
def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
scored = catalog.copy()
scored['activation_contrast'] = scored['activation_rate_pos'] - scored['activation_rate_neg']
ordered = scored.sort_values(
['concept', 'train_auroc', 'activation_contrast'],
ascending=[True, False, False],
)
return ordered.groupby('concept', as_index=False).first()
def _effect_column(frame: pd.DataFrame) -> str:
"""Prefer the full-continuation length-normalized metric, with legacy fallback."""
if 'target_mean_logprob_delta' in frame.columns:
return 'target_mean_logprob_delta'
return 'target_logprob_delta'
def _save_plots(
artifact_dir: Path,
selected: pd.DataFrame,
layers: pd.DataFrame,
causal: pd.DataFrame,
feature_sets: pd.DataFrame | None,
) -> None:
fig_dir = artifact_dir / 'figures'
fig_dir.mkdir(parents=True, exist_ok=True)
figure = plt.figure(figsize=(7.5, 4.2))
ax = figure.add_subplot(111)
ordered = selected.sort_values('auroc')
ax.barh(ordered['concept'], ordered['auroc'])
ax.axvline(0.5, linewidth=1, linestyle='--')
ax.set_xlabel('Held-out AUROC')
ax.set_title('Selected SAE feature predictiveness')
figure.tight_layout()
figure.savefig(fig_dir / 'feature_auroc.png', dpi=160)
plt.close(figure)
figure = plt.figure(figsize=(7.0, 4.2))
ax = figure.add_subplot(111)
ax.plot(
layers['layer'],
layers['linear_probe_macro_auroc'],
marker='o',
label='Linear probe AUROC',
)
ax.plot(
layers['layer'],
layers['reconstruction_cosine'],
marker='o',
label='SAE reconstruction cosine',
)
ax.set_xlabel('Layer')
ax.set_ylim(0, 1.05)
ax.set_title('Layer-wise representation diagnostics')
ax.legend()
figure.tight_layout()
figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
plt.close(figure)
causal_metric = _effect_column(causal)
grouped = (
causal.groupby(['intervention', 'condition'])[causal_metric]
.apply(lambda values: float(np.mean(np.abs(values))))
.reset_index(name='mean_abs_effect')
)
pivot = grouped.pivot(index='intervention', columns='condition', values='mean_abs_effect')
figure = plt.figure(figsize=(7.0, 4.2))
ax = figure.add_subplot(111)
pivot.plot(kind='bar', ax=ax)
ax.set_ylabel('Mean |Δ mean log p/token|' if causal_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
ax.set_title('Single-feature SAE edits vs norm-matched controls')
ax.tick_params(axis='x', rotation=0)
figure.tight_layout()
figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
plt.close(figure)
if feature_sets is not None and not feature_sets.empty:
set_metric = _effect_column(feature_sets)
grouped_sets = (
feature_sets.groupby(['set_size', 'condition'])[set_metric]
.apply(lambda values: float(np.mean(np.abs(values))))
.reset_index(name='mean_abs_effect')
)
set_pivot = grouped_sets.pivot(index='set_size', columns='condition', values='mean_abs_effect')
figure = plt.figure(figsize=(7.0, 4.2))
ax = figure.add_subplot(111)
set_pivot.plot(kind='line', marker='o', ax=ax)
ax.set_xlabel('Jointly ablated feature count')
ax.set_ylabel('Mean |Δ mean log p/token|' if set_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
ax.set_title('Distributed feature-set causal effect')
figure.tight_layout()
figure.savefig(fig_dir / 'feature_set_effects.png', dpi=160)
plt.close(figure)
def _paired_stats(
frame: pd.DataFrame,
*,
index: list[str],
sae_condition: str,
random_condition: str,
seed: int,
) -> dict[str, float | list[float]]:
"""Pair one SAE effect with the mean absolute effect of its random-control ensemble."""
metric = _effect_column(frame)
sae = (
frame[frame['condition'] == sae_condition]
.groupby(index, as_index=False)[metric]
.first()
.rename(columns={metric: 'sae_effect'})
)
random = (
frame[frame['condition'] == random_condition]
.assign(_abs_effect=lambda data: np.abs(data[metric].astype(float)))
.groupby(index, as_index=False)['_abs_effect']
.mean()
.rename(columns={'_abs_effect': 'random_abs_effect'})
)
paired = sae.merge(random, on=index, how='inner')
sae_abs = np.abs(paired['sae_effect'].to_numpy(dtype=float))
random_abs = paired['random_abs_effect'].to_numpy(dtype=float)
if sae_abs.size == 0:
return {
'sae_abs': float('nan'),
'random_abs': float('nan'),
'ratio': float('nan'),
'paired_advantage': float('nan'),
'ci': [float('nan'), float('nan')],
'pvalue': float('nan'),
'n_pairs': 0,
}
diff = sae_abs - random_abs
low, high = paired_bootstrap_difference_ci(sae_abs, random_abs, seed=seed)
return {
'sae_abs': float(np.mean(sae_abs)),
'random_abs': float(np.mean(random_abs)),
'ratio': float(np.mean(sae_abs) / max(float(np.mean(random_abs)), 1e-12)),
'paired_advantage': float(np.mean(diff)),
'ci': [float(low), float(high)],
'pvalue': float(paired_sign_flip_pvalue(sae_abs, random_abs, seed=seed + 1)),
'n_pairs': int(sae_abs.size),
}
def main() -> None:
args = parse_args()
catalog = pd.read_csv(args.artifact_dir / 'feature_catalog.csv')
layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
stability = pd.read_csv(args.artifact_dir / 'stability.csv')
causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
feature_set_path = args.artifact_dir / 'feature_set_results.csv'
feature_sets = pd.read_csv(feature_set_path) if feature_set_path.exists() else None
selected = _selected_features(catalog)
_save_plots(args.artifact_dir, selected, layers, causal, feature_sets)
mean_auc = float(selected['auroc'].mean())
median_auc = float(selected['auroc'].median())
auc_ci_low, auc_ci_high = bootstrap_mean_ci(selected['auroc'].to_numpy(), seed=42)
best_layer_row = layers.sort_values('linear_probe_macro_auroc', ascending=False).iloc[0]
mean_jaccard = float(stability['topk_jaccard'].mean())
mean_sparse_cos = float(stability['sparse_cosine'].mean())
single = _paired_stats(
causal,
index=['task_id', 'intervention'],
sae_condition='sae_feature',
random_condition='random_norm_matched',
seed=43,
)
sae = causal[causal['condition'] == 'sae_feature']
active_rate = float(np.mean(sae['feature_activation'] > 0))
top1_change = float(sae['top1_changed'].mean())
set_summary: dict[int, dict[str, float | list[float]]] = {}
largest_set: dict[str, float | list[float]] | None = None
largest_k: int | None = None
if feature_sets is not None and not feature_sets.empty:
for size in sorted(int(x) for x in feature_sets['set_size'].unique()):
subset = feature_sets[feature_sets['set_size'] == size]
set_summary[size] = _paired_stats(
subset,
index=['task_id', 'set_size'],
sae_condition='sae_feature_set',
random_condition='random_norm_matched',
seed=100 + size,
)
largest_k = max(set_summary)
largest_set = set_summary[largest_k]
sae_abs = float(single['sae_abs'])
random_abs = float(single['random_abs'])
ratio = float(single['ratio'])
single_ci_low = float(single['ci'][0])
single_p = float(single['pvalue'])
single_specific = ratio >= 1.5 and single_ci_low > 0.0 and single_p < 0.05
if mean_auc >= 0.8 and sae_abs < 0.08:
interpretation = (
'The selected sparse features were strongly predictive on held-out prompts, but single-feature '
'interventions produced only modest downstream changes. FeatureLens therefore treats the '
'representation-level signal as correlational rather than automatically causal.'
)
elif mean_auc >= 0.8 and sae_abs >= 0.08 and single_specific:
interpretation = (
'The selected sparse features were strongly predictive and single-feature interventions produced '
'larger target-continuation shifts than norm-matched random residual perturbations. The paired '
'bootstrap interval excludes zero and the sign-flip test passes the configured 0.05 threshold, '
'supporting a causal-specificity claim for at least some predictive features.'
)
elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
interpretation = (
'The selected sparse features were strongly predictive and their point-estimate intervention '
'effects exceeded norm-matched random controls, but the paired uncertainty test does not support '
'a strong causal-specificity claim at the 0.05 threshold. The result is reported as suggestive '
'rather than conclusive.'
)
elif mean_auc < 0.65:
interpretation = (
'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims would '
'be premature. The main result is diagnostic: concept design or feature selection should be '
'refined before interpreting intervention effects.'
)
else:
interpretation = (
'The results show mixed predictive and causal evidence. FeatureLens reports association, '
'robustness, and intervention measurements separately rather than collapsing them into one score.'
)
if largest_set is not None and largest_k is not None:
set_advantage = float(largest_set['paired_advantage'])
set_ci_low = float(largest_set['ci'][0])
set_p = float(largest_set['pvalue'])
set_specific = set_ci_low > 0.0 and set_p < 0.05
if set_advantage > float(single['paired_advantage']) + 0.02 and set_specific:
interpretation += (
f' Joint ablation of the top {largest_k} same-layer concept features produced a larger '
'paired advantage over random controls than the single-feature edits, with paired uncertainty '
'supporting the difference. This is consistent with causal influence being distributed across '
'a sparse feature set rather than concentrated in one unit.'
)
elif set_advantage > float(single['paired_advantage']) + 0.02:
interpretation += (
f' The top-{largest_k} joint-ablation point estimate exceeded the single-feature advantage, '
'but its paired uncertainty test does not support a strong distributed-causality claim at the '
'0.05 threshold. The pattern is therefore treated as suggestive only.'
)
elif abs(set_advantage) <= 0.02:
interpretation += (
f' Expanding the intervention to the top {largest_k} same-layer features did not materially '
'increase specificity over random controls, which argues against assuming that a broader '
'concept-associated sparse subspace is automatically more causal.'
)
effect_label = 'mean log p/token' if _effect_column(causal) == 'target_mean_logprob_delta' else 'target log-probability'
headline = (
f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; single-feature SAE interventions '
f'changed {effect_label} by {sae_abs:.3f} in absolute value on average versus {random_abs:.3f} '
'for norm-matched random residual controls.'
)
highlights = [
f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
f'Selected feature active on {active_rate:.1%} of causal prompts; modified next-token top-1 on {top1_change:.1%}.',
f'Single-feature mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
f'Single-feature paired mean |effect| advantage over random: {float(single["paired_advantage"]):+.3f}, 95% bootstrap CI [{float(single["ci"][0]):+.3f}, {float(single["ci"][1]):+.3f}], sign-flip p={float(single["pvalue"]):.4f}.',
]
if largest_set is not None and largest_k is not None:
highlights.append(
f'Top-{largest_k} joint ablation: SAE/random mean absolute effect ratio {float(largest_set["ratio"]):.2f}×; '
f'paired advantage {float(largest_set["paired_advantage"]):+.3f}, 95% CI '
f'[{float(largest_set["ci"][0]):+.3f}, {float(largest_set["ci"][1]):+.3f}], '
f'sign-flip p={float(largest_set["pvalue"]):.4f}.'
)
summary = {
'headline': headline,
'highlights': highlights,
'interpretation': interpretation,
'metrics': {
'mean_selected_feature_test_auroc': mean_auc,
'mean_selected_feature_test_auroc_bootstrap_ci_95': [auc_ci_low, auc_ci_high],
'median_selected_feature_test_auroc': median_auc,
'best_linear_probe_layer': int(best_layer_row['layer']),
'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
'mean_paraphrase_topk_jaccard': mean_jaccard,
'mean_paraphrase_sparse_cosine': mean_sparse_cos,
'single_feature_effect_metric': _effect_column(causal),
'mean_abs_sae_effect': sae_abs,
'mean_abs_random_effect': random_abs,
'causal_to_random_effect_ratio': ratio,
'paired_mean_abs_effect_advantage': float(single['paired_advantage']),
'paired_mean_abs_effect_advantage_bootstrap_ci_95': single['ci'],
'paired_sign_flip_pvalue': float(single['pvalue']),
'causal_prompt_feature_active_rate': active_rate,
'sae_top1_change_rate': top1_change,
'feature_set_results': {str(k): value for k, value in set_summary.items()},
},
}
(args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
lines = [
'# FeatureLens experiment report',
'',
'## Research question',
'',
'**Do sparse features that predict a concept also causally influence model behaviour?**',
'',
'## Executive summary',
'',
headline,
'',
interpretation,
'',
'## Key measurements',
'',
*[f'- {item}' for item in highlights],
'',
'## Experimental design',
'',
'- Model: Qwen3-1.7B-Base.',
'- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.',
'- Discovery set: controlled concept prompts with paired paraphrases.',
'- Split discipline: paraphrase groups stay entirely in train or held-out test.',
'- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
'- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
'- Feature-set causal edit: joint ablation of top same-layer concept features, evaluated at k=1/3/5 by default.',
'- Negative control: ensemble of deterministic random residual directions, each matched to the SAE perturbation L2 norm.',
'- Target metric: exact full target continuation scored teacher-forced; mean log probability per target token is the primary length-comparable effect.',
'- Secondary diagnostics: first-token probability/rank, next-token JS divergence, and top-1 changes.',
'- Uncertainty: bootstrap 95% confidence intervals and paired sign-flip randomization tests.',
'',
'## Figures',
'',
'![Feature AUROC](figures/feature_auroc.png)',
'',
'![Layer diagnostics](figures/layer_diagnostics.png)',
'',
'![Single-feature causal effects](figures/causal_effects.png)',
]
if feature_sets is not None and not feature_sets.empty:
lines.extend(['', '![Feature-set causal effects](figures/feature_set_effects.png)'])
lines.extend(
[
'',
'## Interpretation guardrails',
'',
'A high feature/concept AUROC or high paraphrase overlap is correlational evidence only. Causal evidence requires a downstream change under intervention and is interpreted relative to a norm-matched random control. Feature-set effects are not assumed stronger a priori; they are separately measured. The narrative above is generated from saved metrics, with no hard-coded result values.',
'',
'## Reproducibility',
'',
'Run `python experiments/run_all.py` from the repository root. Raw activation matrices, splits, selected features, single-feature rows, feature-set rows, figures, and this report are materialized under `artifacts/`.',
'',
]
)
(args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
print(headline)
print(f'Wrote {args.artifact_dir / "report.md"}')
if __name__ == '__main__':
main()