TalkingHeadBenchEnv / src /envs /subenv3 /node7_weight_extractor.py
elix3r's picture
Upload folder using huggingface_hub
3da2703 verified
Raw
History Blame Contribute Delete
20.8 kB
"""
Node 7: Weight Signal Extractor — Sub-env 3.
Extracts canonical weight statistics from a trained LoRA ``.safetensors`` file.
**All statistics are computed from canonical SVD components (U, S, Vt, Q)**
produced by ``canonicalize_lora_factors()`` imported from
``src/utils/canonical.py``. Raw A/B matrices are never used for statistics
directly — they are fed through the W2T QR → SVD pipeline first, resolving
column-space factorization ambiguity.
``token_position_to_phoneme`` is loaded from the tokenizer config JSON shipped
alongside the ``.safetensors`` file. It is NOT derived from the weights. If
``tokenizer_config_path`` is ``None``, the field is set to ``None``.
Statistic formulas:
- Layer norms: ``torch.linalg.norm(canonical_effective_update)``
- Layer entropy: inverted normalized entropy of canonical singular values
- Rank utilization: from canonical SVD S directly (no extra computation)
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import numpy as np
import torch
from safetensors.torch import load_file
from src.schemas.subenv2 import SyntheticWeightDescriptor
from src.schemas.subenv3 import WeightSignalObservation
from src.utils.canonical import (
CanonicalComponents,
canonicalize_lora_factors,
layer_entropy_from_singular_values,
singular_direction_anomaly_scores,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Fraction of a layer's max singular value below which an S entry is "near zero".
_SPARSITY_THRESHOLD_RATIO: float = 0.01
# Cumulative energy fraction for counting "dominant" singular directions.
_DOMINANT_ENERGY_FRACTION: float = 0.90
# Standard-deviation multipliers for flagging anomalous Vt-row entropy.
_ENTROPY_ANOMALY_SIGMA: float = 2.0
# Number of histogram bins for the canonical S distribution.
_HISTOGRAM_BINS: int = 20
# ---------------------------------------------------------------------------
# Private helpers — safetensors parsing
# ---------------------------------------------------------------------------
def _find_lora_pairs(state_dict: dict[str, torch.Tensor]) -> dict[str, tuple[torch.Tensor, torch.Tensor]]:
"""Parse a LoRA state-dict into ``{layer_name: (A, B)}`` pairs.
Supports two common key conventions:
- PEFT-style: ``...lora_A...weight`` / ``...lora_B...weight``
- Kohya-style: ``...lora_down.weight`` / ``...lora_up.weight``
``A`` is the "down" (input) projection; ``B`` is the "up" (output)
projection, matching the LoRA convention ``delta_W = B @ A``.
Keys that are not 2-D tensors are silently skipped (e.g. alpha scalars).
"""
a_keys: dict[str, str] = {}
b_keys: dict[str, str] = {}
for key in state_dict:
if state_dict[key].ndim != 2:
continue
lo = key.lower()
if "lora_a" in lo or "lora_down" in lo:
# Derive canonical layer name by stripping the LoRA suffix
base = (
key.replace(".lora_A.weight", "")
.replace(".lora_A.default.weight", "")
.replace(".lora_down.weight", "")
)
a_keys[base] = key
elif "lora_b" in lo or "lora_up" in lo:
base = (
key.replace(".lora_B.weight", "")
.replace(".lora_B.default.weight", "")
.replace(".lora_up.weight", "")
)
b_keys[base] = key
pairs: dict[str, tuple[torch.Tensor, torch.Tensor]] = {}
for base in a_keys:
if base in b_keys:
A = state_dict[a_keys[base]] # shape: (rank, in_features) → transpose → (in_features, rank)
B = state_dict[b_keys[base]] # shape: (out_features, rank)
# PEFT stores A as (rank, in), B as (out, rank).
# canonicalize_lora_factors expects A: (in, rank), B: (out, rank).
if A.shape[0] < A.shape[1]:
A = A.T
pairs[base] = (A, B)
return pairs
# ---------------------------------------------------------------------------
# Private helpers — per-layer statistics (all from canonical components)
# ---------------------------------------------------------------------------
def _layer_norm_from_canonical(cc: CanonicalComponents) -> float:
"""Frobenius norm of the canonical effective update.
Equivalent to ``torch.linalg.norm(U @ diag(S) @ Vt)``, which simplifies
to ``torch.linalg.norm(S)`` since U and Vt are orthonormal.
"""
return float(torch.linalg.norm(cc.S))
def _layer_sparsity_from_canonical(cc: CanonicalComponents) -> float:
"""Fraction of canonical singular values near zero.
A value is «near zero» when it is below
``_SPARSITY_THRESHOLD_RATIO × max(S)``.
"""
s = cc.S
if s.numel() == 0:
return 1.0
threshold = _SPARSITY_THRESHOLD_RATIO * float(s.max())
near_zero = int((s < threshold).sum())
return near_zero / s.numel()
def _layer_rank_utilization_from_canonical(cc: CanonicalComponents) -> float:
"""Effective rank / nominal rank from canonical SVD S.
Effective rank = number of singular values above the sparsity threshold.
Nominal rank = total number of singular values (= LoRA rank).
"""
s = cc.S
if s.numel() == 0:
return 0.0
threshold = _SPARSITY_THRESHOLD_RATIO * float(s.max())
effective_rank = int((s >= threshold).sum())
return effective_rank / s.numel()
def _layer_entropy_from_canonical(cc: CanonicalComponents) -> float:
"""Inverted normalized entropy of canonical singular values.
Returns a value in [0, 1] where:
- 0.0 indicates uniform singular values (healthy utilization),
- 1.0 indicates concentrated energy (rank collapse tendency).
"""
return layer_entropy_from_singular_values(cc.S)
def _per_row_entropies(cc: CanonicalComponents) -> list[float]:
"""Per-direction anomaly scores from normalized singular values.
Values are in [0, 1]; higher values indicate singular directions that
dominate layer energy.
"""
return singular_direction_anomaly_scores(cc.S)
def _u_column_norm_variance(cc: CanonicalComponents) -> float:
"""Variance of U column norms across the canonical left singular vectors."""
u = cc.U # (out_features, rank)
if u.numel() == 0:
return 0.0
col_norms = torch.linalg.norm(u, dim=0) # (rank,)
return float(col_norms.var())
def _dominant_directions(cc: CanonicalComponents, fraction: float = _DOMINANT_ENERGY_FRACTION) -> int:
"""Number of canonical singular values that capture ``fraction`` of total energy."""
s = cc.S
if s.numel() == 0:
return 0
energy = s ** 2
total = float(energy.sum())
if total < 1e-12:
return 0
cumsum = torch.cumsum(energy, dim=0)
k = int((cumsum < fraction * total).sum()) + 1
return min(k, s.numel())
# ---------------------------------------------------------------------------
# Private helpers — cross-layer statistics
# ---------------------------------------------------------------------------
def _layer_update_vector(cc: CanonicalComponents) -> np.ndarray:
"""Flatten the canonical effective update to a 1-D vector for correlation."""
# Effective update = U @ diag(S) @ Vt; we use S as a compact representation.
return cc.S.detach().cpu().float().numpy()
def _correlation_matrix(vectors: list[np.ndarray]) -> list[list[float]]:
"""Pairwise Pearson correlation matrix of layer canonical S vectors.
Vectors are zero-padded or truncated to the shortest common length before
correlation is computed, since different layers may have different ranks.
"""
if not vectors:
return []
min_len = min(len(v) for v in vectors)
mat = np.stack([v[:min_len] for v in vectors], axis=0) # (n_layers, min_len)
try:
corr = np.corrcoef(mat) # (n_layers, n_layers)
except Exception:
n = len(vectors)
corr = np.eye(n)
return [[float(corr[i, j]) for j in range(corr.shape[1])] for i in range(corr.shape[0])]
def _attention_head_specialization(
layer_pairs: dict[str, tuple[CanonicalComponents, int]],
) -> dict[str, float]:
"""Per-layer attention head specialization score.
For each attention-related layer, measures how much the canonical energy
is concentrated in a subset of singular directions relative to the number
of heads. A score near 1.0 indicates one head dominates; near 0.0
indicates uniform distribution across heads.
Args:
layer_pairs: ``{layer_name: (CanonicalComponents, n_heads)}`` where
``n_heads`` is inferred from the layer name (see implementation).
Returns:
``{layer_name: specialization_score}`` for attention layers only.
"""
result: dict[str, float] = {}
for name, (cc, n_heads) in layer_pairs.items():
if n_heads < 2:
continue
s = cc.S.detach().cpu().float().numpy()
if len(s) == 0:
continue
energy = s ** 2
total = energy.sum()
if total < 1e-12:
continue
# Fraction of energy in the top (rank // n_heads) directions
head_size = max(1, len(s) // n_heads)
top_energy = energy[:head_size].sum()
result[name] = float(top_energy / total)
return result
def _weight_magnitude_histogram(all_s_values: list[float], n_bins: int = _HISTOGRAM_BINS) -> list[float]:
"""Binned histogram of all canonical singular values (S) across layers."""
if not all_s_values:
return [0.0] * n_bins
arr = np.array(all_s_values, dtype=np.float32)
counts, _ = np.histogram(arr, bins=n_bins)
total = counts.sum()
return [float(c / total) if total > 0 else 0.0 for c in counts]
def _gradient_noise_estimate(layer_canonical: dict[str, CanonicalComponents]) -> float:
"""Estimate gradient noise from the flatness of the canonical S spectrum.
A noisy training signal causes a more uniform singular value spectrum (high
ratio of minimum to maximum S). Returns a value in [0.0, 1.0] where 1.0
is maximum estimated noise.
"""
ratios: list[float] = []
for cc in layer_canonical.values():
s = cc.S.detach().cpu().float().numpy()
if len(s) < 2 or s[0] < 1e-8:
continue
ratios.append(float(s[-1] / s[0])) # min / max (S is descending from SVD)
return float(np.mean(ratios)) if ratios else 0.0
def _overfitting_signature(
rank_utilizations: dict[str, float],
layer_norms: dict[str, float],
) -> float:
"""Overfitting signature: low rank utilization combined with high norms.
Returns a value in [0.0, 1.0] where 1.0 indicates a strong overfitting
pattern (collapsed canonical directions + inflated update norms).
"""
if not rank_utilizations:
return 0.0
mean_util = float(np.mean(list(rank_utilizations.values())))
mean_norm = float(np.mean(list(layer_norms.values()))) if layer_norms else 0.0
# Low utilization (near 0) and high norm (uncapped) → high overfitting score.
# Norm is normalised with a soft cap at 10.0.
norm_factor = float(np.clip(mean_norm / 10.0, 0.0, 1.0))
return float(np.clip((1.0 - mean_util) * norm_factor, 0.0, 1.0))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def _infer_n_heads(layer_name: str) -> int:
"""Heuristically infer the number of attention heads from a layer name."""
# Common naming patterns: attn.q_proj, self_attn.k_proj, attention.query
_ATTN_KEYWORDS = ("q_proj", "k_proj", "v_proj", "query", "key", "value", "attn")
lo = layer_name.lower()
if not any(kw in lo for kw in _ATTN_KEYWORDS):
return 0 # not an attention layer — signal to skip head specialization
# Return a reasonable default; could be overridden via config in production.
return 8
def extract_weight_signals(
weight_path: Path,
tokenizer_config_path: Optional[Path] = None,
dataset_health_summary: Optional[SyntheticWeightDescriptor] = None,
suspected_anomalous_phonemes: Optional[list[str]] = None,
) -> WeightSignalObservation:
"""Extract canonical weight statistics from a LoRA ``.safetensors`` file.
``canonicalize_lora_factors()`` is called for every LoRA (A, B) pair
before any statistics are computed. All ``canonical_*`` fields in the
returned observation are derived exclusively from the canonical SVD
components (U, S, Vt, Q), never from raw A/B matrices.
Args:
weight_path: Path to the ``.safetensors`` LoRA weight file.
tokenizer_config_path: Path to the audio tokenizer config JSON that
ships alongside the ``.safetensors`` file. Must contain a
``"token_position_to_phoneme"`` key mapping int positions to
phoneme strings. If ``None``, the field is set to ``None``.
dataset_health_summary: Optional ``SyntheticWeightDescriptor`` from
Sub-env 2 Node 6, forwarded as prior context.
suspected_anomalous_phonemes: Optional list of phonemes flagged by
Sub-env 2, forwarded as prior context.
Returns:
A fully populated :class:`WeightSignalObservation`.
Raises:
FileNotFoundError: If ``weight_path`` does not exist.
ValueError: If no valid LoRA (A, B) pairs are found in the file.
"""
weight_path = Path(weight_path)
if not weight_path.exists():
raise FileNotFoundError(f"Weight file not found: {weight_path}")
weight_file_id = weight_path.name
# ------------------------------------------------------------------
# Load state dict and parse LoRA pairs
# ------------------------------------------------------------------
state_dict: dict[str, torch.Tensor] = load_file(str(weight_path))
lora_pairs = _find_lora_pairs(state_dict)
if not lora_pairs:
raise ValueError(
f"No valid LoRA (A, B) pairs found in '{weight_path}'. "
"Check that the file contains lora_A/lora_B or lora_down/lora_up keys."
)
target_modules = sorted(lora_pairs.keys())
# Infer rank from first pair
first_A, _ = next(iter(lora_pairs.values()))
lora_rank = first_A.shape[1] # A is (in_features, rank)
total_parameters = sum(
A.numel() + B.numel() for A, B in lora_pairs.values()
)
# ------------------------------------------------------------------
# Canonicalize every (A, B) pair — all subsequent stats use cc only
# ------------------------------------------------------------------
layer_canonical: dict[str, CanonicalComponents] = {}
for name, (A, B) in lora_pairs.items():
layer_canonical[name] = canonicalize_lora_factors(A.float(), B.float())
# ------------------------------------------------------------------
# Layer-wise statistics (all from canonical components)
# ------------------------------------------------------------------
layer_norms: dict[str, float] = {}
layer_sparsity: dict[str, float] = {}
layer_rank_utilization: dict[str, float] = {}
canonical_entropy_per_layer: dict[str, float] = {}
# Collect all S values for histogram and per-direction anomaly scores
all_s_values: list[float] = []
all_row_entropies: list[tuple[int, float]] = [] # (position_idx, anomaly_score)
for name, cc in layer_canonical.items():
layer_norms[name] = _layer_norm_from_canonical(cc)
layer_sparsity[name] = _layer_sparsity_from_canonical(cc)
layer_rank_utilization[name] = _layer_rank_utilization_from_canonical(cc)
canonical_entropy_per_layer[name] = _layer_entropy_from_canonical(cc)
all_s_values.extend(cc.S.detach().cpu().float().tolist())
for row_idx, h in enumerate(_per_row_entropies(cc)):
all_row_entropies.append((row_idx, h))
# ------------------------------------------------------------------
# High-entropy token positions (anomalous singular directions across layers)
# ------------------------------------------------------------------
if all_row_entropies:
entropies_only = np.array([e for _, e in all_row_entropies], dtype=np.float32)
mu = float(entropies_only.mean())
sigma = float(entropies_only.std())
threshold = max(mu + _ENTROPY_ANOMALY_SIGMA * sigma, 0.1)
high_entropy_token_positions: list[int] = sorted(
set(
pos
for pos, h in all_row_entropies
if h > threshold
)
)
else:
high_entropy_token_positions = []
# ------------------------------------------------------------------
# Token-to-phoneme mapping (from tokenizer config, NOT from weights)
# ------------------------------------------------------------------
token_position_to_phoneme: Optional[dict[int, str]] = None
if tokenizer_config_path is not None:
tokenizer_config_path = Path(tokenizer_config_path)
with tokenizer_config_path.open("r", encoding="utf-8") as fh:
tok_cfg = json.load(fh)
raw_map: dict = tok_cfg.get("token_position_to_phoneme", {})
token_position_to_phoneme = {int(k): str(v) for k, v in raw_map.items()}
# ------------------------------------------------------------------
# Canonical U-component statistics (aggregated across all layers)
# ------------------------------------------------------------------
u_norm_variances = [_u_column_norm_variance(cc) for cc in layer_canonical.values()]
canonical_output_norm_variance = float(np.mean(u_norm_variances)) if u_norm_variances else 0.0
dominant_per_layer = [_dominant_directions(cc) for cc in layer_canonical.values()]
canonical_dominant_directions = int(np.mean(dominant_per_layer)) if dominant_per_layer else 0
# ------------------------------------------------------------------
# Cross-layer correlation matrix (over canonical S vectors)
# ------------------------------------------------------------------
layer_vectors = [_layer_update_vector(cc) for cc in layer_canonical.values()]
layer_correlation_matrix = _correlation_matrix(layer_vectors)
# ------------------------------------------------------------------
# Attention head specialization
# ------------------------------------------------------------------
attn_pairs: dict[str, tuple[CanonicalComponents, int]] = {
name: (cc, _infer_n_heads(name))
for name, cc in layer_canonical.items()
if _infer_n_heads(name) >= 2
}
attention_head_specialization = _attention_head_specialization(attn_pairs)
# ------------------------------------------------------------------
# Training quality signals
# ------------------------------------------------------------------
weight_magnitude_histogram = _weight_magnitude_histogram(all_s_values)
gradient_noise_estimate = _gradient_noise_estimate(layer_canonical)
overfitting_sig = _overfitting_signature(layer_rank_utilization, layer_norms)
# ------------------------------------------------------------------
# Assemble observation
# ------------------------------------------------------------------
return WeightSignalObservation(
weight_file_id=weight_file_id,
lora_rank=lora_rank,
target_modules=target_modules,
total_parameters=total_parameters,
# Layer-wise (canonical)
layer_norms=layer_norms,
layer_sparsity=layer_sparsity,
layer_rank_utilization=layer_rank_utilization,
# Canonical Vt analysis
canonical_entropy_per_layer=canonical_entropy_per_layer,
high_entropy_token_positions=high_entropy_token_positions,
# Token-to-phoneme (from tokenizer config only)
token_position_to_phoneme=token_position_to_phoneme,
# Canonical U analysis
canonical_output_norm_variance=canonical_output_norm_variance,
canonical_dominant_directions=canonical_dominant_directions,
# Cross-layer patterns
layer_correlation_matrix=layer_correlation_matrix,
attention_head_specialization=attention_head_specialization,
# Training quality
weight_magnitude_histogram=weight_magnitude_histogram,
gradient_noise_estimate=gradient_noise_estimate,
overfitting_signature=overfitting_sig,
# Sub-env 2 context
dataset_health_summary=dataset_health_summary,
suspected_anomalous_phonemes=suspected_anomalous_phonemes,
)