Files
protocol-bicorder/analysis/scripts/compare_analyses.py
T
Nathan Schneider 55cbd6cd5d feat: version-agnostic analysis scripts with shared version helpers
- scripts/bicorder_common.py (new): single source of truth for historical
  gradient renames (COLUMN_RENAMES), version detection (bicorder_version col
  → version col → data/<type>_<version>/ dir convention), and training-CSV
  auto-selection (find_training_csv)
- classify_readings.py: auto-select training run by recorded bicorder version
  (excludes the input itself to avoid circular training), canonicalize old
  column names, version-mismatch warnings
- bicorder_classifier.py / export_model_for_js.py: share renames + dimension
  loader; instructive error when clustering results are missing;
  bicorder_version recorded in exported models
- compare_analyses.py: CLI (reference + comparison CSVs), rename
  canonicalization so runs of different versions align on shared gradients,
  Descriptor dedup (was silently skewing merges); legacy no-arg audit intact
- scripts/univariate_analysis.py (new): per-protocol/per-gradient averages,
  distributions, summary stats; --img publishes the three README summary
  charts to img/
- sync_readings.sh: defer classifier training to auto-matching; gitignore
  analysis/.venv and __pycache__
2026-10-02 08:32:59 -06:00

258 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""
Compare multiple analysis CSV files to determine which most closely resembles a reference file.
Uses Euclidean distance, correlation, and RMSE metrics.
Readings files are canonicalized to the current bicorder terminology (history of
renames in bicorder_common.py), and comparison runs on the gradient columns
shared by all files — so any versions can be compared, and renamed gradients
remain comparable across version boundaries.
Usage:
# Legacy audit: manual review vs. the three model test runs (as in README)
python3 scripts/compare_analyses.py
# Explicit: reference file first, then any number of comparison files
python3 scripts/compare_analyses.py \
data/synthetic_1.2.6/readings.csv data/synthetic_1.4.0/readings.csv
"""
import argparse
import sys
import pandas as pd
import numpy as np
from scipy.stats import pearsonr
from pathlib import Path
from bicorder_common import apply_renames, csv_version
def load_canonical(path):
"""Load a readings CSV, canonicalize columns, and coerce gradient values to numeric."""
df = pd.read_csv(path, quotechar='"', escapechar='\\', engine='python')
df = apply_renames(df)
numeric_cols = [col for col in df.columns if
col.startswith(('Design_', 'Entanglement_', 'Experience_'))]
for col in numeric_cols:
df[col] = pd.to_numeric(df[col], errors='coerce')
return df, numeric_cols
def calculate_euclidean_distance(df1, df2, numeric_cols):
"""Calculate Euclidean distance between two dataframes."""
distances = []
for idx in df1.index:
diff = df1.loc[idx, numeric_cols] - df2.loc[idx, numeric_cols]
# Use nansum to ignore NaN values
distance = np.sqrt(np.nansum(diff ** 2))
distances.append(distance)
return np.array(distances)
def calculate_rmse(df1, df2, numeric_cols):
"""Calculate Root Mean Squared Error."""
diff = df1[numeric_cols] - df2[numeric_cols]
# Use nanmean to ignore NaN values
mse = np.nanmean(diff.values ** 2)
return np.sqrt(mse)
def calculate_correlation(df1, df2, numeric_cols):
"""Calculate Pearson correlation across all numeric values."""
vals1 = df1[numeric_cols].values.flatten()
vals2 = df2[numeric_cols].values.flatten()
# Remove NaN values - only use positions where both have valid values
mask = ~(np.isnan(vals1) | np.isnan(vals2))
vals1_clean = vals1[mask]
vals2_clean = vals2[mask]
if len(vals1_clean) < 2:
return np.nan, np.nan
corr, pvalue = pearsonr(vals1_clean, vals2_clean)
return corr, pvalue
def compare_analyses(reference_file, comparison_files):
"""Compare multiple analysis files to a reference file."""
# Read and canonicalize reference file
print(f"Reading reference file: {reference_file}")
ref_version = csv_version(reference_file)
if ref_version:
print(f" bicorder version recorded: v{ref_version}")
ref_df, ref_numeric = load_canonical(reference_file)
# Repeated Descriptor entries (kept as control cases in the datasets) would
# multiply rows in the Descriptor-based merge; keep first occurrence like
# the classifier does.
if 'Descriptor' in ref_df.columns:
before = len(ref_df)
ref_df = ref_df.drop_duplicates(subset='Descriptor', keep='first')
if len(ref_df) < before:
print(f" Deduplicated reference: {before} → {len(ref_df)} rows (kept first of repeated Descriptor)")
print(f"\nFound {len(ref_numeric)} numeric dimensions in reference file")
print(f"Comparing {len(ref_df)} protocols\n")
print("="*80)
results = {}
for comp_file in comparison_files:
print(f"\nComparing: {Path(comp_file).name}")
print("-"*80)
# Read and canonicalize comparison file
comp_version = csv_version(comp_file)
if comp_version:
print(f" bicorder version recorded: v{comp_version}")
comp_df, comp_numeric = load_canonical(comp_file)
if 'Descriptor' in comp_df.columns:
before = len(comp_df)
comp_df = comp_df.drop_duplicates(subset='Descriptor', keep='first')
if len(comp_df) < before:
print(f" Deduplicated comparison: {before} → {len(comp_df)} rows (kept first of repeated Descriptor)")
# Restrict to columns shared by both files (post-rename): enables
# comparing across bicorder versions when gradients were renamed
numeric_cols = [col for col in ref_numeric if col in comp_numeric]
missing = [col for col in ref_numeric if col not in comp_numeric]
if missing:
print(f" Note: {len(missing)} gradient(s) absent here are excluded: {', '.join(missing)}")
# Ensure same protocols in same order (match by Descriptor)
if 'Descriptor' in ref_df.columns and 'Descriptor' in comp_df.columns:
# Use merge to ensure exact matching - only keep protocols in ref_df
comp_df = pd.merge(
ref_df[['Descriptor']],
comp_df,
on='Descriptor',
how='left'
)
# Calculate Euclidean distances using reset indices to ensure alignment
ref_temp = ref_df.reset_index(drop=True)
comp_temp = comp_df.reset_index(drop=True)
euclidean_distances = calculate_euclidean_distance(ref_temp, comp_temp, numeric_cols)
total_euclidean = np.sum(euclidean_distances)
avg_euclidean = np.mean(euclidean_distances)
# Calculate RMSE
rmse = calculate_rmse(ref_temp, comp_temp, numeric_cols)
# Calculate correlation
correlation, p_value = calculate_correlation(ref_temp, comp_temp, numeric_cols)
# Store results
results[Path(comp_file).name] = {
'total_euclidean': total_euclidean,
'avg_euclidean': avg_euclidean,
'rmse': rmse,
'correlation': correlation,
'p_value': p_value,
'per_protocol_distances': euclidean_distances,
'protocols': ref_df['Descriptor'].values if 'Descriptor' in ref_df.columns else None
}
# Print results
print(f" Total Euclidean Distance: {total_euclidean:.2f}")
print(f" Average Euclidean Distance: {avg_euclidean:.2f}")
print(f" RMSE: {rmse:.2f}")
print(f" Pearson Correlation: {correlation:.4f} (p={p_value:.2e})")
# Summary comparison
print("\n" + "="*80)
print("SUMMARY RANKING (lower distance = more similar)")
print("="*80)
# Sort by average Euclidean distance
sorted_by_euclidean = sorted(results.items(), key=lambda x: x[1]['avg_euclidean'])
print("\nBy Average Euclidean Distance:")
for i, (name, data) in enumerate(sorted_by_euclidean, 1):
print(f" {i}. {name:30s} - Avg Distance: {data['avg_euclidean']:.2f}")
# Sort by correlation (higher is better)
sorted_by_corr = sorted(results.items(), key=lambda x: x[1]['correlation'], reverse=True)
print("\nBy Correlation (higher = more similar):")
for i, (name, data) in enumerate(sorted_by_corr, 1):
print(f" {i}. {name:30s} - Correlation: {data['correlation']:.4f}")
# Sort by RMSE
sorted_by_rmse = sorted(results.items(), key=lambda x: x[1]['rmse'])
print("\nBy RMSE (lower = more similar):")
for i, (name, data) in enumerate(sorted_by_rmse, 1):
print(f" {i}. {name:30s} - RMSE: {data['rmse']:.2f}")
# Show protocols with largest differences for the best match
print("\n" + "="*80)
best_match_name, best_match_data = sorted_by_euclidean[0]
print(f"Top 10 protocols with largest differences from {best_match_name}:")
print("="*80)
if best_match_data['protocols'] is not None:
distances = best_match_data['per_protocol_distances']
protocols = best_match_data['protocols']
top_diff_indices = np.argsort(distances)[-10:][::-1]
for idx in top_diff_indices:
print(f" {protocols[idx]:50s} - Distance: {distances[idx]:.2f}")
return results
def main(argv=None):
"""CLI entry point.
With no arguments, falls back to the legacy audit: the 1.2.6 manual review
against the three model test runs (as described in README.md).
"""
legacy_reference = "data/synthetic_1.2.6/readings_manual.csv"
legacy_comparisons = [
"data/synthetic_1.2.6/readings_gemma3-12b.csv",
"data/synthetic_1.2.6/readings_gpt-oss.csv",
"data/synthetic_1.2.6/readings_mistral.csv",
]
if argv is None:
argv = sys.argv[1:]
if argv:
parser = argparse.ArgumentParser(
description='Compare readings CSVs to a reference (Euclidean distance, RMSE, correlation)',
epilog="""Example (cross-version):
python3 scripts/compare_analyses.py \\
data/synthetic_1.4.0/readings.csv data/synthetic_1.2.6/readings.csv
""",
)
parser.add_argument('reference', help='Reference readings CSV')
parser.add_argument('comparisons', nargs='+', help='Comparison readings CSVs')
args = parser.parse_args(argv)
reference_file, comparison_files = args.reference, args.comparisons
else:
reference_file, comparison_files = legacy_reference, legacy_comparisons
# Check if files exist
if not Path(reference_file).exists():
print(f"Error: Reference file '{reference_file}' not found")
sys.exit(1)
existing = [file for file in comparison_files if Path(file).exists()]
for file in comparison_files:
if not Path(file).exists():
print(f"Warning: Comparison file '{file}' not found, skipping...")
if not existing:
print("Error: No comparison files found")
sys.exit(1)
# Run comparison
results = compare_analyses(reference_file, existing)
print("\n" + "="*80)
print("Analysis complete!")
print("="*80)
return results
if __name__ == "__main__":
main()