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__
This commit is contained in:
Nathan Schneider committed 2026-10-02 08:32:59 -06:00
1 parent a4f16e8e4d
commit 55cbd6cd5d
8 files changed
+591 -109

No files matched your search

+95 -24
View File
@@ -2,13 +2,42 @@
"""
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 = []
@@ -45,18 +74,23 @@ def calculate_correlation(df1, df2, numeric_cols):
def compare_analyses(reference_file, comparison_files):
"""Compare multiple analysis files to a reference file."""
# Read reference file
# Read and canonicalize reference file
print(f"Reading reference file: {reference_file}")
ref_df = pd.read_csv(reference_file, quotechar='"', escapechar='\\', engine='python')
# Get numeric columns (all the rating dimensions)
numeric_cols = [col for col in ref_df.columns if
col.startswith(('Design_', 'Entanglement_', 'Experience_'))]
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)
# Convert numeric columns to numeric type, coercing errors to NaN
for col in numeric_cols:
ref_df[col] = pd.to_numeric(ref_df[col], errors='coerce')
# 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(numeric_cols)} numeric dimensions to compare")
print(f"\nFound {len(ref_numeric)} numeric dimensions in reference file")
print(f"Comparing {len(ref_df)} protocols\n")
print("="*80)
@@ -66,12 +100,23 @@ def compare_analyses(reference_file, comparison_files):
print(f"\nComparing: {Path(comp_file).name}")
print("-"*80)
# Read comparison file
comp_df = pd.read_csv(comp_file, quotechar='"', escapechar='\\', engine='python')
# 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)")
# Convert numeric columns to numeric type, coercing errors to NaN
for col in numeric_cols:
comp_df[col] = pd.to_numeric(comp_df[col], errors='coerce')
# 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:
@@ -155,32 +200,58 @@ def compare_analyses(reference_file, comparison_files):
return results
if __name__ == "__main__":
# Define file paths
reference_file = "data/synthetic_1.2.6/readings_manual.csv"
comparison_files = [
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"
"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")
exit(1)
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...")
comparison_files.remove(file)
if not comparison_files:
if not existing:
print("Error: No comparison files found")
exit(1)
sys.exit(1)
# Run comparison
results = compare_analyses(reference_file, comparison_files)
results = compare_analyses(reference_file, existing)
print("\n" + "="*80)
print("Analysis complete!")
print("="*80)
return results
if __name__ == "__main__":
main()