From 02ffc33c8a660deb154e4e3030f603913091bc1e Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:15:03 -0400 Subject: [PATCH 01/18] Clean up main README --- README.md | 100 +++++++++++++++--------------------------------------- 1 file changed, 27 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index a37dfd52..63e07062 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,18 @@ Neural network alignment analysis and intelligent pruning framework. ---- - ## Overview This framework provides tools for analyzing neural networks through information-theoretic metrics and performing redundancy-aware pruning and quantization. -**Key capabilities:** -- Alignment metrics (Rayleigh Quotient, class-conditioned RQ, mutual information) -- Information-theoretic analysis (redundancy, synergy, PID) -- Pruning strategies (16 methods including magnitude, gradient-based, redundancy-aware) -- Quantization (INT8, INT4, mixed-precision with alignment-guided bit allocation) -- Architecture support (MLPs, CNNs, Transformers, LLMs) -- Data loading (vision and text datasets) -- Evaluation (classification accuracy, perplexity for language models) -- Visualization (plots and reports) - ---- +Key capabilities: +- Alignment metrics for analyzing neural network weight-input relationships +- Information-theoretic analysis tools +- Pruning strategies with multiple scoring methods +- Quantization with alignment-guided precision selection +- Architecture support for MLPs, CNNs, Transformers, and LLMs +- Data loading for vision and text datasets +- Evaluation and visualization tools ## Installation @@ -30,79 +25,40 @@ conda activate alignment pip install -e . ``` -Details: [docs/installation.md](docs/installation.md) - ---- +See [docs/installation.md](docs/installation.md) for details. ## Usage -### Command Line +Run experiments using configuration files: ```bash python scripts/run_experiment.py --config configs/examples/mnist_basic.yaml -python scripts/run_experiment.py --config configs/examples/resnet_pruning.yaml -python scripts/run_experiment.py --config configs/examples/llama3_scoring.yaml -python scripts/run_experiment.py --config configs/examples/llama3_quantization.yaml -``` - -### Python API - -```python -from alignment import ModelWrapper, get_metric - -wrapper = ModelWrapper(model) -rq = get_metric('rayleigh_quotient') - -outputs, acts = wrapper.forward_with_activations(inputs) -weights = wrapper.get_layer_weights() - -scores = rq.compute(acts['layer_input'], weights['layer']) ``` -### Quantization - -```python -from alignment.quantization import quantize_model, find_optimal_bit_allocation +The framework supports: +- Training networks from scratch or loading pre-trained models +- Computing alignment and information-theoretic scores +- Applying pruning with different strategies and distributions +- Quantization with various precision settings -# INT8 quantization -results = quantize_model(model, precision='int8') +Example configurations are available in `configs/examples/`: +- `mnist_basic.yaml` - Basic alignment analysis on MNIST +- `resnet_pruning.yaml` - Pruning ResNet on CIFAR-10 +- `llama3_scoring.yaml` - Computing scores for LLaMA models +- `llama3_pruning.yaml` - Pruning transformer networks -# Mixed precision using alignment scores -layer_importance = {layer: rq_scores[layer].mean() for layer in layers} -bit_allocation = find_optimal_bit_allocation(model, layer_importance, target_avg_bits=6.0) -``` - ---- +See `configs/template.yaml` for all available configuration options. ## Documentation -- [Installation](docs/installation.md) - Setup and dependencies -- [Usage Guide](docs/usage.md) - Running experiments -- [User Guide](docs/user_guide.md) - Complete documentation -- [API Reference](docs/api_reference.md) - API details +- [Usage Guide](docs/usage.md) - Running experiments with configs +- [User Guide](docs/user_guide.md) - Detailed framework documentation +- [API Reference](docs/api_reference.md) - API documentation - [Quick Reference](docs/quick_reference.md) - Code examples -- [Changelog](docs/changelog.md) - Version history - -Full documentation: [docs/README.md](docs/README.md) - ---- - -## Configuration -Experiments are configured via YAML files. See `configs/template.yaml` for all parameters. +## Examples -Available examples in `configs/examples/`: -- mnist_basic.yaml -- resnet_pruning.yaml -- llama3_scoring.yaml -- llama3_pruning.yaml -- llama3_quantization.yaml - ---- - -## Code Examples - -Python examples in `examples/` directory: +Python scripts demonstrating framework capabilities: ```bash python examples/07_mnist_intelligent_pruning.py @@ -110,7 +66,7 @@ python examples/08_llama_ffn_pruning.py python examples/09_attention_neuron_vs_head_pruning.py ``` ---- + ## Testing @@ -118,8 +74,6 @@ python examples/09_attention_neuron_vs_head_pruning.py pytest tests/ ``` ---- - ## License See LICENSE file. From de8da9df846273b713a4965ddae4c5852414f0aa Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:18:08 -0400 Subject: [PATCH 02/18] Update user guide with comprehensive metrics and pruning docs --- docs/user_guide.md | 636 +++++++++++++++++++++++++++------------------ 1 file changed, 383 insertions(+), 253 deletions(-) diff --git a/docs/user_guide.md b/docs/user_guide.md index ebc8b3b4..217406c4 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -1,63 +1,128 @@ # Alignment Framework User Guide -**Comprehensive guide to using the alignment framework for neural network analysis and pruning** - ---- +Comprehensive guide to neural network alignment analysis and pruning. ## Table of Contents 1. [Core Concepts](#core-concepts) -2. [Computing Metrics](#computing-metrics) -3. [Pruning Strategies](#pruning-strategies) -4. [Architecture Support](#architecture-support) -5. [Configuration](#configuration) -6. [Advanced Features](#advanced-features) - ---- +2. [Available Metrics](#available-metrics) +3. [Computing Metrics](#computing-metrics) +4. [Pruning Strategies](#pruning-strategies) +5. [Architecture Support](#architecture-support) +6. [Configuration](#configuration) ## Core Concepts -### Rayleigh Quotient (RQ) +### Alignment -Measures how well neuron weights align with input principal components: +Alignment measures how well neuron weights align with the structure of their input activations. The Rayleigh Quotient quantifies the proportion of input variance captured by each neuron's weight vector. +For weight vector w and input covariance Σ: ``` RQ(w) = (w^T Σ w) / (w^T w · tr(Σ)) ``` -Higher RQ indicates better alignment with dominant input variance. +Higher RQ values indicate the neuron aligns with dominant input variance directions. -### Class-Conditioned RQ (ΔRQ) +### Class-Conditioned Alignment -Measures task-relevant alignment: +Class-conditioned analysis compares alignment across different classes versus overall alignment. The difference (ΔRQ) indicates task-relevant alignment: ``` ΔRQ = RQ(overall) - E[RQ(class-conditioned)] ``` -Positive ΔRQ indicates the neuron captures discriminative features. +Positive ΔRQ means the neuron captures discriminative features between classes. -### Redundancy +### Information Theory -Measures overlap between neuron pairs: +The framework uses information-theoretic measures to quantify relationships between neurons: -``` -R(i,j) = I(Y_i; Y_j) = -0.5 · log(1 - ρ²) -``` +- **Mutual Information**: Shared information between two variables +- **Redundancy**: Information overlap between neuron pairs +- **Synergy**: Information that emerges only from joint neuron outputs +- **Partial Information Decomposition (PID)**: Decomposes information into unique, redundant, and synergistic components -High redundancy means neurons capture similar information. +## Available Metrics -### Synergy +### Alignment Metrics -Measures complementary information: +**Rayleigh Quotient (RQ)** +- Measures alignment with input covariance structure +- Returns per-neuron scores indicating variance captured +- Supports relative normalization and regularization +- Works with linear, convolutional, and transformer layers -``` -S(Z; Y_i, Y_j) = I(Z; Y_i, Y_j) - I(Z; Y_i) - I(Z; Y_j) + min(I(Z; Y_i), I(Z; Y_j)) -``` +**Class-Conditioned RQ** +- Computes RQ separately for each class +- Returns ΔRQ showing task-relevant alignment +- Requires labeled data + +**Spectral Alignment** +- Analyzes eigenvalue decomposition of weight-covariance product +- Provides detailed spectral properties + +### Information-Theoretic Metrics + +**Mutual Information (MI)** +- Gaussian MI using analytic formula: MI = -0.5 log(1 - ρ²) +- Can compute MI between neuron outputs and targets +- Supports both continuous and categorical targets + +**Pairwise Redundancy** +- Computes redundancy between neuron pairs +- Output-based mode for efficiency with large models +- Covariance-based mode for precise estimates +- Sampling strategies: random, nearest, or all pairs +- Returns per-neuron average redundancy scores + +**Synergy (MMI)** +- Measures complementary information from neuron pairs +- Uses Minimum Mutual Information redundancy definition +- Returns per-neuron synergy scores +- Requires target labels + +**Partial Information Decomposition (PID)** +- Decomposes information into unique, redundant, and synergistic parts +- Shared information: overlap between two neurons +- Unique information: contribution from single neurons +- Synergistic information: emergent from joint outputs + +**Higher-Order Metrics** +- Total correlation: generalization of MI to multiple variables +- Interaction information: higher-order dependencies +- Connected information: network-level measures + +### Gradient-Based Metrics -High synergy means neurons provide unique joint information. +**Gradient Alignment** +- Compares gradient-based learning with local learning rules +- Analyzes alignment between backpropagation and Hebbian-style updates +- Useful for biologically-plausible learning analysis ---- +**Local Learning Rule Search** +- Finds optimal local learning rules per neuron +- Searches over combinations of pre/post-synaptic activity +- Returns best-fit local rule for each neuron + +### Task-Specific Metrics + +**Classification Alignment** +- Measures discriminative power for classification tasks +- Analyzes separation of class representations + +**Vision Task Alignment** +- Specialized for vision architectures +- Analyzes feature selectivity patterns + +### Similarity Metrics + +**Centered Kernel Alignment (CKA)** +- Compares representations between layers or models +- Invariant to isotropic scaling + +**Canonical Correlation Analysis (CCA)** +- Finds maximally correlated directions between representations ## Computing Metrics @@ -66,13 +131,13 @@ High synergy means neurons provide unique joint information. ```python from alignment import ModelWrapper, get_metric -# Wrap model +# Wrap model to track layers wrapper = ModelWrapper(model, tracked_layers=['conv1', 'fc1']) -# Get metric +# Get metric instance rq_metric = get_metric('rayleigh_quotient') -# Capture activations +# Forward pass with activation capture outputs, acts = wrapper.forward_with_activations(inputs) weights = wrapper.get_layer_weights() @@ -83,26 +148,6 @@ scores = rq_metric.compute( ) ``` -### Available Metrics - -**Alignment:** -- `rayleigh_quotient` - Standard RQ -- `delta_alignment` - Change in alignment over training - -**Information-Theoretic:** -- `gaussian_mi_analytic` - Mutual information (Gaussian approximation) -- `pairwise_redundancy_gaussian` - Per-neuron redundancy -- `synergy_gaussian_mmi` - Per-neuron synergy -- `conditional_mutual_information` - Conditional MI - -**Gradient-Based:** -- `gradient_alignment` - Alignment between local signal and backprop -- `local_learning_rule_search` - Find optimal local rules per neuron - -**Task-Specific:** -- `classification_alignment` - Classification-specific importance -- `vision_task_alignment` - Vision-specific patterns - ### Composite Scoring Combine multiple metrics for robust importance estimation: @@ -113,7 +158,7 @@ from alignment.services import NodeScoringService scorer = NodeScoringService( metrics={ 'rq': get_metric('rayleigh_quotient'), - 'redundancy': get_metric('pairwise_redundancy_gaussian', mode='output_based'), + 'redundancy': get_metric('pairwise_redundancy_gaussian'), 'synergy': get_metric('synergy_gaussian_mmi') }, alpha_mi=0.0, @@ -123,290 +168,375 @@ scorer = NodeScoringService( ) scores = scorer.compute_composite_scores(inputs, weights, targets) -# score = β·synergy - γ·redundancy + δ·log(RQ) ``` ---- +The composite score combines: synergy (positive), redundancy (negative), and alignment. ## Pruning Strategies -### Available Strategies +### Overview -**Basic:** -- `magnitude` - L1/L2 norm -- `random` - Random baseline -- `gradient` - Gradient-based -- `fisher` - Fisher information +Pruning removes less important network parameters to reduce model size and computational cost. The framework provides multiple strategies based on different importance criteria. -**Alignment-Based:** -- `alignment` - RQ-based structured pruning -- `global_alignment` - Global threshold across layers -- `hybrid` - Magnitude + Alignment combination +### Magnitude-Based Strategies -**Advanced:** -- `movement` - Prune weights moving toward zero (training-aware) -- `adaptive` - Adaptive per-layer amounts based on sensitivity -- `ultimate` - Multi-stage combining all best practices +**MagnitudePruning** +- Prunes weights with smallest absolute values +- Fast and simple baseline +- Works for unstructured and structured pruning +- Applies L1 or L2 norm for importance scores -**Novel:** -- `composite` - Redundancy-aware (preserves synergistic neurons) +**IterativeMagnitudePruning** +- Applies magnitude pruning in multiple rounds +- Allows network to recover between pruning steps +- Typically achieves better accuracy than one-shot pruning -### Distribution Across Layers +**GlobalMagnitudePruning** +- Applies single threshold across all layers +- Natural adaptation to layer sensitivity +- Some layers may be pruned more than others -**Uniform:** Same percentage per layer -```python -# 70% removed from each layer -``` +### Gradient-Based Strategies -**Global Threshold:** Single threshold across all layers -```python -# Naturally varying amounts based on score distribution -``` +**GradientPruning** +- Uses gradient magnitude as importance signal +- Can use gradient alone or gradient-weight product (Taylor approximation) +- Requires backward pass to compute gradients -**Adaptive Sensitivity:** Per-layer amounts based on importance -```python -# Sensitive layers: 50% -# Robust layers: 85% -# Overall: 70% average -``` +**FisherPruning** +- Uses Fisher information for importance +- Accounts for both gradient and Hessian information +- More accurate but computationally expensive -### Using the Orchestrator +**MomentumPruning** +- Incorporates momentum in importance computation +- Smooths importance over training iterations -```python -from alignment.pruning.orchestrator import prune_with_all_options - -result = prune_with_all_options( - model, - target_sparsity=0.7, - - # Distribution: how to allocate across layers - # Options: 'uniform', 'global_threshold', 'adaptive_sensitivity', - # 'importance_weighted', 'cascading', 'size_proportional', 'hybrid' - distribution='adaptive_sensitivity', - - # Scoring: what importance metric - # Options: 'magnitude', 'rayleigh_quotient', 'composite', 'movement' - scoring='composite', - - # Direction: which neurons to remove - # Options: 'low' (remove unimportant), 'high' (ablation), 'random' (baseline) - direction='low', - - # Data - train_loader=train_loader, # For fine-tuning - val_loader=val_loader, # For evaluation - trainer_fn=train_function, # Training function - eval_fn=eval_function, # Evaluation function - - # Options - fine_tune_epochs=20 -) -``` +### Alignment-Based Strategies ---- +**AlignmentPruning** +- Uses Rayleigh Quotient or other alignment metrics +- Structured pruning (removes entire neurons/channels) +- Considers input-weight relationships -## Architecture Support +**GlobalAlignmentPruning** +- Global threshold across layers based on alignment +- Automatically handles layer-wise sensitivity -### MLPs +**HybridPruning** +- Combines magnitude and alignment scores +- Balances simple magnitude with structural alignment -```python -layer = model.fc1 # Linear(784, 256) -scores = rq.compute(inputs, layer.weight) -# [256] - one score per neuron -``` +**CascadingAlignmentPruning** +- Prunes layers sequentially +- Recomputes alignment after each layer +- Accounts for pruning effects on downstream layers -### CNNs +### Random Strategies -```python -conv = model.conv1 # Conv2d(64, 128, 3, 3) -scores = rq.compute(inputs, conv.weight) -# [128] - one score per channel +**RandomPruning** +- Prunes weights randomly +- Baseline for comparison +- Useful for ablation studies -# Dependency-aware pruning automatically handles channel propagation -``` +**LayerwiseRandomPruning** +- Random pruning with per-layer amounts -### Transformers & LLMs +**BernoulliPruning** +- Stochastic pruning with Bernoulli sampling -```python -from alignment.models.transformer_enhanced import LLaMAWrapper +### Parallel Strategies -wrapper = LLaMAWrapper(llama_model, track_ffn=True) +**ParallelModePruning** +- Applies multiple pruning modes simultaneously (low/high/random) +- Returns separate masks for each mode +- Useful for comparing ablations -# FFN neurons: up_proj has 11,008 neurons -ffn_up = model.model.layers[0].mlp.up_proj -scores = rq.compute(inputs, ffn_up.weight) -# [11008] - one score per FFN neuron +**TensorizedPruning** +- GPU-optimized parallel execution +- Efficient for multiple strategies -# Attention: Can analyze per-head or per-neuron -``` +**AsyncParallelPruning** +- Asynchronous parallel execution +- For distributed pruning experiments ---- +### Advanced Strategies -## Configuration +**MovementPruning** +- Tracks weight movement during training +- Prunes weights moving toward zero +- Training-aware strategy -### Quick Start Config +**AdaptiveMovementPruning** +- Adapts pruning amount per layer based on movement patterns -```yaml -# configs/quickstart.yaml -experiment: - name: "quickstart" +### Pruning Distribution -model: - name: "resnet18" - pretrained: true +Distribution strategies determine how pruning is allocated across layers. -dataset: - name: "cifar10" - batch_size: 128 +**Uniform Distribution** +- Same sparsity percentage for all layers +- Simple and predictable +- May not respect layer-specific sensitivity -metrics: - enabled: ['rayleigh_quotient'] -``` +**Global Threshold** +- Single importance threshold across all layers +- Natural adaptation based on score distributions +- Different layers pruned by different amounts -### Complete Options +**Adaptive Sensitivity** +- Per-layer pruning amounts based on sensitivity analysis +- Sensitive layers pruned less +- Robust layers pruned more +- Maintains target overall sparsity -See `configs/template_master_v2.yaml` for all parameters with documentation. +**Importance Weighted** +- Allocates pruning inversely to layer importance +- More important layers retain more parameters -Key sections: -- `experiment`: Name, seed, device, output -- `model`: Architecture, pretrained, layers to track -- `dataset`: Data source, batch size, augmentation -- `metrics`: Which metrics to compute and their parameters -- `pruning`: Strategy, distribution, scoring, fine-tuning -- `training`: Training parameters, metric tracking -- `advanced`: Backend selection, parallelization +**Size Proportional** +- Pruning amount scales with layer size +- Larger layers can tolerate more pruning ---- +**Cascading** +- Sequential layer-by-layer pruning +- Recomputes scores after each layer -## Advanced Features +### Pruning Modes -### Training-Time Metrics +**Low Mode** (default) +- Prunes weights/neurons with lowest importance scores +- Standard pruning approach -Track alignment evolution during training with zero overhead: +**High Mode** +- Prunes weights/neurons with highest importance scores +- Used for ablation studies to test importance hypotheses -```python -from alignment.training.callbacks import AlignmentMetricsCallback +**Random Mode** +- Random pruning regardless of scores +- Baseline for comparison -callback = AlignmentMetricsCallback( - metrics={'rq': get_metric('rayleigh_quotient')}, - layers=['conv1', 'fc1'], - frequency=100 # Every 100 steps -) +### Structured vs Unstructured + +**Unstructured Pruning** +- Prunes individual weights +- Arbitrary sparsity patterns +- Requires sparse tensor support for speedup -# In training loop: -for inputs, targets in train_loader: - outputs = model(inputs) - loss.backward() - optimizer.step() - - callback.on_batch_end(wrapper, inputs, targets, global_step) +**Structured Pruning** +- Prunes entire neurons or channels +- Maintains dense tensor operations +- Immediate speedup without specialized hardware +- Handles dependencies automatically -# Analyze evolution -history = callback.get_history() +### Using Configuration Files + +Specify pruning in YAML configuration: + +```yaml +pruning: + enabled: true + strategy: 'composite' + target_sparsity: 0.7 + distribution: 'adaptive_sensitivity' + scoring: 'rayleigh_quotient' + direction: 'low' + structured: true + dependency_aware: true + + fine_tune: + enabled: true + epochs: 20 + learning_rate: 0.0001 + + composite_weights: + beta_synergy: 0.3 + gamma_redundancy: 0.4 + delta_rq: 0.3 ``` -### Gradient-Based Local Learning +Then run: +```bash +python scripts/run_experiment.py --config configs/my_pruning.yaml +``` + +## Architecture Support + +### Linear Layers (MLPs) -Design bio-plausible learning rules: +For fully-connected layers, metrics compute per-neuron scores: ```python -from alignment.metrics.gradient_based import LocalLearningRuleSearch +layer = model.fc1 # Linear(in_features=784, out_features=256) +scores = rq.compute(inputs, layer.weight) # Returns [256] +``` -searcher = LocalLearningRuleSearch() +Each neuron receives an importance score. Structured pruning removes entire neurons. -# After backward pass: -best_rules = searcher.compute( - inputs, outputs, - gradients=layer.weight.grad -) -# Returns best local rule per neuron +### Convolutional Layers (CNNs) + +For convolutional layers, metrics operate on channels: + +```python +conv = model.conv1 # Conv2d(in_channels=64, out_channels=128, kernel_size=3) +scores = rq.compute(inputs, conv.weight) # Returns [128] ``` -### Pairwise Metrics +Each output channel receives a score. The framework handles: +- Spatial weight dimensions automatically +- Different conv modes: unfold, patchwise, channel variance +- Dependency propagation between layers -Any pairwise metric can aggregate to single-neuron scores: +**CNN-Specific Modes:** +- `unfold`: Unfolds spatial dimensions into feature vectors +- `patchwise`: Treats each spatial location separately +- `channel_variance`: Computes variance across spatial dimensions +- `batch_patch_combined`: Combines batch and spatial statistics +### Transformer Layers + +For transformer architectures, the framework supports: + +**Feed-Forward Networks (FFN)** ```python -redundancy = get_metric('pairwise_redundancy_gaussian', - mode='output_based', # Fast! - num_pairs=10, # Sample 10 partners - aggregation='mean') # How to aggregate +ffn_layer = model.layers[0].mlp.up_proj +scores = rq.compute(inputs, ffn_layer.weight) +``` -scores = redundancy.compute(outputs=layer_outputs) -# [N] - per-neuron redundancy +Each FFN neuron receives a score. For LLaMA models, typical FFN dimensions are 11,008 neurons. -# Or get full matrix: -matrix = redundancy.compute(outputs, return_matrix=True) -# [N, N] - all pairwise relationships +**Attention Layers** +```python +# Per-head analysis +wrapper = TransformerWrapper(model, track_per_head=True) + +# Per-neuron within attention projections +q_proj = model.layers[0].self_attn.q_proj +scores = rq.compute(inputs, q_proj.weight) ``` -### Dependency-Aware Pruning +Can analyze: +- Query, Key, Value projections separately +- Per-head importance +- Per-neuron importance within projections +- Attention output projections + +**Aggregation Options:** +- `sequence_mean`: Average over sequence length +- `token_level`: Per-token analysis -Automatically handles inter-layer dependencies: +### Large Language Models + +The framework provides specialized wrappers for LLMs: ```python -from alignment.pruning.dependency_aware import prune_model_with_dependencies +from alignment.models.transformer_enhanced import LLaMAWrapper -result = prune_model_with_dependencies( - model, - layer_scores={'conv1': scores1, 'conv2': scores2}, - amount=0.5, - verbose=True +wrapper = LLaMAWrapper( + llama_model, + track_ffn=True, + track_attention=True ) +``` -# Automatically propagates: -# conv1.out_channels → conv2.in_channels -# Maintains shape compatibility +Supports analysis and pruning of: +- FFN intermediate layers (up_proj, down_proj) +- Attention projections (q_proj, k_proj, v_proj, o_proj) +- Layer-wise or model-wide analysis + +## Configuration + +Experiments are configured using YAML files. The framework provides a template with all available options. + +### Basic Configuration + +```yaml +experiment: + name: "my_experiment" + seed: 42 + device: "cuda" + output_dir: "./results" + +model: + name: "resnet18" + pretrained: true + tracked_layers: null # Auto-detect + +dataset: + name: "cifar10" + data_path: "./data" + batch_size: 128 + +metrics: + enabled: ['rayleigh_quotient'] ``` ---- +### Configuration Sections + +**experiment** +- Experiment name, random seed, device selection +- Output directory for results + +**model** +- Model architecture name or path +- Whether to use pretrained weights +- Checkpoint path for custom models +- Which layers to track for analysis -## Performance +**dataset** +- Dataset name and path +- Batch size and data loading workers +- Data augmentation settings -### Computation Time +**metrics** +- List of metrics to compute +- Per-metric configuration parameters +- Regularization and sampling settings -| Model Scale | Per-Layer Time | Throughput | -|-------------|----------------|------------| -| Small (N=256) | ~28ms | 35 layers/sec | -| Medium (N=1024) | ~105ms | 10 layers/sec | -| Large (N=4096) | ~440ms | 2.3 layers/sec | +**pruning** +- Pruning strategy and target sparsity +- Distribution method across layers +- Scoring method for importance +- Structured vs unstructured +- Fine-tuning configuration -### Speedup Techniques +**training** +- Training parameters if training from scratch +- Learning rate, optimizer, scheduler +- Metric tracking during training -- **Output-based redundancy**: 30x faster for large models -- **Shared computation**: 2-3x when computing multiple metrics -- **Parallel strategies**: M strategies in ~1.3x time +**advanced** +- Covariance computation methods +- Hook management settings +- Layer-specific configurations ---- +See `configs/template.yaml` for complete documentation of all parameters. ## Testing +Run tests to verify installation and functionality: + ```bash -# Run all tests +# All tests pytest tests/ -# Scientific correctness validation -python tests/unit/metrics/test_scientific_correctness.py +# Specific test modules +pytest tests/unit/metrics/ +pytest tests/unit/pruning/ -# Specific module -pytest tests/unit/services/ +# Scientific correctness validation +pytest tests/unit/metrics/test_scientific_correctness.py ``` ---- - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Add tests for new features -4. Submit pull request - ---- +## Examples -## Support +The `examples/` directory contains scripts demonstrating framework capabilities: -- **Documentation**: See guides in repository root -- **Examples**: `examples/` directory -- **Issues**: GitHub issues -- **API Reference**: Run `cd docs && make html` +- `01_quick_start.py` - Basic usage +- `02_complete_experiment.py` - Full experiment workflow +- `03_pruning_strategies.py` - Pruning strategy comparison +- `06_redundancy_aware_pruning.py` - Information-theoretic pruning +- `07_mnist_intelligent_pruning.py` - MNIST pruning example +- `08_llama_ffn_pruning.py` - LLM feed-forward pruning +- `09_attention_neuron_vs_head_pruning.py` - Attention layer analysis From fbb441f76cfb2fed10935fc8f5a24cb5ec03fdd0 Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:28:37 -0400 Subject: [PATCH 03/18] Clean up usage guide --- docs/usage.md | 190 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 132 insertions(+), 58 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index b531e2a7..e86eae76 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,61 +1,48 @@ -# How to Run Experiments with YAML Configs +# Usage Guide -## Quick Start +## Running Experiments -### Step 1: Activate Environment +The framework uses YAML configuration files to specify experiments. This approach allows reproducible experiments and easy parameter management. + +### Basic Usage ```bash conda activate alignment cd /path/to/alignment -``` - -### Step 2: Run Experiment - -```bash python scripts/run_experiment.py --config configs/examples/mnist_basic.yaml ``` ---- - -## Available Example Configs +### Example Configurations -### 1. MNIST Basic Analysis +The `configs/examples/` directory contains pre-configured experiments: +**MNIST Basic Analysis** ```bash python scripts/run_experiment.py --config configs/examples/mnist_basic.yaml ``` +Trains an MLP on MNIST and computes alignment scores. -Computes: RQ scores for simple MLP on MNIST - -### 2. ResNet Pruning - +**ResNet Pruning** ```bash python scripts/run_experiment.py --config configs/examples/resnet_pruning.yaml ``` +Applies pruning to ResNet-18 on CIFAR-10 using alignment-based importance scores. -Performs: Redundancy-aware pruning on ResNet-18 with CIFAR-10 - -### 3. LLaMA-3 Scoring - +**LLaMA-3 Scoring** ```bash python scripts/run_experiment.py --config configs/examples/llama3_scoring.yaml ``` +Computes per-neuron importance scores for LLaMA model feed-forward layers. -Computes: Per-neuron importance scores for LLaMA-3 FFN - -### 4. LLaMA-3 Pruning - +**LLaMA-3 Pruning** ```bash python scripts/run_experiment.py --config configs/examples/llama3_pruning.yaml ``` - -Performs: Redundancy-aware pruning of LLaMA-3 model - ---- +Prunes LLaMA model using information-theoretic importance scores. ## Command-Line Overrides -Override any parameter: +Override configuration parameters from the command line: ```bash python scripts/run_experiment.py \ @@ -65,62 +52,149 @@ python scripts/run_experiment.py \ --target-sparsity 0.5 ``` -Common overrides: -- `--device cuda:0` - GPU selection -- `--batch-size 64` - Batch size -- `--target-sparsity 0.7` - Pruning amount -- `--epochs 50` - Training epochs -- `--output-dir ./my_results` - Output directory +Common override options: +- `--device cuda:0` - Select GPU device +- `--batch-size 64` - Set batch size +- `--target-sparsity 0.7` - Set pruning target +- `--epochs 50` - Set training epochs +- `--output-dir ./results` - Set output directory ---- +## Creating Custom Configurations -## Creating Custom Configs +### From Template -### Method 1: Copy and Modify Template +Copy the template and modify for your needs: ```bash cp configs/template.yaml configs/my_experiment.yaml -# Edit my_experiment.yaml +# Edit my_experiment.yaml with desired parameters python scripts/run_experiment.py --config configs/my_experiment.yaml ``` -### Method 2: Copy Existing Example +### From Existing Example + +Start with an example configuration: ```bash cp configs/examples/resnet_pruning.yaml configs/my_resnet.yaml -# Modify my_resnet.yaml +# Modify specific parameters python scripts/run_experiment.py --config configs/my_resnet.yaml ``` ---- - -## Config Structure +## Configuration Structure -All configs have the same structure: +All configuration files follow the same structure: ```yaml -experiment: # Experiment settings - name: "..." - seed: 42 +```yaml +experiment: + name: "my_experiment" device: "cuda" - -model: # Model architecture - name: "..." # 'resnet18', 'mlp', 'meta-llama/...' + +model: + name: "resnet18" pretrained: true - -dataset: # Data source - name: "..." # 'mnist', 'cifar10', 'wikitext' + +dataset: + name: "cifar10" batch_size: 128 -metrics: # Metrics to compute +metrics: enabled: ['rayleigh_quotient'] -pruning: # Pruning settings (optional) +pruning: + enabled: false +``` + +See `configs/template.yaml` for all available parameters. + +## Experiment Types + +### Computing Metrics + +Compute alignment and information-theoretic scores: + +```yaml +metrics: + enabled: ['rayleigh_quotient', 'pairwise_redundancy_gaussian', 'synergy_gaussian_mmi'] + + rayleigh_quotient: + relative: true + regularization: 1.0e-6 + + pairwise_redundancy_gaussian: + mode: 'output_based' + num_pairs: 10 + +training: + enabled: false +pruning: + enabled: false +``` + +### Training Networks + +Train from scratch with optional metric tracking: + +```yaml +training: enabled: true - strategy: '...' + epochs: 100 + learning_rate: 0.001 + optimizer: 'adam' + compute_metrics_during_training: false +``` + +### Pruning Networks + +Apply pruning with specified strategy: + +```yaml +pruning: + enabled: true + strategy: 'composite' target_sparsity: 0.7 + distribution: 'adaptive_sensitivity' + scoring: 'rayleigh_quotient' + structured: true + + fine_tune: + enabled: true + epochs: 20 + learning_rate: 0.0001 +``` + +### Multi-Level Pruning + +Test multiple sparsity levels: + +```yaml +pruning: + enabled: true + sparsity_levels: [0.3, 0.5, 0.7, 0.9] + strategy: 'magnitude' +``` -# ... other sections as needed +## Output Structure + +Results are saved to the specified output directory: + +``` +results/[experiment_name]/ +├── config.yaml # Configuration used +├── results.json # Numerical results +├── scores/ # Per-layer importance scores +├── plots/ # Visualizations +└── checkpoints/ # Model checkpoints +``` + +## Workflow + +1. Create or select configuration file +2. Activate environment: `conda activate alignment` +3. Run experiment: `python scripts/run_experiment.py --config [path]` +4. Results saved to output directory +5. Analyze results and visualizations ``` See `configs/template.yaml` for complete parameter reference. From eddbb740c75ec4f4c3c92d5070098c9180ae0fee Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:30:39 -0400 Subject: [PATCH 04/18] Clean up API reference formatting --- docs/api_reference.md | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/docs/api_reference.md b/docs/api_reference.md index ed32cedc..22e4d492 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -1,8 +1,6 @@ # API Reference -**Quick reference for alignment framework APIs** - ---- +Quick reference for framework APIs. ## Core Classes @@ -35,8 +33,6 @@ metric.requires_outputs # bool: needs layer outputs metric.compute(inputs, weights, outputs, **kwargs) # Returns scores ``` ---- - ## Metrics ### Rayleigh Quotient @@ -102,8 +98,6 @@ alignment = grad_align.compute( # Returns correlation between local signal and backprop ``` ---- - ## Services ### ActivationCaptureService @@ -176,8 +170,6 @@ masks = MaskOperations.global_threshold_mask( ) ``` ---- - ## Pruning ### Quick Pruning @@ -229,8 +221,6 @@ results = optimizer.compare_strategies_parallel( ) ``` ---- - ## Training ### Training Callback @@ -253,8 +243,6 @@ callback.on_batch_end(wrapper, inputs, targets, step) history = callback.get_history() ``` ---- - ## Model Wrappers ### Generic Wrapper @@ -301,8 +289,6 @@ wrapper.ffn_layers # {'expansion': [...], 'contraction': [...]} wrapper.attention_layers # {'q': [...], 'k': [...], 'v': [...]} ``` ---- - ## Utility Functions ### Layer Detection @@ -329,8 +315,6 @@ cov = estimate_covariance( ) ``` ---- - ## Configuration Parameters ### Metric Parameters @@ -356,8 +340,6 @@ cov = estimate_covariance( **Direction:** 'low' (prune unimportant), 'high' (ablation), 'random' (baseline) ---- - ## Common Patterns ### Analyze Pretrained Model @@ -408,15 +390,11 @@ results = optimizer.compare_strategies_parallel( ) ``` ---- - ## Examples See `examples/` directory for complete workflows: - `07_mnist_intelligent_pruning.py` - End-to-end pruning -- `08_llama_ffn_pruning.py` - LLM FFN analysis -- `09_attention_neuron_vs_head_pruning.py` - Attention analysis - -Run: `python examples/07_mnist_intelligent_pruning.py` +- `08_llama_ffn_pruning.py` - LLM feed-forward analysis +- `09_attention_neuron_vs_head_pruning.py` - Attention layer analysis From 898b36b6166b43c8297609bf779f6a00abf61512 Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:32:45 -0400 Subject: [PATCH 05/18] Clean up quick reference --- docs/quick_reference.md | 59 +++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/docs/quick_reference.md b/docs/quick_reference.md index a2bd8137..e25f0cac 100644 --- a/docs/quick_reference.md +++ b/docs/quick_reference.md @@ -1,8 +1,6 @@ # Quick Reference -**Common code patterns and examples** - ---- +Common code patterns and examples. ## Basic Usage @@ -66,8 +64,6 @@ result = prune_with_all_options( ) ``` ---- - ## Metrics ### Rayleigh Quotient @@ -124,30 +120,33 @@ scorer = NodeScoringService( scores = scorer.compute_composite_scores(inputs, weights, targets) ``` ---- - ## Pruning -### Pruning Strategies +### Available Strategies ```python -# Available strategies: strategies = [ - 'magnitude', # L1/L2 norm - 'random', # Baseline - 'gradient', # Gradient-based - 'alignment', # RQ-based - 'composite', # Redundancy-aware - 'movement', # Training-aware - 'adaptive', # Adaptive per-layer - 'ultimate' # Multi-stage (best) + 'magnitude', + 'random', + 'gradient', + 'alignment', + 'composite', + 'movement', + 'adaptive', + 'ultimate' ] ``` ### Distribution Methods ```python -# How to allocate sparsity across layers: +distributions = [ + 'uniform', + 'global_threshold', + 'adaptive_sensitivity', + 'importance_weighted', + 'size_proportional' +] distributions = [ 'uniform', # Same % per layer 'global_threshold', # Global score threshold @@ -159,16 +158,14 @@ distributions = [ ] ``` -### Direction Options +### Pruning Direction ```python -direction='low' # Prune low-scoring (production default) -direction='high' # Prune high-scoring (ablation study) -direction='random' # Random (baseline) +direction='low' # Prune low-scoring neurons +direction='high' # Prune high-scoring neurons (ablation) +direction='random' # Random pruning (baseline) ``` ---- - ## Training Integration ### Track Metrics During Training @@ -206,8 +203,6 @@ alignment = grad_align.compute( # High alignment = Hebbian rule works for this neuron ``` ---- - ## Architecture-Specific ### CNNs @@ -256,8 +251,6 @@ scores = redundancy.compute(outputs=outputs) # [11008] - one per neuron ``` ---- - ## Configuration ### Minimal Config @@ -290,8 +283,6 @@ pruning: Run: `python scripts/run_experiment.py --config my_config.yaml` ---- - ## Performance Tips ### For Large Models (LLMs) @@ -320,8 +311,6 @@ optimizer = ParallelPruningOptimizer() results = optimizer.prune_ensemble_parallel(networks, ...) ``` ---- - ## Common Patterns ### Complete Pruning Workflow @@ -361,8 +350,6 @@ train(model, train_loader, epochs=20) accuracy = evaluate(model, test_loader) ``` ---- - ## Troubleshooting ### Singular Covariance @@ -387,7 +374,5 @@ from alignment.pruning.dependency_aware import prune_model_with_dependencies result = prune_model_with_dependencies(model, scores, amount) ``` ---- - -See [USER_GUIDE.md](USER_GUIDE.md) for detailed documentation. +See [user_guide.md](user_guide.md) for detailed documentation. From 850f83fef5ddf216c52ff892637b3fda31d98d24 Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:34:47 -0400 Subject: [PATCH 06/18] Clean up docs README --- docs/README.md | 47 +++++++++++++++++------------------------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/docs/README.md b/docs/README.md index 3925a8db..fc33b8fd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,45 +1,34 @@ # Alignment Framework Documentation -Neural Network Alignment Analysis & Intelligent Pruning - ---- +Neural network alignment analysis and intelligent pruning. ## Getting Started - [Installation Guide](installation.md) - Setup and dependencies -- [Usage Guide](usage.md) - How to run experiments with YAML configs -- [User Guide](user_guide.md) - Complete usage documentation - ---- +- [Usage Guide](usage.md) - Running experiments with configuration files +- [User Guide](user_guide.md) - Comprehensive framework documentation ## Reference -- [API Reference](api_reference.md) - Class and method documentation -- [Quick Reference](quick_reference.md) - Code examples and patterns -- [Configuration Reference](../configs/template.yaml) - All parameters with options - ---- - -## Version Information - -- [Changelog](changelog.md) - Release notes and version history - ---- +- [API Reference](api_reference.md) - API documentation +- [Quick Reference](quick_reference.md) - Code examples +- [Configuration Template](../configs/template.yaml) - All configuration parameters ## Examples -Working example configurations: - -- `configs/examples/mnist_basic.yaml` - Simple MNIST analysis -- `configs/examples/resnet_pruning.yaml` - ResNet pruning on CIFAR-10 -- `configs/examples/llama3_scoring.yaml` - LLaMA-3 per-neuron scoring -- `configs/examples/llama3_pruning.yaml` - LLaMA-3 pruning +Example configuration files in `configs/examples/`: -Run: `python scripts/run_experiment.py --config [path]` +- `mnist_basic.yaml` - MLP analysis on MNIST +- `resnet_pruning.yaml` - ResNet pruning on CIFAR-10 +- `llama3_scoring.yaml` - LLaMA scoring +- `llama3_pruning.yaml` - LLaMA pruning ---- +Run experiments: +```bash +python scripts/run_experiment.py --config configs/examples/mnist_basic.yaml +``` -## Quick Example +## Quick Start ```python from alignment import ModelWrapper, get_metric @@ -53,9 +42,7 @@ weights = wrapper.get_layer_weights() scores = rq.compute(acts['layer_input'], weights['layer']) ``` ---- - -## Building HTML Documentation +## Building Documentation ```bash cd docs From 8d6399df38fd73a6fe77f6732655dd304d7e672a Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:38:20 -0400 Subject: [PATCH 07/18] Clean up example comments --- examples/07_mnist_intelligent_pruning.py | 44 +++++------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/examples/07_mnist_intelligent_pruning.py b/examples/07_mnist_intelligent_pruning.py index 78e85d84..586e5492 100644 --- a/examples/07_mnist_intelligent_pruning.py +++ b/examples/07_mnist_intelligent_pruning.py @@ -1,13 +1,11 @@ """ -Complete Example: Intelligent Pruning on MNIST +MNIST Intelligent Pruning Example -This script demonstrates the full workflow: -1. Train a simple model on MNIST -2. Compute redundancy-aware composite scores -3. Prune using multiple strategies +Demonstrates complete workflow: +1. Train MLP on MNIST +2. Compute composite importance scores +3. Apply pruning with different strategies 4. Compare results - -This is a practical, runnable example showing real improvements. """ import torch @@ -28,9 +26,8 @@ from alignment.metrics import get_metric -# Simple MLP for MNIST class SimpleMLP(nn.Module): - """Simple MLP: 784 -> 128 -> 64 -> 10""" + """MLP: 784 -> 128 -> 64 -> 10""" def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 128) @@ -48,7 +45,7 @@ def forward(self, x): def train_model(model, train_loader, epochs=5, device='cpu'): - """Quick training.""" + """Train model.""" model.train() optimizer = optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() @@ -98,12 +95,7 @@ def evaluate(model, test_loader, device='cpu'): def prune_model(model, wrapper, pruning_method, pruning_amount, val_loader, device='cpu'): - """ - Prune model using specified method. - - Args: - pruning_method: 'random', 'magnitude', 'rq', or 'composite' - """ + """Prune model using specified method.""" print(f"\nPruning with {pruning_method} (amount={pruning_amount:.1%})...") # Get a batch for metric computation @@ -300,25 +292,7 @@ def main(): best_method = min(results.keys(), key=lambda m: results[m]['drop']) print(f"\n✓ Best method: {best_method} (smallest accuracy drop: {results[best_method]['drop']:.2f}%)") - # Expected outcome - print("\n" + "=" * 80) - print("EXPECTED OUTCOME") - print("=" * 80) - print(""" -Redundancy-aware pruning (composite) should outperform others because: -• Preserves high-synergy neuron pairs (complementary information) -• Removes high-redundancy neurons (overlapping information) -• Uses task-relevance when targets available (ΔRQ) - -Expected ranking (best to worst): -1. Composite (redundancy-aware) - Smallest drop -2. RQ (alignment-aware) -3. Magnitude -4. Random - Largest drop - -If composite matches or beats magnitude with same drop, that's a success! -At higher sparsity (70-90%), the gap should be even larger (+3-5% better). - """) + print("\nComposite pruning considers redundancy and synergy in addition to alignment.") # Save results output_dir = Path('results/mnist_intelligent_pruning') From 25dbe2f068870d3821a3e4f43811e8c2b3b08e9c Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:40:23 -0400 Subject: [PATCH 08/18] Clean up configs README --- configs/README.md | 64 ++++++++++++++++++----------------------------- 1 file changed, 25 insertions(+), 39 deletions(-) diff --git a/configs/README.md b/configs/README.md index 26b6f47d..2144ae9e 100644 --- a/configs/README.md +++ b/configs/README.md @@ -1,41 +1,31 @@ # Configuration Guide -All experiments are configured via YAML files. - ---- +Experiments are configured using YAML files. ## Template -`template.yaml` - Complete template with all available parameters documented inline. - ---- - -## Examples - -Compact, ready-to-use configurations: - -### LLaMA-3 +`template.yaml` - Complete template with all available parameters. -- `examples/llama3_scoring.yaml` - Compute per-neuron importance scores -- `examples/llama3_pruning.yaml` - Redundancy-aware pruning +## Example Configurations -### Vision Models +Ready-to-use configurations in `examples/`: -- `examples/resnet_pruning.yaml` - ResNet-18 pruning with dependency handling -- `examples/mnist_basic.yaml` - Simple MNIST analysis +**Vision Models** +- `mnist_basic.yaml` - MLP analysis on MNIST +- `resnet_pruning.yaml` - ResNet-18 pruning on CIFAR-10 ---- +**LLaMA Models** +- `llama3_scoring.yaml` - Compute importance scores +- `llama3_pruning.yaml` - Apply pruning ## Usage -Run any experiment: - +Run experiment: ```bash python scripts/run_experiment.py --config configs/examples/resnet_pruning.yaml ``` Override parameters: - ```bash python scripts/run_experiment.py \ --config configs/examples/resnet_pruning.yaml \ @@ -44,27 +34,23 @@ python scripts/run_experiment.py \ --target-sparsity 0.5 ``` ---- - -## Creating Custom Configs +## Creating Configurations 1. Copy template: `cp configs/template.yaml configs/my_config.yaml` -2. Modify parameters (all options documented inline) +2. Edit parameters as needed 3. Run: `python scripts/run_experiment.py --config configs/my_config.yaml` ---- - -## Parameter Categories +## Configuration Sections -- `experiment`: Name, seed, device, output directory -- `model`: Architecture, pretrained, layers to track -- `dataset`: Data source, batch size, preprocessing -- `metrics`: Which metrics to compute and their parameters -- `training`: Training parameters (if training from scratch) -- `pruning`: Pruning strategy, distribution, scoring method -- `layer_config`: Architecture-specific settings (CNN, transformer) -- `analysis`: Analysis options (class-conditioned, save options) -- `visualization`: Plot generation settings -- `advanced`: Backend, parallelization, optimization options +- `experiment` - Name, seed, device, output directory +- `model` - Architecture, pretrained weights, layers to track +- `dataset` - Data source, batch size, preprocessing +- `metrics` - Metrics to compute and their parameters +- `training` - Training parameters +- `pruning` - Pruning strategy, distribution, scoring +- `layer_config` - Architecture-specific settings +- `analysis` - Analysis options +- `visualization` - Plot settings +- `advanced` - Backend and optimization options -See `template.yaml` for complete parameter documentation. +See `template.yaml` for detailed parameter documentation. From 1a6c1d60a155355dbdc7ad1b9ef88cac9be35b1b Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:41:01 -0400 Subject: [PATCH 09/18] Clean up examples README --- examples/README.md | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/examples/README.md b/examples/README.md index 918bc121..080d2ff9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,30 +1,37 @@ -## Examples +# Examples -This directory contains example scripts demonstrating the alignment framework functionality. +Example scripts demonstrating framework capabilities. -### Available Examples +## Available Examples -1. **01_quick_start.py** - Minimal example showing basic model wrapping and metrics -2. **02_complete_experiment.py** - Full workflow with training, pruning, and visualization -3. **03_pruning_strategies.py** - Comparison of different pruning methods -4. **04_visualization_gallery.py** - Comprehensive visualization capabilities +**Basic Usage** +- `01_quick_start.py` - Model wrapping and basic metrics +- `02_complete_experiment.py` - Full workflow with training and pruning +- `03_pruning_strategies.py` - Pruning strategy comparison +- `04_visualization_gallery.py` - Visualization examples -### Running Examples +**Advanced Usage** +- `06_redundancy_aware_pruning.py` - Information-theoretic pruning +- `07_mnist_intelligent_pruning.py` - Complete MNIST pruning workflow +- `08_llama_ffn_pruning.py` - LLaMA feed-forward pruning +- `09_attention_neuron_vs_head_pruning.py` - Attention analysis -Each example can be run independently: +## Running Examples + +Each example is self-contained: ```bash python examples/01_quick_start.py -python examples/02_complete_experiment.py -python examples/03_pruning_strategies.py -python examples/04_visualization_gallery.py +python examples/07_mnist_intelligent_pruning.py +python examples/08_llama_ffn_pruning.py ``` -### Configuration-Based Experiments +## Configuration-Based Experiments -For experiments using YAML configurations, see: +For YAML-based experiments: ```bash -python scripts/run_experiment.py --config configs/examples/resnet18_analysis.yaml +python scripts/run_experiment.py --config configs/examples/mnist_basic.yaml +python scripts/run_experiment.py --config configs/examples/resnet_pruning.yaml ``` -See the `configs/examples/` directory for configuration templates. \ No newline at end of file +See `configs/examples/` for available configurations. \ No newline at end of file From 438e2592ceec310be6bcad07f510d77eb5e2ca36 Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:42:46 -0400 Subject: [PATCH 10/18] Clean up scripts README --- scripts/README.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 4c0e4ae0..9deacea9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,21 +1,27 @@ -# Production Scripts +# Scripts -This directory contains production-ready scripts for running alignment experiments. +Scripts for running experiments. -## Scripts +## Main Script -### `run_experiment.py` -The main experiment runner for the alignment framework. This is the primary tool for running research experiments with full configuration support. +`run_experiment.py` - Experiment runner with YAML configuration support. -**Usage:** +Usage: ```bash -# From repository root -python scripts/run_experiment.py --config configs/unified_config.yaml +python scripts/run_experiment.py --config configs/examples/mnist_basic.yaml ``` -## Difference from Examples +The script handles: +- Model loading (pretrained or training from scratch) +- Metric computation +- Pruning with various strategies +- Evaluation and visualization -- **`examples/`**: Educational scripts for learning the framework -- **`scripts/`**: Production tools for research experiments +## Configuration -The examples demonstrate concepts with hardcoded parameters for clarity, while the scripts here are fully configurable tools designed for actual research work. \ No newline at end of file +Experiments are configured via YAML files. See `configs/template.yaml` for all parameters and `configs/examples/` for working examples. + +## Examples vs Scripts + +- `examples/` - Standalone demonstration scripts +- `scripts/` - Configuration-based experiment runner \ No newline at end of file From f6256559b9aced654b566c54edfb5df643b687b4 Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:47:32 -0400 Subject: [PATCH 11/18] Fix imports and linting issues --- examples/01_quick_start.py | 53 +++-- examples/02_complete_experiment.py | 124 +++++----- examples/03_pruning_strategies.py | 167 ++++++------- examples/04_visualization_gallery.py | 82 +++---- examples/06_redundancy_aware_pruning.py | 78 +++--- examples/07_mnist_intelligent_pruning.py | 117 ++++----- examples/08_llama_ffn_pruning.py | 179 +++++++------- .../09_attention_neuron_vs_head_pruning.py | 154 ++++++------ examples/quick_demo.py | 53 +++-- scripts/run_experiment.py | 163 +++++++------ src/alignment/__init__.py | 34 +-- src/alignment/analysis/__init__.py | 18 +- .../analysis/aggregation/__init__.py | 6 +- src/alignment/analysis/aggregation/layers.py | 55 ++--- src/alignment/analysis/aggregation/metrics.py | 49 ++-- src/alignment/analysis/aggregation/results.py | 89 +++---- src/alignment/analysis/dynamic_scoring.py | 124 +++++----- src/alignment/analysis/unified_reporter.py | 163 ++++++------- .../analysis/visualization/__init__.py | 7 +- .../visualization/unified_visualizer.py | 222 +++++++++--------- src/alignment/configs/__init__.py | 2 +- src/alignment/configs/config_loader.py | 125 +++++----- src/alignment/configs/config_validator.py | 50 ++-- src/alignment/core/__init__.py | 23 +- src/alignment/core/base.py | 147 ++++++------ src/alignment/core/layer_detector.py | 120 +++++----- src/alignment/core/protocols.py | 85 +++---- src/alignment/core/registry.py | 53 ++--- src/alignment/data/__init__.py | 6 +- src/alignment/data/base.py | 15 +- src/alignment/data/datasets/__init__.py | 18 +- .../data/datasets/unified_dataset.py | 9 +- src/alignment/data/loaders.py | 12 +- src/alignment/data/processing/__init__.py | 11 +- src/alignment/data/processing/batch.py | 10 +- src/alignment/data/processing/layers.py | 5 +- src/alignment/evaluation.py | 95 ++++---- src/alignment/experiments/__init__.py | 16 +- src/alignment/experiments/base.py | 19 +- .../experiments/config_components.py | 2 +- .../experiments/general_alignment.py | 52 ++-- src/alignment/experiments/llm_experiments.py | 11 +- src/alignment/experiments/runner.py | 13 +- .../experiments/tracking/__init__.py | 6 +- src/alignment/experiments/tracking/base.py | 7 +- src/alignment/experiments/training_utils.py | 5 +- .../external/BROJA_2PID/BROJA_2PID.py | 44 ++-- src/alignment/external/__init__.py | 2 +- src/alignment/infrastructure/__init__.py | 51 ++-- .../infrastructure/computing/__init__.py | 20 +- .../infrastructure/computing/distributed.py | 177 +++++++------- .../computing/optimized/__init__.py | 28 +-- .../infrastructure/computing/optimized/gpu.py | 140 +++++------ .../infrastructure/computing/optimized/jit.py | 144 ++++++------ .../infrastructure/storage/__init__.py | 13 +- .../infrastructure/storage/checkpoint.py | 35 +-- .../infrastructure/storage/logging.py | 76 +++--- src/alignment/metrics/__init__.py | 20 +- src/alignment/metrics/gradient_based.py | 180 +++++++------- src/alignment/metrics/information/__init__.py | 23 +- .../conditional_mutual_information.py | 140 +++++------ .../metrics/information/gaussian_mi.py | 132 +++++------ .../metrics/information/gaussian_pid.py | 6 +- .../metrics/information/gpu_binning.py | 68 +++--- .../metrics/information/higher_order.py | 151 ++++++------ .../metrics/information/mi_projection.py | 70 +++--- .../metrics/information/mutual_information.py | 124 +++++----- .../metrics/information/pairwise_gaussian.py | 153 ++++++------ src/alignment/metrics/information/pid.py | 155 ++++++------ .../metrics/information/redundancy.py | 117 ++++----- .../metrics/information/synergy_mmi.py | 135 +++++------ src/alignment/metrics/pairwise_base.py | 63 ++--- src/alignment/metrics/rayleigh/__init__.py | 2 +- .../metrics/rayleigh/delta_alignment.py | 48 ++-- .../metrics/rayleigh/rayleigh_quotient.py | 189 +++++++-------- .../metrics/rayleigh/rq_alternative.py | 48 ++-- src/alignment/metrics/similarity/__init__.py | 13 +- .../metrics/similarity/cosine_similarity.py | 115 ++++----- .../metrics/similarity/node_correlation.py | 62 ++--- .../metrics/similarity/node_redundancy.py | 60 ++--- .../metrics/similarity/weight_similarity.py | 58 ++--- src/alignment/metrics/spectral/__init__.py | 12 +- .../metrics/spectral/spectral_alignment.py | 112 ++++----- .../metrics/spectral/spectral_classic.py | 132 ++++++----- .../metrics/task_specific/__init__.py | 19 +- .../task_specific/activation_magnitude.py | 70 +++--- .../metrics/task_specific/classification.py | 68 +++--- .../metrics/task_specific/general.py | 198 ++++++++-------- .../metrics/task_specific/language_model.py | 78 +++--- .../task_specific/reinforcement_learning.py | 66 +++--- src/alignment/metrics/task_specific/vision.py | 113 ++++----- src/alignment/models/__init__.py | 23 +- .../models/architectures/standard_models.py | 2 +- src/alignment/models/base.py | 8 +- src/alignment/models/hooks.py | 5 +- src/alignment/models/hub.py | 3 +- src/alignment/models/transformers.py | 7 +- src/alignment/models/wrappers.py | 11 +- src/alignment/pruning/__init__.py | 70 +++--- src/alignment/pruning/base.py | 111 ++++----- src/alignment/pruning/dependency_aware.py | 183 ++++++++------- src/alignment/pruning/distribution.py | 161 ++++++------- src/alignment/pruning/experiments/__init__.py | 8 +- .../pruning/experiments/cascading_layer.py | 15 +- .../pruning/experiments/eigenvector_based.py | 13 +- .../pruning/experiments/global_pruning.py | 15 +- .../pruning/experiments/layer_wise.py | 17 +- src/alignment/pruning/orchestrator.py | 109 ++++----- src/alignment/pruning/parallel_optimizer.py | 129 +++++----- src/alignment/pruning/strategies/__init__.py | 41 +--- src/alignment/pruning/strategies/adaptive.py | 155 ++++++------ .../pruning/strategies/alignment_based.py | 163 ++++++------- src/alignment/pruning/strategies/cascading.py | 83 +++---- src/alignment/pruning/strategies/gradient.py | 113 ++++----- src/alignment/pruning/strategies/magnitude.py | 65 ++--- src/alignment/pruning/strategies/movement.py | 86 +++---- src/alignment/pruning/strategies/parallel.py | 123 +++++----- .../pruning/strategies/parallel_batch.py | 161 ++++++------- src/alignment/pruning/strategies/random.py | 91 +++---- src/alignment/pruning/strategies/ultimate.py | 101 ++++---- .../pruning/strategies/ultra_fast.py | 212 ++++++++--------- src/alignment/quantization/__init__.py | 17 +- src/alignment/quantization/analysis.py | 73 +++--- src/alignment/quantization/ptq.py | 111 ++++----- src/alignment/services/__init__.py | 12 +- src/alignment/services/activation_capture.py | 84 +++---- src/alignment/services/mask_ops.py | 107 ++++----- src/alignment/services/scoring.py | 101 ++++---- src/alignment/training/__init__.py | 4 +- src/alignment/training/base.py | 125 +++++----- src/alignment/training/callbacks/__init__.py | 5 +- .../training/callbacks/alignment_callback.py | 76 +++--- src/alignment/training/experiment_trainer.py | 161 ++++++------- src/alignment/training/multi_network.py | 155 ++++++------ 134 files changed, 4954 insertions(+), 4945 deletions(-) diff --git a/examples/01_quick_start.py b/examples/01_quick_start.py index 462167e4..0459145c 100644 --- a/examples/01_quick_start.py +++ b/examples/01_quick_start.py @@ -6,13 +6,13 @@ Usage: python quick_demo.py - + No configuration needed - this script runs with default settings and creates its own model. Requirements: - PyTorch - alignment package installed - + Output: - Console output showing: * Model structure @@ -24,8 +24,9 @@ import torch import torch.nn as nn -from alignment.models import ModelWrapper + from alignment.metrics import get_metric +from alignment.models import ModelWrapper def main(): @@ -37,55 +38,55 @@ def main(): nn.ReLU(), nn.Linear(128, 10) ) - + print("Model created with 3 linear layers") - + # 2. Wrap the model to track activations wrapped_model = ModelWrapper(model) print(f"\nTracked layers: {wrapped_model.tracked_layers}") - + # 3. Generate some dummy data batch_size = 32 inputs = torch.randn(batch_size, 784) - + # 4. Forward pass with activation tracking outputs, activations = wrapped_model.forward_with_activations(inputs) print(f"\nCollected activations from {len(activations)} points") print(f"Activation keys: {list(activations.keys())}") - + # 5. Get layer weights weights = wrapped_model.get_layer_weights() print(f"Extracted weights from {len(weights)} layers") - + # 6. Compute alignment metrics print("\n" + "="*60) print("Computing Alignment Metrics") print("="*60) - + # Rayleigh Quotient RQMetric = get_metric('rayleigh_quotient') rq_metric = RQMetric() # Instantiate the metric - + for layer_name in wrapped_model.tracked_layers: # Get inputs and weights for this layer layer_inputs = activations[f"{layer_name}_input"] layer_weights = weights[layer_name] - + # Compute RQ scores scores = rq_metric.compute(inputs=layer_inputs, weights=layer_weights) - + print(f"\nLayer: {layer_name}") print(f" Input shape: {layer_inputs.shape}") print(f" Weight shape: {layer_weights.shape}") print(f" RQ scores: mean={scores.mean():.4f}, std={scores.std():.4f}") print(f" Min neuron score: {scores.min():.4f}") print(f" Max neuron score: {scores.max():.4f}") - + # 7. Try other metrics print("\n" + "="*60) print("Trying Other Metrics") print("="*60) - + # Weight cosine similarity WeightSimMetric = get_metric('weight_cosine_similarity') weight_sim = WeightSimMetric() # Instantiate @@ -93,35 +94,35 @@ def main(): layer_weights = weights[layer_name] sim_scores = weight_sim.compute(weights=layer_weights) print(f"\nWeight Cosine Similarity ({layer_name}): mean={sim_scores.mean():.4f}") - + # 8. Demonstrate pruning print("\n" + "="*60) print("Pruning Demo") print("="*60) - - from alignment.pruning import get_pruning_strategy, PruningConfig - + + from alignment.pruning import PruningConfig, get_pruning_strategy + # Use magnitude-based pruning config = PruningConfig(amount=0.5, pruning_mode='low') strategy = get_pruning_strategy('magnitude', config=config) - + # Apply pruning to first layer first_layer = model[0] # First linear layer mask = strategy.prune(first_layer) - - print(f"\nPruning 50% of weights in first layer") + + print("\nPruning 50% of weights in first layer") print(f"Sparsity achieved: {(mask == 0).float().mean():.2%}") - + # Apply mask first_layer.weight.data *= mask - + # Test forward pass after pruning pruned_outputs = model(inputs) print(f"\nOutput shape after pruning: {pruned_outputs.shape}") print("Pruning applied successfully!") - + print("\nDemo completed successfully!") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/02_complete_experiment.py b/examples/02_complete_experiment.py index 945133fb..d3b8e19a 100644 --- a/examples/02_complete_experiment.py +++ b/examples/02_complete_experiment.py @@ -12,7 +12,7 @@ Usage: python standard_alignment_experiment.py - + No configuration needed - this script runs with default settings. The script will: - Download MNIST dataset automatically (if not present) @@ -37,21 +37,21 @@ - comparison_grid.png: Comprehensive analysis grid """ +import json +from pathlib import Path + import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms -import numpy as np -from pathlib import Path -import json -from typing import Dict, List, Any + +from alignment.analysis.visualization import PruningVisualizer +from alignment.metrics import get_metric # Alignment framework imports from alignment.models import ModelWrapper -from alignment.metrics import get_metric -from alignment.pruning import get_pruning_strategy, PruningConfig -from alignment.analysis.visualization import PruningVisualizer +from alignment.pruning import PruningConfig, get_pruning_strategy class SimpleNet(nn.Module): @@ -64,7 +64,7 @@ def __init__(self, hidden_sizes=[784, 256, 128, 10]): if i < len(hidden_sizes) - 2: # No ReLU after last layer layers.append(nn.ReLU()) self.model = nn.Sequential(*layers) - + def forward(self, x): x = x.view(x.size(0), -1) return self.model(x) @@ -75,9 +75,9 @@ def train_model(model, train_loader, val_loader, epochs=10, device='cuda'): model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) - + history = {'train_loss': [], 'val_acc': []} - + for epoch in range(epochs): # Training model.train() @@ -90,7 +90,7 @@ def train_model(model, train_loader, val_loader, epochs=10, device='cuda'): loss.backward() optimizer.step() train_loss += loss.item() - + # Validation model.eval() correct = 0 @@ -100,15 +100,15 @@ def train_model(model, train_loader, val_loader, epochs=10, device='cuda'): output = model(data) pred = output.argmax(dim=1) correct += pred.eq(target).sum().item() - + train_loss /= len(train_loader) val_acc = 100. * correct / len(val_loader.dataset) - + history['train_loss'].append(train_loss) history['val_acc'].append(val_acc) - + print(f'Epoch {epoch+1}/{epochs}: Loss: {train_loss:.4f}, Acc: {val_acc:.2f}%') - + return history @@ -116,36 +116,36 @@ def compute_alignment_metrics(model, data_loader, device='cuda'): """Compute various alignment metrics for the model.""" model = model.to(device) wrapped_model = ModelWrapper(model.model) # Wrap the sequential model - + # Get a batch of data for metric computation data_iter = iter(data_loader) inputs, _ = next(data_iter) inputs = inputs.to(device) inputs = inputs.view(inputs.size(0), -1) # Flatten for the model - + # Forward pass to collect activations _, activations = wrapped_model.forward_with_activations(inputs) weights = wrapped_model.get_layer_weights() - + # Initialize metrics metrics = { 'rayleigh_quotient': get_metric('rayleigh_quotient')(), 'weight_cosine_similarity': get_metric('weight_cosine_similarity')(), } - + results = {} - + # Compute metrics for each layer for layer_name in wrapped_model.tracked_layers: results[layer_name] = {} - + # Rayleigh Quotient if f"{layer_name}_input" in activations: layer_inputs = activations[f"{layer_name}_input"] layer_weights = weights[layer_name] - + rq_scores = metrics['rayleigh_quotient'].compute( - inputs=layer_inputs, + inputs=layer_inputs, weights=layer_weights ) results[layer_name]['rayleigh_quotient'] = { @@ -154,7 +154,7 @@ def compute_alignment_metrics(model, data_loader, device='cuda'): 'min': float(rq_scores.min()), 'max': float(rq_scores.max()) } - + # Weight Cosine Similarity layer_weights = weights[layer_name] sim_scores = metrics['weight_cosine_similarity'].compute(weights=layer_weights) @@ -162,40 +162,40 @@ def compute_alignment_metrics(model, data_loader, device='cuda'): 'mean': float(sim_scores.mean()), 'std': float(sim_scores.std()) } - + return results -def apply_pruning_experiment(model, val_loader, strategies=['magnitude', 'random'], +def apply_pruning_experiment(model, val_loader, strategies=['magnitude', 'random'], sparsities=[0.1, 0.3, 0.5, 0.7, 0.9], device='cuda'): """Apply different pruning strategies and evaluate.""" results = {} - + for strategy_name in strategies: results[strategy_name] = {} - + for sparsity in sparsities: # Clone model for this experiment pruned_model = SimpleNet() pruned_model.load_state_dict(model.state_dict()) pruned_model = pruned_model.to(device) - + # Apply pruning config = PruningConfig(amount=sparsity, pruning_mode='low') strategy = get_pruning_strategy(strategy_name, config=config) - + # Prune each linear layer for name, module in pruned_model.named_modules(): if isinstance(module, nn.Linear): mask = strategy.prune(module) module.weight.data *= mask - + # Evaluate pruned_model.eval() correct = 0 total_loss = 0 criterion = nn.CrossEntropyLoss() - + with torch.no_grad(): for data, target in val_loader: data, target = data.to(device), target.to(device) @@ -204,18 +204,18 @@ def apply_pruning_experiment(model, val_loader, strategies=['magnitude', 'random total_loss += loss.item() pred = output.argmax(dim=1) correct += pred.eq(target).sum().item() - + accuracy = 100. * correct / len(val_loader.dataset) avg_loss = total_loss / len(val_loader) - + results[strategy_name][sparsity] = { 'accuracy': accuracy, 'loss': avg_loss } - + print(f"{strategy_name} - Sparsity {sparsity:.1%}: " f"Acc: {accuracy:.2f}%, Loss: {avg_loss:.4f}") - + return results @@ -223,24 +223,24 @@ def visualize_results(pruning_results, output_dir='results/standard_experiment') """Generate visualizations of the results.""" output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - + visualizer = PruningVisualizer() - + # Create performance comparison plot - fig1 = visualizer.plot_pruning_performance( + visualizer.plot_pruning_performance( pruning_results, metrics=['accuracy', 'loss'], save_path=output_dir / 'pruning_performance.png', title='Pruning Strategy Comparison on MNIST', show_confidence=False ) - + # Create comprehensive comparison grid - fig2 = visualizer.plot_pruning_comparison_grid( + visualizer.plot_pruning_comparison_grid( pruning_results, save_path=output_dir / 'comparison_grid.png' ) - + print(f"\nVisualizations saved to {output_dir}") @@ -249,75 +249,75 @@ def main(): print("=" * 60) print("Standard Alignment Experiment") print("=" * 60) - + # Setup device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print(f"\nUsing device: {device}") - + # Data loading print("\nLoading MNIST dataset...") transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) - + train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform) val_dataset = datasets.MNIST('./data', train=False, transform=transform) - + train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=256, shuffle=False) - + # Create model print("\nCreating model...") model = SimpleNet() print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}") - + # Train model print("\nTraining model...") history = train_model(model, train_loader, val_loader, epochs=5, device=device) - + # Save training history output_dir = Path('results/standard_experiment') output_dir.mkdir(parents=True, exist_ok=True) - + with open(output_dir / 'training_history.json', 'w') as f: json.dump(history, f, indent=2) - + # Compute alignment metrics print("\nComputing alignment metrics...") metrics = compute_alignment_metrics(model, val_loader, device=device) - + print("\nAlignment Metrics:") for layer, layer_metrics in metrics.items(): print(f"\nLayer {layer}:") for metric_name, values in layer_metrics.items(): if 'mean' in values: print(f" {metric_name}: mean={values['mean']:.4f}, std={values['std']:.4f}") - + # Save metrics with open(output_dir / 'alignment_metrics.json', 'w') as f: json.dump(metrics, f, indent=2) - + # Pruning experiments print("\n" + "=" * 60) print("Pruning Experiments") print("=" * 60) - + pruning_results = apply_pruning_experiment( model, val_loader, strategies=['magnitude', 'random'], sparsities=[0.1, 0.3, 0.5, 0.7, 0.9], device=device ) - + # Save pruning results with open(output_dir / 'pruning_results.json', 'w') as f: json.dump(pruning_results, f, indent=2) - + # Generate visualizations print("\nGenerating visualizations...") visualize_results(pruning_results, output_dir) - + # Summary print("\n" + "=" * 60) print("Experiment Complete!") @@ -329,12 +329,12 @@ def main(): print(" - pruning_results.json: Pruning experiment results") print(" - pruning_performance.png: Performance comparison plot") print(" - comparison_grid.png: Comprehensive analysis grid") - + # Print key findings print("\nKey Findings:") baseline_acc = history['val_acc'][-1] print(f" - Baseline accuracy: {baseline_acc:.2f}%") - + for strategy in pruning_results: acc_50 = pruning_results[strategy][0.5]['accuracy'] acc_90 = pruning_results[strategy][0.9]['accuracy'] @@ -344,4 +344,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/03_pruning_strategies.py b/examples/03_pruning_strategies.py index f3849f26..53baae99 100644 --- a/examples/03_pruning_strategies.py +++ b/examples/03_pruning_strategies.py @@ -8,7 +8,7 @@ Usage: python pruning_strategies_demo.py - + No configuration needed - this script runs standalone demonstrations. This comprehensive demo showcases: @@ -35,12 +35,13 @@ - Key takeaways and insights """ -import torch -import torch.nn as nn -import numpy as np -from pathlib import Path import sys import time +from pathlib import Path + +import numpy as np +import torch +import torch.nn as nn # Add src to path sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) @@ -48,10 +49,10 @@ # Direct imports from alignment.pruning.base import PruningConfig from alignment.pruning.strategies import ( - MagnitudePruning, GradientPruning, - RandomPruning, + MagnitudePruning, ParallelModePruning, + RandomPruning, TensorizedPruning, ) @@ -65,7 +66,7 @@ def __init__(self, input_size=784, hidden_size=256, num_classes=10): self.fc2 = nn.Linear(hidden_size, 128) self.relu2 = nn.ReLU() self.fc3 = nn.Linear(128, num_classes) - + def forward(self, x): x = x.view(x.size(0), -1) x = self.relu1(self.fc1(x)) @@ -77,65 +78,65 @@ def forward(self, x): def demo_basic_pruning(): """Demonstrate basic pruning with different modes.""" print("\n=== Basic Pruning Demo ===\n") - + # Create model model = SimpleNet(hidden_size=512) - + # Initialize weights for m in model.modules(): if isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) - + # Test different pruning modes on first layer layer = model.fc1 sparsity = 0.5 - + print(f"Layer shape: {layer.weight.shape}") print(f"Target sparsity: {sparsity*100}%\n") - + # 1. Magnitude pruning - low mode (prune small weights) print("1. Magnitude Pruning - Low Mode (prune small weights):") config = PruningConfig(amount=sparsity, pruning_mode='low') pruner = MagnitudePruning(config) - + scores = pruner.compute_importance_scores(layer) mask = pruner.create_pruning_mask(scores) - + weights = layer.weight.data.flatten() kept_weights = weights[mask.flatten().bool()] pruned_weights = weights[~mask.flatten().bool()] - + print(f" Sparsity achieved: {(mask == 0).float().mean():.2%}") print(f" Avg magnitude of kept weights: {kept_weights.abs().mean():.4f}") print(f" Avg magnitude of pruned weights: {pruned_weights.abs().mean():.4f}") print(f" Max pruned weight magnitude: {pruned_weights.abs().max():.4f}") - + # 2. Magnitude pruning - high mode (prune large weights) print("\n2. Magnitude Pruning - High Mode (prune large weights):") config = PruningConfig(amount=sparsity, pruning_mode='high') pruner = MagnitudePruning(config) - + mask = pruner.create_pruning_mask(scores) - + kept_weights = weights[mask.flatten().bool()] pruned_weights = weights[~mask.flatten().bool()] - + print(f" Sparsity achieved: {(mask == 0).float().mean():.2%}") print(f" Avg magnitude of kept weights: {kept_weights.abs().mean():.4f}") print(f" Avg magnitude of pruned weights: {pruned_weights.abs().mean():.4f}") print(f" Min pruned weight magnitude: {pruned_weights.abs().min():.4f}") - + # 3. Random pruning print("\n3. Random Pruning:") config = PruningConfig(amount=sparsity) pruner = RandomPruning(config) - + scores = pruner.compute_importance_scores(layer) mask = pruner.create_pruning_mask(scores) - + kept_weights = weights[mask.flatten().bool()] pruned_weights = weights[~mask.flatten().bool()] - + print(f" Sparsity achieved: {(mask == 0).float().mean():.2%}") print(f" Avg magnitude of kept weights: {kept_weights.abs().mean():.4f}") print(f" Avg magnitude of pruned weights: {pruned_weights.abs().mean():.4f}") @@ -145,62 +146,62 @@ def demo_basic_pruning(): def demo_parallel_pruning(): """Demonstrate parallel pruning strategies.""" print("\n\n=== Parallel Pruning Demo ===\n") - + # Create model model = SimpleNet(hidden_size=256) - + # Initialize weights for m in model.modules(): if isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) - + layer = model.fc1 sparsity = 0.5 - + # 1. Parallel mode pruning print("1. Parallel Mode Pruning (all modes simultaneously):") parallel_pruner = ParallelModePruning( modes=['low', 'high', 'random'], base_strategy='magnitude' ) - + start_time = time.time() result = parallel_pruner.prune_parallel(layer, amount=sparsity) parallel_time = time.time() - start_time - + print(f"\n Execution time: {parallel_time:.4f}s") - print(f"\n Sparsity by mode:") + print("\n Sparsity by mode:") for mode, sparse in result.sparsities.items(): print(f" {mode}: {sparse:.2%}") - + # Analyze overlaps - print(f"\n Mask overlaps:") + print("\n Mask overlaps:") low_high_overlap = (result.masks['low'] * result.masks['high']).sum().item() low_random_overlap = (result.masks['low'] * result.masks['random']).sum().item() high_random_overlap = (result.masks['high'] * result.masks['random']).sum().item() total = result.masks['low'].numel() - + print(f" low ∩ high: {low_high_overlap/total:.2%}") print(f" low ∩ random: {low_random_overlap/total:.2%}") print(f" high ∩ random: {high_random_overlap/total:.2%}") - + # 2. Sequential comparison print("\n2. Sequential execution (for comparison):") - + start_time = time.time() masks_sequential = {} - + for mode in ['low', 'high', 'random']: config = PruningConfig(amount=sparsity, pruning_mode=mode) pruner = MagnitudePruning(config) scores = pruner.compute_importance_scores(layer) masks_sequential[mode] = pruner.create_pruning_mask(scores) - + sequential_time = time.time() - start_time - + print(f"\n Execution time: {sequential_time:.4f}s") print(f" Speedup from parallel: {sequential_time/parallel_time:.2f}x") - + # Verify results match print(f"\n Results match: {all(torch.allclose(result.masks[mode], masks_sequential[mode]) for mode in ['low', 'high', 'random'])}") @@ -208,7 +209,7 @@ def demo_parallel_pruning(): def demo_tensorized_pruning(): """Demonstrate tensorized pruning for GPU efficiency.""" print("\n\n=== Tensorized Pruning Demo ===\n") - + # Create larger model for better timing comparison model = nn.Sequential( nn.Linear(1024, 2048), @@ -217,29 +218,29 @@ def demo_tensorized_pruning(): nn.ReLU(), nn.Linear(1024, 512), ) - + # Move to GPU if available device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) - + print(f"Device: {device}") print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}") - + # Initialize weights for m in model.modules(): if isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) - + # 1. Tensorized pruning - compute multiple sparsity levels at once print("\n1. Tensorized Pruning (multiple sparsity levels):") - + tensorized_pruner = TensorizedPruning() - + # Test on first layer layer = model[0] modes = ['low', 'high', 'random'] amounts = [0.1, 0.3, 0.5, 0.7, 0.9] - + start_time = time.time() pruning_tensor = tensorized_pruner.compute_pruning_tensor( layer, @@ -247,29 +248,29 @@ def demo_tensorized_pruning(): amounts=amounts ) tensorized_time = time.time() - start_time - + print(f"\n Execution time: {tensorized_time:.4f}s") print(f" Pruning tensor shape: {pruning_tensor.shape}") print(f" (modes={len(modes)}, amounts={len(amounts)}, *weight_shape)") - + # Analyze patterns print("\n Analyzing pruning patterns...") analysis = tensorized_pruner.analyze_pruning_patterns(pruning_tensor) - - print(f"\n Sparsity progression (avg across weight dims):") + + print("\n Sparsity progression (avg across weight dims):") sparsity_prog = analysis['sparsity_progression'] for i, mode in enumerate(modes): print(f" {mode}: ", end="") for j, amount in enumerate(amounts): print(f"{sparsity_prog[i, j]:.2f} ", end="") print() - + # 2. Compare with sequential approach print("\n2. Sequential approach (for comparison):") - + start_time = time.time() sequential_masks = {} - + for mode in modes: sequential_masks[mode] = {} for amount in amounts: @@ -278,32 +279,32 @@ def demo_tensorized_pruning(): pruner = RandomPruning(config) else: pruner = MagnitudePruning(config) - + scores = pruner.compute_importance_scores(layer) mask = pruner.create_pruning_mask(scores) sequential_masks[mode][amount] = mask - + sequential_time = time.time() - start_time - + print(f"\n Execution time: {sequential_time:.4f}s") print(f" Speedup from tensorized: {sequential_time/tensorized_time:.2f}x") - + # 3. Memory efficiency analysis print("\n3. Memory efficiency:") - + # Tensorized approach stores everything in one tensor tensorized_memory = pruning_tensor.element_size() * pruning_tensor.nelement() - + # Sequential approach stores individual masks sequential_memory = 0 for mode_masks in sequential_masks.values(): for mask in mode_masks.values(): sequential_memory += mask.element_size() * mask.nelement() - + print(f" Tensorized memory: {tensorized_memory / (1024**2):.2f} MB") print(f" Sequential memory: {sequential_memory / (1024**2):.2f} MB") print(f" Memory efficiency: {sequential_memory/tensorized_memory:.2f}x") - + # 4. Show overlap analysis if 'mode_overlap' in analysis: print("\n4. Mode overlap analysis (low ∩ high):") @@ -320,57 +321,57 @@ def demo_tensorized_pruning(): def demo_gradient_based_pruning(): """Demonstrate gradient-based pruning.""" print("\n\n=== Gradient-Based Pruning Demo ===\n") - + # Create model and dummy data model = SimpleNet(input_size=784, hidden_size=256) criterion = nn.CrossEntropyLoss() - + # Create dummy batch batch_size = 32 x = torch.randn(batch_size, 784) y = torch.randint(0, 10, (batch_size,)) - + # Forward and backward pass to get gradients output = model(x) loss = criterion(output, y) loss.backward() - + layer = model.fc1 sparsity = 0.5 - + print(f"Layer shape: {layer.weight.shape}") print(f"Target sparsity: {sparsity*100}%") - + # 1. Gradient magnitude pruning - low mode print("\n1. Gradient Pruning - Low Mode (prune small gradients):") config = PruningConfig(amount=sparsity, pruning_mode='low') pruner = GradientPruning(config) - + scores = pruner.compute_importance_scores(layer) mask = pruner.create_pruning_mask(scores) - + grad_magnitudes = layer.weight.grad.abs().flatten() kept_grads = grad_magnitudes[mask.flatten().bool()] pruned_grads = grad_magnitudes[~mask.flatten().bool()] - + print(f" Sparsity achieved: {(mask == 0).float().mean():.2%}") print(f" Avg gradient magnitude of kept: {kept_grads.mean():.6f}") print(f" Avg gradient magnitude of pruned: {pruned_grads.mean():.6f}") - + # 2. Gradient magnitude pruning - high mode print("\n2. Gradient Pruning - High Mode (prune large gradients):") config = PruningConfig(amount=sparsity, pruning_mode='high') pruner = GradientPruning(config) - + mask = pruner.create_pruning_mask(scores) - + kept_grads = grad_magnitudes[mask.flatten().bool()] pruned_grads = grad_magnitudes[~mask.flatten().bool()] - + print(f" Sparsity achieved: {(mask == 0).float().mean():.2%}") print(f" Avg gradient magnitude of kept: {kept_grads.mean():.6f}") print(f" Avg gradient magnitude of pruned: {pruned_grads.mean():.6f}") - + # Compare with weight magnitude print("\n3. Gradient vs Weight magnitude correlation:") weight_mags = layer.weight.abs().flatten() @@ -383,29 +384,29 @@ def main(): print("=" * 60) print("Pruning Strategies Demo") print("=" * 60) - + # Set random seed torch.manual_seed(42) np.random.seed(42) - + try: # Run demos demo_basic_pruning() demo_parallel_pruning() demo_tensorized_pruning() demo_gradient_based_pruning() - + print("\n" + "=" * 60) print("Demo Complete!") print("=" * 60) - + print("\nKey takeaways:") print("1. Low mode pruning: Removes small magnitude weights (common approach)") print("2. High mode pruning: Removes large magnitude weights (adversarial)") print("3. Parallel pruning: Compute multiple modes simultaneously for efficiency") print("4. Tensorized pruning: GPU-optimized for multiple sparsity levels") print("5. Gradient pruning: Uses gradient information for importance") - + except Exception as e: print(f"\nError: {e}") import traceback @@ -413,4 +414,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/04_visualization_gallery.py b/examples/04_visualization_gallery.py index 6fb3dc2c..c1d732cd 100644 --- a/examples/04_visualization_gallery.py +++ b/examples/04_visualization_gallery.py @@ -6,7 +6,7 @@ Usage: python visualization_demo.py - + This demo showcases: - Enhanced accuracy vs sparsity plots with relative performance - Weight distribution comparison before/after pruning @@ -19,15 +19,17 @@ Results are saved to: results/enhanced_visualizations/ """ -import torch -import numpy as np from pathlib import Path + +import numpy as np +import torch + from alignment.analysis.visualization import PruningVisualizer def create_demo_results(): """Create comprehensive demo results for visualization.""" - + # Progressive dropout results dropout_results = { 'dropout_rates': [0.0, 0.2, 0.4, 0.6, 0.8, 0.9], @@ -53,7 +55,7 @@ def create_demo_results(): 'random': [46, 55, 62, 67, 71, 74, 76, 77, 78, 79] } } - + # Standard pruning results (sparsity-based) pruning_results = {} for strategy in ['magnitude', 'gradient', 'fisher', 'random']: @@ -68,12 +70,12 @@ def create_demo_results(): } accuracy = base_acc * (1 - sparsity * decay_factors[strategy]) accuracy += np.random.normal(0, 1.0) - + pruning_results[strategy][sparsity] = { 'accuracy': max(10, min(100, accuracy)), 'loss': 0.1 + sparsity * 0.8 * (1 + decay_factors[strategy]) } - + # Multi-seed results multi_seed_results = {} for strategy in ['magnitude', 'gradient', 'random']: @@ -88,36 +90,36 @@ def create_demo_results(): 'loss': base['loss'] + np.random.normal(0, 0.1) } multi_seed_results[strategy].append(seed_data) - + # Layer-wise sparsity patterns layer_sparsities = { 'magnitude': {'conv1': 0.3, 'conv2': 0.5, 'fc1': 0.7, 'fc2': 0.9}, 'gradient': {'conv1': 0.4, 'conv2': 0.5, 'fc1': 0.6, 'fc2': 0.8}, 'random': {'conv1': 0.5, 'conv2': 0.5, 'fc1': 0.5, 'fc2': 0.5} } - + model_accuracy = { 'magnitude': 85.3, 'gradient': 82.1, 'random': 68.5 } - + # Weight distributions (simulated) np.random.seed(42) weights_before = { 'conv1': torch.randn(64, 3, 3, 3), 'fc1': torch.randn(128, 512) } - + weights_after = {} for layer_name, weights in weights_before.items(): # Simulate pruning effects mask_magnitude = torch.abs(weights) > torch.quantile(torch.abs(weights), 0.5) weights_after[f"{layer_name}_magnitude"] = weights * mask_magnitude - + mask_random = torch.rand_like(weights) > 0.5 weights_after[f"{layer_name}_random"] = weights * mask_random - + # Multi-metric comparison data strategy_metrics = { 'magnitude_low': { @@ -149,7 +151,7 @@ def create_demo_results(): 'Memory': 0.90 } } - + # Efficiency curve data efficiency_data = {} for strategy in ['magnitude_low', 'magnitude_high', 'gradient_low', 'random']: @@ -163,9 +165,9 @@ def create_demo_results(): factor = efficiency_factors[strategy] accuracies = 100 * np.exp(-0.3 * (compression_ratios - 1) / factor) accuracies = np.clip(accuracies, 10, 100) - + efficiency_data[strategy] = list(zip(compression_ratios, accuracies)) - + return { 'dropout_results': dropout_results, 'pruning_results': pruning_results, @@ -184,87 +186,87 @@ def main(): print("=" * 60) print("Enhanced Pruning Visualization Demo") print("=" * 60) - + # Create output directory output_dir = Path('results/enhanced_visualizations') output_dir.mkdir(parents=True, exist_ok=True) - + # Create demo data print("\nGenerating demo data...") demo_data = create_demo_results() - + # Initialize visualizer visualizer = PruningVisualizer() - + # 1. Enhanced accuracy vs sparsity plot print("\n1. Creating enhanced accuracy vs sparsity plot...") - fig1 = visualizer.plot_accuracy_vs_sparsity_enhanced( + visualizer.plot_accuracy_vs_sparsity_enhanced( demo_data['dropout_results'], save_path=output_dir / 'enhanced_accuracy_vs_sparsity.png' ) - + # 2. Standard pruning performance print("2. Creating standard pruning performance plot...") - fig2 = visualizer.plot_pruning_performance( + visualizer.plot_pruning_performance( demo_data['pruning_results'], metrics=['accuracy', 'loss'], save_path=output_dir / 'pruning_performance.png', title='Pruning Strategy Performance Comparison' ) - + # 3. Weight distribution comparison print("3. Creating weight distribution comparison...") - fig3 = visualizer.plot_weight_distribution_comparison( + visualizer.plot_weight_distribution_comparison( demo_data['weights_before'], demo_data['weights_after'], strategies=['magnitude', 'random'], save_path=output_dir / 'weight_distributions.png' ) - + # 4. Multi-metric radar chart print("4. Creating multi-metric radar chart...") - fig4 = visualizer.plot_multi_metric_radar( + visualizer.plot_multi_metric_radar( demo_data['strategy_metrics'], save_path=output_dir / 'multi_metric_radar.png' ) - + # 5. Pruning efficiency curves print("5. Creating pruning efficiency curves...") - fig5 = visualizer.plot_pruning_efficiency_curve( + visualizer.plot_pruning_efficiency_curve( demo_data['efficiency_data'], save_path=output_dir / 'efficiency_curves.png' ) - + # 6. Comprehensive dashboard print("6. Creating comprehensive dashboard...") - fig6 = visualizer.plot_comprehensive_dashboard( + visualizer.plot_comprehensive_dashboard( demo_data['dropout_results'], save_path=output_dir / 'comprehensive_dashboard.png' ) - + # 7. Comparison grid print("7. Creating comparison grid...") - fig7 = visualizer.plot_pruning_comparison_grid( + visualizer.plot_pruning_comparison_grid( demo_data['pruning_results'], save_path=output_dir / 'comparison_grid.png' ) - + # 8. Multi-seed analysis print("8. Creating multi-seed analysis...") - fig8 = visualizer.plot_multi_seed_results( + visualizer.plot_multi_seed_results( demo_data['multi_seed_results'], metric='accuracy', save_path=output_dir / 'multi_seed_analysis.png' ) - + # 9. Layer-wise pruning visualization print("9. Creating layer-wise pruning visualization...") - fig9 = visualizer.plot_layer_wise_pruning( + visualizer.plot_layer_wise_pruning( demo_data['layer_sparsities'], demo_data['model_accuracy'], save_path=output_dir / 'layer_wise_pruning.png' ) - + # Summary print("\n" + "=" * 60) print("Enhanced Visualization Demo Complete!") @@ -280,7 +282,7 @@ def main(): print(" 7. comparison_grid.png - 6-panel strategy comparison") print(" 8. multi_seed_analysis.png - Statistical analysis across seeds") print(" 9. layer_wise_pruning.png - Layer-specific sparsity patterns") - + print("\nVisualization Features:") print(" - Consistent color scheme across all plots") print(" - High-resolution output (300 DPI)") @@ -290,4 +292,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/06_redundancy_aware_pruning.py b/examples/06_redundancy_aware_pruning.py index 5c02fcca..f74703a0 100644 --- a/examples/06_redundancy_aware_pruning.py +++ b/examples/06_redundancy_aware_pruning.py @@ -19,17 +19,17 @@ import torch import torch.nn as nn -from torchvision import datasets, transforms, models -import numpy as np +from torchvision import datasets, transforms + +from alignment.metrics import get_metric # Alignment framework imports from alignment.models import BaseModelWrapper from alignment.services import ( ActivationCaptureService, + MaskOperations, NodeScoringService, - MaskOperations ) -from alignment.metrics import get_metric def create_simple_cnn(): @@ -54,7 +54,7 @@ def evaluate_model(model, dataloader, device='cpu'): model.eval() correct = 0 total = 0 - + with torch.no_grad(): for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) @@ -62,7 +62,7 @@ def evaluate_model(model, dataloader, device='cpu'): _, predicted = torch.max(outputs, 1) total += targets.size(0) correct += (predicted == targets).sum().item() - + return 100 * correct / total @@ -71,58 +71,58 @@ def main(): print("=" * 80) print("Redundancy-Aware Pruning Demonstration") print("=" * 80) - + # Setup device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f"\nUsing device: {device}") - + # Load MNIST print("\n1. Loading MNIST dataset...") transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) - + test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform ) - + test_loader = torch.utils.data.DataLoader( test_dataset, batch_size=128, shuffle=False ) - + # Create and wrap model print("\n2. Creating model...") model = create_simple_cnn().to(device) - + # Simple training (or load pretrained) # For demo, we'll use random weights print(" (Using random initialization for demo)") - + wrapper = BaseModelWrapper( model, tracked_layers=['3', '6'], # Conv2 and Linear1 track_inputs=True, track_outputs=True ) - + print(f" Tracking layers: {wrapper.tracked_layers}") - + # Initialize services print("\n3. Initializing services...") capture_service = ActivationCaptureService(wrapper) - + # Initialize metrics print("\n4. Initializing metrics...") rq_metric = get_metric('rayleigh_quotient', relative=True, regularization=1e-6) redundancy_metric = get_metric('pairwise_redundancy_gaussian', num_pairs=10) synergy_metric = get_metric('synergy_gaussian_mmi', num_pairs=10) - + scoring_service = NodeScoringService( metrics={ 'rq': rq_metric, @@ -134,21 +134,21 @@ def main(): gamma_redundancy=0.4, # Redundancy weight (negative) delta_rq=0.3 # RQ weight ) - + # Capture activations on a subset print("\n5. Capturing activations...") batch_inputs, batch_targets = next(iter(test_loader)) batch_inputs = batch_inputs.to(device) batch_targets = batch_targets.to(device) - + activation_data = capture_service.capture( batch_inputs, layers=wrapper.tracked_layers, include_weights=True ) - + print(f" Captured data from {len(activation_data.layer_names)} layers") - + # Compute composite scores print("\n6. Computing composite scores...") layerwise_scores = scoring_service.compute_layerwise_scores( @@ -157,13 +157,13 @@ def main(): include_redundancy=True, include_synergy=True ) - + # Display results for first layer layer_name = wrapper.tracked_layers[0] if layer_name in layerwise_scores: scores = layerwise_scores[layer_name] print(f"\n Results for layer '{layer_name}':") - + if scores.rq is not None: print(f" - RQ: mean={scores.rq.mean():.4f}, std={scores.rq.std():.4f}") if scores.redundancy is not None: @@ -172,7 +172,7 @@ def main(): print(f" - Synergy: mean={scores.synergy.mean():.4f}, std={scores.synergy.std():.4f}") if scores.composite is not None: print(f" - Composite: mean={scores.composite.mean():.4f}, std={scores.composite.std():.4f}") - + # Demonstrate class-conditioned RQ print("\n7. Computing class-conditioned RQ (ΔRQ)...") layer_name = wrapper.tracked_layers[-1] # Linear layer @@ -183,73 +183,73 @@ def main(): targets=batch_targets, return_delta_rq=True ) - + print(f" Layer '{layer_name}':") print(f" - RQ (unconditional): {delta_rq_results['rq_uncond'].mean():.4f}") print(f" - RQ (class-cond): {delta_rq_results['rq_cond'].mean():.4f}") print(f" - ΔRQ: {delta_rq_results['delta_rq'].mean():.4f}") - print(f" ΔRQ measures task-relevant alignment (larger = more discriminative)") - + print(" ΔRQ measures task-relevant alignment (larger = more discriminative)") + # Create pruning masks print("\n8. Creating pruning masks...") pruning_amount = 0.3 # Prune 30% - + for layer_name, scores in layerwise_scores.items(): if scores.composite is None: continue - + # Create mask using composite scores mask = MaskOperations.create_structured_mask( scores.composite, amount=pruning_amount, mode='low' # Prune low-importance neurons ) - + stats = MaskOperations.get_mask_statistics(mask) print(f" Layer '{layer_name}': {stats['kept_elements']}/{stats['total_elements']} neurons kept") - + # Compare methods print("\n9. Comparing pruning methods...") print(" Method comparison (on same layer):") layer_name = wrapper.tracked_layers[-1] if layer_name in layerwise_scores: scores = layerwise_scores[layer_name] - + # Method 1: Random mask_random = MaskOperations.create_structured_mask( torch.rand_like(scores.composite), amount=pruning_amount, mode='random' ) - + # Method 2: RQ only mask_rq = MaskOperations.create_structured_mask( scores.rq, amount=pruning_amount, mode='low' ) - + # Method 3: Composite (redundancy-aware) mask_composite = MaskOperations.create_structured_mask( scores.composite, amount=pruning_amount, mode='low' ) - + # Check overlap overlap_rq_composite = (mask_rq & mask_composite).sum().item() / mask_rq.sum().item() overlap_random_composite = (mask_random & mask_composite).sum().item() / mask_random.sum().item() - + print(f" - Overlap (RQ vs Composite): {overlap_rq_composite:.2%}") print(f" - Overlap (Random vs Composite): {overlap_random_composite:.2%}") print(f" → Redundancy-awareness changes {(1-overlap_rq_composite)*100:.1f}% of pruning decisions") - + # Evaluate baseline print("\n10. Evaluating model...") baseline_acc = evaluate_model(model, test_loader, device) print(f" Baseline accuracy: {baseline_acc:.2f}%") print(" (Would apply masks and fine-tune for full experiment)") - + print("\n" + "=" * 80) print("Summary") print("=" * 80) @@ -257,7 +257,7 @@ def main(): Key Features Demonstrated: ✓ ActivationCaptureService - Clean API for activation capture ✓ PairwiseRedundancyGaussian - Identifies redundant neurons -✓ SynergyGaussianMMI - Identifies complementary neurons +✓ SynergyGaussianMMI - Identifies complementary neurons ✓ Class-conditioned RQ - Measures task-relevant alignment (ΔRQ) ✓ NodeScoringService - Composite scoring with configurable weights ✓ MaskOperations - Flexible mask creation and analysis diff --git a/examples/07_mnist_intelligent_pruning.py b/examples/07_mnist_intelligent_pruning.py index 586e5492..e19826cc 100644 --- a/examples/07_mnist_intelligent_pruning.py +++ b/examples/07_mnist_intelligent_pruning.py @@ -8,22 +8,23 @@ 4. Compare results """ +from pathlib import Path + import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms -import matplotlib.pyplot as plt -from pathlib import Path + +from alignment.metrics import get_metric # Alignment framework from alignment.models import BaseModelWrapper from alignment.services import ( ActivationCaptureService, + MaskOperations, NodeScoringService, - MaskOperations ) -from alignment.metrics import get_metric class SimpleMLP(nn.Module): @@ -35,7 +36,7 @@ def __init__(self): self.fc2 = nn.Linear(128, 64) self.relu2 = nn.ReLU() self.fc3 = nn.Linear(64, 10) - + def forward(self, x): x = x.view(x.size(0), -1) x = self.relu1(self.fc1(x)) @@ -49,30 +50,30 @@ def train_model(model, train_loader, epochs=5, device='cpu'): model.train() optimizer = optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() - + print(f"Training for {epochs} epochs...") for epoch in range(epochs): total_loss = 0 correct = 0 total = 0 - + for batch_idx, (inputs, targets) in enumerate(train_loader): inputs, targets = inputs.to(device), targets.to(device) - + optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() - + total_loss += loss.item() _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() - + if batch_idx % 100 == 0: print(f' Epoch {epoch+1}, Batch {batch_idx}: Loss={loss.item():.4f}') - + acc = 100. * correct / total print(f'Epoch {epoch+1}: Loss={total_loss/len(train_loader):.4f}, Acc={acc:.2f}%') @@ -82,7 +83,7 @@ def evaluate(model, test_loader, device='cpu'): model.eval() correct = 0 total = 0 - + with torch.no_grad(): for inputs, targets in test_loader: inputs, targets = inputs.to(device), targets.to(device) @@ -90,19 +91,19 @@ def evaluate(model, test_loader, device='cpu'): _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() - + return 100. * correct / total def prune_model(model, wrapper, pruning_method, pruning_amount, val_loader, device='cpu'): """Prune model using specified method.""" print(f"\nPruning with {pruning_method} (amount={pruning_amount:.1%})...") - + # Get a batch for metric computation inputs_batch, targets_batch = next(iter(val_loader)) inputs_batch = inputs_batch.to(device) targets_batch = targets_batch.to(device) - + # Capture activations capture_service = ActivationCaptureService(wrapper) data = capture_service.capture( @@ -110,30 +111,30 @@ def prune_model(model, wrapper, pruning_method, pruning_amount, val_loader, devi layers=wrapper.tracked_layers, include_weights=True ) - + # Compute scores based on method masks = {} - + for layer_name in wrapper.tracked_layers: if layer_name not in data.inputs or layer_name not in data.weights: continue - + inputs = data.inputs[layer_name] weights = data.weights[layer_name] - + if pruning_method == 'random': # Random scores scores = torch.rand(weights.shape[0]) - + elif pruning_method == 'magnitude': # L2 norm of weights scores = torch.norm(weights, p=2, dim=1) - + elif pruning_method == 'rq': # RQ only rq_metric = get_metric('rayleigh_quotient') scores = rq_metric.compute(inputs, weights) - + elif pruning_method == 'composite': # Redundancy-aware composite scoring_service = NodeScoringService( @@ -147,15 +148,15 @@ def prune_model(model, wrapper, pruning_method, pruning_amount, val_loader, devi gamma_redundancy=0.4, delta_rq=0.3 ) - + layer_scores = scoring_service.compute_composite_scores( inputs, weights, targets_batch ) scores = layer_scores.composite - + else: raise ValueError(f"Unknown method: {pruning_method}") - + # Create mask mask = MaskOperations.create_structured_mask( scores, @@ -163,13 +164,13 @@ def prune_model(model, wrapper, pruning_method, pruning_amount, val_loader, devi mode='low' ) masks[layer_name] = mask - + stats = MaskOperations.get_mask_statistics(mask) print(f" {layer_name}: {stats['kept_elements']}/{stats['total_elements']} kept") - + # Apply masks apply_masks_to_model(model, masks, wrapper.tracked_layers) - + return masks @@ -178,13 +179,13 @@ def apply_masks_to_model(model, masks, layer_names): for name, module in model.named_modules(): if name in masks and hasattr(module, 'weight'): mask = masks[name] - + # Expand mask to weight dimensions if isinstance(module, nn.Linear): # Zero out entire rows (neurons) weight_mask = mask.unsqueeze(1).expand_as(module.weight) module.weight.data *= weight_mask.float() - + if module.bias is not None: module.bias.data *= mask.float() @@ -194,55 +195,55 @@ def main(): print("=" * 80) print("MNIST Intelligent Pruning - Complete Workflow") print("=" * 80) - + device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f"\nDevice: {device}") - + # Load MNIST print("\n1. Loading MNIST...") transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) - + train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST('./data', train=False, transform=transform) - + train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False) - + # Validation loader (smaller for metric computation) val_loader = DataLoader(test_dataset, batch_size=512, shuffle=False) - + # Train baseline model print("\n2. Training baseline model...") model = SimpleMLP().to(device) train_model(model, train_loader, epochs=3, device=device) - + baseline_acc = evaluate(model, test_loader, device) print(f"\n✓ Baseline accuracy: {baseline_acc:.2f}%") - + # Wrap model - wrapper = BaseModelWrapper( + BaseModelWrapper( model, tracked_layers=['fc1', 'fc2'], # Track hidden layers track_inputs=True, track_outputs=True ) - + # Pruning experiments print("\n3. Pruning experiments...") print("-" * 80) - + pruning_amount = 0.5 # Prune 50% methods = ['random', 'magnitude', 'rq', 'composite'] results = {} - + for method in methods: # Create fresh copy of model model_copy = SimpleMLP().to(device) model_copy.load_state_dict(model.state_dict()) - + # Wrap wrapper_copy = BaseModelWrapper( model_copy, @@ -250,57 +251,57 @@ def main(): track_inputs=True, track_outputs=True ) - + # Prune - masks = prune_model( - model_copy, wrapper_copy, method, + prune_model( + model_copy, wrapper_copy, method, pruning_amount, val_loader, device ) - + # Evaluate immediately after pruning (no fine-tuning) acc_pruned = evaluate(model_copy, test_loader, device) - + # Fine-tune print(f"\n Fine-tuning {method}...") train_model(model_copy, train_loader, epochs=2, device=device) - + # Final evaluation acc_final = evaluate(model_copy, test_loader, device) - + results[method] = { 'acc_before': baseline_acc, 'acc_pruned': acc_pruned, 'acc_final': acc_final, 'drop': baseline_acc - acc_final } - + print(f" {method}: {baseline_acc:.2f}% → {acc_pruned:.2f}% → {acc_final:.2f}% (drop: {baseline_acc - acc_final:.2f}%)") - + # Summary print("\n" + "=" * 80) print("RESULTS SUMMARY") print("=" * 80) print(f"\nPruning amount: {pruning_amount:.0%} of neurons") print(f"Baseline accuracy: {baseline_acc:.2f}%\n") - + print("Method | After Pruning | After Fine-tune | Accuracy Drop") print("-" * 70) for method, res in results.items(): print(f"{method:15s} | {res['acc_pruned']:13.2f}% | {res['acc_final']:15.2f}% | {res['drop']:13.2f}%") - + # Find best method best_method = min(results.keys(), key=lambda m: results[m]['drop']) print(f"\n✓ Best method: {best_method} (smallest accuracy drop: {results[best_method]['drop']:.2f}%)") - + print("\nComposite pruning considers redundancy and synergy in addition to alignment.") - + # Save results output_dir = Path('results/mnist_intelligent_pruning') output_dir.mkdir(parents=True, exist_ok=True) - + torch.save(results, output_dir / 'pruning_results.pt') print(f"\n✓ Results saved to {output_dir}") - + return results diff --git a/examples/08_llama_ffn_pruning.py b/examples/08_llama_ffn_pruning.py index 67d9698e..91ed3055 100644 --- a/examples/08_llama_ffn_pruning.py +++ b/examples/08_llama_ffn_pruning.py @@ -12,59 +12,58 @@ up_proj: Linear(4096 → 11,008) ← 11,008 neurons we can analyze! gate_proj: Linear(4096 → 11,008) down_proj: Linear(11,008 → 4096) - + Forward: down_proj(SiLU(gate_proj(x)) * up_proj(x)) """ + import torch import torch.nn as nn from transformers import AutoModelForCausalLM, AutoTokenizer -from typing import Dict, Optional + +from alignment.metrics import get_metric # Alignment framework from alignment.models.transformer_enhanced import LLaMAWrapper from alignment.services import ( ActivationCaptureService, - NodeScoringService, - MaskOperations + MaskOperations, ) -from alignment.metrics import get_metric -from alignment.pruning.dependency_aware import DependencyAwarePruning def load_llama_model(model_name: str = 'gpt2', use_small_model: bool = True): """ Load LLaMA or similar model. - + Args: model_name: HuggingFace model name use_small_model: If True, use GPT-2 as proxy (faster for demo) - + Returns: model, tokenizer """ # For demo, use GPT-2 (similar architecture, much smaller) # Replace with 'meta-llama/Meta-Llama-3-8B' for actual LLaMA-3 - + if use_small_model: model_name = 'gpt2' # 12 layers, 768 hidden, much faster to load - print(f"Using GPT-2 as demo (same architecture principles as LLaMA)") - + print("Using GPT-2 as demo (same architecture principles as LLaMA)") + print(f"Loading {model_name}...") model = AutoModelForCausalLM.from_pretrained(model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) - + # Set padding token if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token - + model.eval() - + print(f"✓ Loaded {model_name}") print(f" - Layers: {model.config.num_hidden_layers}") print(f" - Hidden size: {model.config.hidden_size}") print(f" - Attention heads: {model.config.num_attention_heads}") - + return model, tokenizer @@ -77,32 +76,32 @@ def analyze_ffn_layer( ): """ Analyze a single FFN layer - compute per-neuron scores. - + Args: model: LLaMA/GPT model wrapper: LLaMAWrapper instance layer_idx: Which transformer layer to analyze (0-indexed) input_text: List of input strings tokenizer: HuggingFace tokenizer - + Returns: Dict with per-neuron scores """ print(f"\n{'='*80}") print(f"Analyzing Layer {layer_idx} FFN") print(f"{'='*80}") - + # Tokenize inputs inputs = tokenizer(input_text, padding=True, truncation=True, return_tensors='pt') - + # Get FFN layer names (HF naming convention) # GPT-2: transformer.h.{i}.mlp.c_fc (up), c_proj (down) # LLaMA: model.layers.{i}.mlp.up_proj, down_proj, gate_proj - + # Try to infer FFN layer names ffn_up = None ffn_down = None - + for name, module in model.named_modules(): if f'.{layer_idx}.' in name or f'.h.{layer_idx}.' in name: if 'mlp' in name: @@ -111,51 +110,51 @@ def analyze_ffn_layer( ffn_up = name elif 'down_proj' in name or 'c_proj' in name: ffn_down = name - + if ffn_up is None: print(f" ⚠️ Could not find FFN layers for layer {layer_idx}") return {} - - print(f" FFN layers found:") + + print(" FFN layers found:") print(f" - Up projection: {ffn_up}") print(f" - Down projection: {ffn_down}") - + # Get module to check dimensions ffn_up_module = dict(model.named_modules())[ffn_up] num_neurons = ffn_up_module.out_features hidden_dim = ffn_up_module.in_features - - print(f" Dimensions:") + + print(" Dimensions:") print(f" - Hidden: {hidden_dim}") print(f" - FFN neurons: {num_neurons} ← Can analyze each one!") - + # Wrap model to track this layer wrapper_ffn = LLaMAWrapper(model, tracked_layers=[ffn_up]) - + # Capture activations - capture = ActivationCaptureService(wrapper_ffn) - + ActivationCaptureService(wrapper_ffn) + with torch.no_grad(): # Use model's forward to get activations - outputs = model(**inputs) - + model(**inputs) + # Get cached activations acts = wrapper_ffn._activation_cache - + # Get layer activations and weights ffn_input = acts.get(f'{ffn_up}_input') ffn_output = acts.get(f'{ffn_up}_output') ffn_weights = ffn_up_module.weight.detach() # [num_neurons, hidden_dim] - + if ffn_input is None or ffn_output is None: print(" ⚠️ Could not capture activations") return {} - - print(f"\n Activation shapes:") + + print("\n Activation shapes:") print(f" - Input: {ffn_input.shape}") # [B, T, hidden_dim] print(f" - Output: {ffn_output.shape}") # [B, T, num_neurons] print(f" - Weights: {ffn_weights.shape}") # [num_neurons, hidden_dim] - + # Preprocess for metrics (average over sequence) if ffn_input.ndim == 3: ffn_input_2d = ffn_input.mean(dim=1) # [B, hidden_dim] @@ -163,37 +162,37 @@ def analyze_ffn_layer( else: ffn_input_2d = ffn_input ffn_output_2d = ffn_output - - print(f"\n Computing per-neuron metrics...") - + + print("\n Computing per-neuron metrics...") + # Compute multiple metrics per neuron metrics_results = {} - + # 1. Rayleigh Quotient (alignment) - print(f" - RQ (alignment with input PCs)...") + print(" - RQ (alignment with input PCs)...") rq_metric = get_metric('rayleigh_quotient') rq_scores = rq_metric.compute(inputs=ffn_input_2d, weights=ffn_weights) metrics_results['rq'] = rq_scores print(f" Range: [{rq_scores.min():.4f}, {rq_scores.max():.4f}], Mean: {rq_scores.mean():.4f}") - + # 2. Redundancy (overlap with other neurons) - print(f" - Redundancy (overlap with other neurons)...") - redundancy_metric = get_metric('pairwise_redundancy_gaussian', + print(" - Redundancy (overlap with other neurons)...") + redundancy_metric = get_metric('pairwise_redundancy_gaussian', mode='output_based', # FAST! num_pairs=20) redundancy_scores = redundancy_metric.compute(outputs=ffn_output_2d) metrics_results['redundancy'] = redundancy_scores print(f" Range: [{redundancy_scores.min():.4f}, {redundancy_scores.max():.4f}], Mean: {redundancy_scores.mean():.4f}") - + # 3. Show per-neuron breakdown - print(f"\n Per-neuron analysis (first 10 neurons):") + print("\n Per-neuron analysis (first 10 neurons):") print(f" {'Neuron':<8} {'RQ':<10} {'Redundancy':<12} {'Assessment'}") print(f" {'-'*60}") - + for i in range(min(10, num_neurons)): rq_val = rq_scores[i].item() red_val = redundancy_scores[i].item() - + # Assess importance if red_val > 0.5: assessment = "Redundant (candidate for pruning)" @@ -201,18 +200,18 @@ def analyze_ffn_layer( assessment = "Important (high alignment)" else: assessment = "Low importance" - + print(f" {i:<8} {rq_val:<10.4f} {red_val:<12.4f} {assessment}") - + # Show statistics high_redundancy = (redundancy_scores > 0.5).sum().item() low_rq = (rq_scores < 0.005).sum().item() - - print(f"\n Summary:") + + print("\n Summary:") print(f" - High redundancy neurons (>0.5): {high_redundancy}/{num_neurons} ({100*high_redundancy/num_neurons:.1f}%)") print(f" - Low RQ neurons (<0.005): {low_rq}/{num_neurons} ({100*low_rq/num_neurons:.1f}%)") print(f" - Potential pruning candidates: {max(high_redundancy, low_rq)} neurons") - + return metrics_results @@ -224,7 +223,7 @@ def prune_ffn_neurons( ): """ Prune individual neurons in FFN layer with dependency awareness. - + Args: model: LLaMA model layer_idx: Layer index @@ -234,11 +233,11 @@ def prune_ffn_neurons( print(f"\n{'='*80}") print(f"Pruning Layer {layer_idx} FFN (amount={amount:.0%})") print(f"{'='*80}") - + # Find FFN layers ffn_up = None ffn_down = None - + for name, module in model.named_modules(): if f'.{layer_idx}.' in name and 'mlp' in name: if isinstance(module, nn.Linear): @@ -246,46 +245,46 @@ def prune_ffn_neurons( ffn_up = (name, module) elif 'down_proj' in name or 'c_proj' in name: ffn_down = (name, module) - + if ffn_up is None or ffn_down is None: print(" ⚠️ Could not find FFN layers") return - + up_name, up_module = ffn_up down_name, down_module = ffn_down - + # Create mask mask = MaskOperations.create_structured_mask(scores, amount=amount, mode='low') num_kept = mask.sum().item() num_total = len(mask) - + print(f"\n Mask: {num_kept}/{num_total} neurons kept ({100*num_kept/num_total:.1f}%)") - + # Apply to up_proj (output dimension) print(f"\n Applying to {up_name}:") print(f" - Before: {up_module.weight.shape}") - + with torch.no_grad(): # Mask output neurons (rows of up_proj) up_module.weight.data *= mask.unsqueeze(1).float() if up_module.bias is not None: up_module.bias.data *= mask.float() - + print(f" - Pruned: {(~mask).sum().item()} neurons zeroed out") - + # Apply to down_proj (input dimension) - DEPENDENCY! print(f"\n Applying to {down_name} (dependency propagation):") print(f" - Before: {down_module.weight.shape}") - + with torch.no_grad(): # Mask input neurons (columns of down_proj) down_module.weight.data *= mask.unsqueeze(0).float() - + print(f" - Pruned: {(~mask).sum().item()} inputs zeroed out (matches up_proj)") - + # Verify dependency handled print(f"\n ✓ Dependency handled: up_proj outputs ({num_kept}) = down_proj inputs ({num_kept})") - + return mask @@ -294,85 +293,85 @@ def main(): print("=" * 80) print("LLaMA FFN Per-Neuron Analysis & Pruning") print("=" * 80) - + # Setup device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f"\nDevice: {device}") - + # Load model (using GPT-2 for demo, same principles apply to LLaMA-3) model, tokenizer = load_llama_model(use_small_model=True) model = model.to(device) - + # Wrap model wrapper = LLaMAWrapper( model, track_ffn=True, track_attention=True ) - + # Sample inputs input_texts = [ "The capital of France is", "Artificial intelligence is", "The meaning of life is" ] - + # Analyze first layer's FFN layer_idx = 0 scores_dict = analyze_ffn_layer( model, wrapper, layer_idx, input_texts, tokenizer ) - + if not scores_dict: print("\n⚠️ Could not analyze FFN layer") return - + # Create composite score print(f"\n{'='*80}") print("Creating Composite Importance Score") print(f"{'='*80}") - + # Combine RQ and redundancy rq = scores_dict['rq'] redundancy = scores_dict['redundancy'] - + # Composite: High RQ + Low Redundancy = Important # Score = RQ - 0.5*Redundancy composite = torch.log(rq + 1e-8) - 0.5 * redundancy - - print(f" Composite = log(RQ) - 0.5*Redundancy") + + print(" Composite = log(RQ) - 0.5*Redundancy") print(f" Range: [{composite.min():.4f}, {composite.max():.4f}]") - + # Identify most/least important neurons top_important = torch.argsort(composite, descending=True)[:5] top_redundant = torch.argsort(redundancy, descending=True)[:5] - + print(f"\n Most important neurons (high composite): {top_important.tolist()}") print(f" Most redundant neurons (high redundancy): {top_redundant.tolist()}") - + # Prune using composite scores print(f"\n{'='*80}") print("Pruning FFN Neurons (Dependency-Aware)") print(f"{'='*80}") - + pruning_amount = 0.3 # Prune 30% of FFN neurons - + mask = prune_ffn_neurons( model, layer_idx=layer_idx, scores=composite, amount=pruning_amount ) - + # Verify model still runs print(f"\n{'='*80}") print("Verifying Pruned Model") print(f"{'='*80}") - + with torch.no_grad(): # Tokenize test_input = tokenizer("After pruning, the model", return_tensors='pt').to(device) - + # Forward pass try: output = model(**test_input) @@ -380,7 +379,7 @@ def main(): print(f" ✓ Output shape: {output.logits.shape}") except Exception as e: print(f" ✗ Model failed after pruning: {e}") - + # Summary print(f"\n{'='*80}") print("Summary") diff --git a/examples/09_attention_neuron_vs_head_pruning.py b/examples/09_attention_neuron_vs_head_pruning.py index f178fdf2..a781e6dd 100644 --- a/examples/09_attention_neuron_vs_head_pruning.py +++ b/examples/09_attention_neuron_vs_head_pruning.py @@ -16,11 +16,10 @@ import torch import torch.nn as nn -from transformers import AutoModelForCausalLM, AutoTokenizer +from transformers import AutoModelForCausalLM -from alignment.models.transformer_enhanced import TransformerWrapperEnhanced -from alignment.services import NodeScoringService, MaskOperations from alignment.metrics import get_metric +from alignment.services import MaskOperations def analyze_attention_structure(model): @@ -28,10 +27,10 @@ def analyze_attention_structure(model): print("=" * 80) print("Attention Layer Structure") print("=" * 80) - + # Get first attention layer attn_layer = model.transformer.h[0].attn if hasattr(model, 'transformer') else model.model.layers[0].self_attn - + # Find projection layers for name, module in attn_layer.named_modules(): if isinstance(module, nn.Linear): @@ -39,7 +38,7 @@ def analyze_attention_structure(model): print(f" Weight shape: {module.weight.shape}") print(f" Output neurons: {module.weight.shape[0]}") print(f" Input dimension: {module.weight.shape[1]}") - + if module.weight.shape[0] % 32 == 0: # Assuming 32 heads neurons_per_head = module.weight.shape[0] // 32 print(f" → Organized as: 32 heads × {neurons_per_head} dims/head") @@ -49,13 +48,13 @@ def analyze_attention_structure(model): def neuron_level_pruning_demo(model, layer_idx=0): """ Demonstrate NEURON-LEVEL pruning in attention. - + Treats Q/K/V/O projections as Linear layers and prunes individual neurons. """ print("\n" + "=" * 80) print("NEURON-LEVEL Attention Pruning") print("=" * 80) - + # Get attention layer try: attn = model.transformer.h[layer_idx].attn # GPT-2 style @@ -65,12 +64,9 @@ def neuron_level_pruning_demo(model, layer_idx=0): except: attn = model.model.layers[layer_idx].self_attn # LLaMA style q_proj = attn.q_proj - k_proj = attn.k_proj - v_proj = attn.v_proj - o_proj = attn.o_proj print(" Model type: LLaMA (separate Q/K/V/O projections)") separate_projections = True - + if not separate_projections: print(" Note: GPT-2 combines Q/K/V, will demonstrate on combined projection") target_proj = q_proj @@ -78,82 +74,82 @@ def neuron_level_pruning_demo(model, layer_idx=0): else: target_proj = q_proj proj_name = "Q projection" - + num_neurons = target_proj.weight.shape[0] hidden_dim = target_proj.weight.shape[1] - + print(f"\n{proj_name}:") print(f" Shape: {target_proj.weight.shape}") print(f" Total neurons: {num_neurons}") print(f" Input dimension: {hidden_dim}") - + if num_neurons % 32 == 0: print(f" Organized as: {num_neurons // 128} heads × 128 dims") - + # Create dummy inputs for scoring - print(f"\nComputing per-neuron importance...") + print("\nComputing per-neuron importance...") inputs = torch.randn(16, hidden_dim) # [B, hidden_dim] outputs = target_proj(inputs) # [B, num_neurons] - + # Compute per-neuron scores rq = get_metric('rayleigh_quotient') rq_scores = rq.compute(inputs, target_proj.weight) - + redundancy = get_metric('pairwise_redundancy_gaussian', mode='output_based', num_pairs=20) redundancy_scores = redundancy.compute(outputs=outputs) - + # Composite score composite = torch.log(rq_scores + 1e-8) - 0.4 * redundancy_scores - + # Show individual neuron analysis print(f"\n Individual neuron breakdown (first 10 of {num_neurons}):") print(f" {'Neuron':<8} {'RQ':<10} {'Redundancy':<12} {'Composite':<12}") print(f" {'-'*50}") - + for i in range(min(10, num_neurons)): print(f" {i:<8} {rq_scores[i]:.4f} {redundancy_scores[i]:.4f} {composite[i]:.4f}") - + # Prune neurons pruning_amount = 0.3 # 30% mask = MaskOperations.create_structured_mask(composite, amount=pruning_amount, mode='low') - + num_pruned = (~mask).sum().item() num_kept = mask.sum().item() - - print(f"\n Pruning plan:") + + print("\n Pruning plan:") print(f" Amount: {pruning_amount:.0%}") print(f" Neurons pruned: {num_pruned}") print(f" Neurons kept: {num_kept}") - + # Show which neurons are pruned pruned_indices = torch.where(~mask)[0] if len(pruned_indices) <= 20: print(f" Pruned neuron indices: {pruned_indices.tolist()}") else: print(f" Pruned neuron indices: {pruned_indices[:10].tolist()} ... (showing first 10)") - + # Apply pruning - print(f"\n Applying neuron-level mask...") + print("\n Applying neuron-level mask...") target_proj.weight.data *= mask.unsqueeze(1).float() if target_proj.bias is not None: target_proj.bias.data *= mask.float() - + print(f" ✓ {num_pruned} neurons zeroed out") - print(f" ✓ Can prune ANY subset of neurons (not constrained to heads)") - + print(" ✓ Can prune ANY subset of neurons (not constrained to heads)") + return mask, composite def head_level_pruning_demo(model, layer_idx=0): """ Demonstrate HEAD-LEVEL pruning in attention. - + Aggregates neurons into heads and prunes entire heads. """ print("\n" + "=" * 80) print("HEAD-LEVEL Attention Pruning") print("=" * 80) - + try: attn = model.model.layers[layer_idx].self_attn q_proj = attn.q_proj @@ -162,69 +158,69 @@ def head_level_pruning_demo(model, layer_idx=0): except: print(" Using GPT-2 (combined QKV, skipping head demo)") return None, None - - print(f"\n Attention configuration:") + + print("\n Attention configuration:") print(f" Number of heads: {num_heads}") print(f" Dimension per head: {head_dim}") print(f" Total dimensions: {num_heads * head_dim}") - + # Compute neuron-level scores first inputs = torch.randn(16, 4096) outputs = q_proj(inputs) - + redundancy = get_metric('pairwise_redundancy_gaussian', mode='output_based', num_pairs=20) neuron_scores = redundancy.compute(outputs=outputs) # [4096] - + # Aggregate to head-level print(f"\n Aggregating {neuron_scores.shape[0]} neurons into {num_heads} heads...") - + head_scores = [] for head_idx in range(num_heads): start_idx = head_idx * head_dim end_idx = start_idx + head_dim - + # Average neuron scores within this head head_score = neuron_scores[start_idx:end_idx].mean() head_scores.append(head_score) - + head_scores = torch.tensor(head_scores) # [32] - + # Show per-head scores - print(f"\n Per-head redundancy:") + print("\n Per-head redundancy:") print(f" {'Head':<6} {'Redundancy':<12} {'Assessment'}") print(f" {'-'*40}") - + for h in range(min(10, num_heads)): assessment = "Redundant (prune)" if head_scores[h] > 0.5 else "Unique (keep)" print(f" {h:<6} {head_scores[h]:.4f} {assessment}") - + # Prune heads pruning_amount = 0.25 # 25% = 8 heads head_mask = MaskOperations.create_structured_mask(head_scores, amount=pruning_amount, mode='low') - + num_heads_pruned = (~head_mask).sum().item() - - print(f"\n Pruning plan:") + + print("\n Pruning plan:") print(f" Amount: {pruning_amount:.0%} of heads") print(f" Heads pruned: {num_heads_pruned}/{num_heads}") print(f" Pruned head indices: {torch.where(~head_mask)[0].tolist()}") - + # Expand to neuron-level mask neuron_mask = head_mask.repeat_interleave(head_dim) # [4096] - - print(f"\n Expanding to neuron-level:") + + print("\n Expanding to neuron-level:") print(f" Neuron mask shape: {neuron_mask.shape}") print(f" Neurons pruned: {(~neuron_mask).sum().item()} (= {num_heads_pruned} heads × {head_dim} dims)") - + # Apply to Q/K/V/O (all get same mask for consistency) - print(f"\n Applying to all projections (Q/K/V/O)...") + print("\n Applying to all projections (Q/K/V/O)...") q_proj.weight.data *= neuron_mask.unsqueeze(1).float() attn.k_proj.weight.data *= neuron_mask.unsqueeze(1).float() attn.v_proj.weight.data *= neuron_mask.unsqueeze(1).float() attn.o_proj.weight.data *= neuron_mask.unsqueeze(0).float() # Input dim - + print(f" ✓ {num_heads_pruned} heads pruned consistently across Q/K/V/O") - + return head_mask, head_scores @@ -233,32 +229,32 @@ def compare_approaches(neuron_mask, head_mask, num_heads=32, head_dim=128): print("\n" + "=" * 80) print("Comparison: Neuron-Level vs Head-Level") print("=" * 80) - + # Convert head mask to neuron mask for comparison if head_mask is not None: head_neuron_mask = head_mask.repeat_interleave(head_dim) else: print(" Head-level demo skipped (GPT-2 model)") return - + # Statistics neuron_pruned = (~neuron_mask).sum().item() head_equivalent_pruned = (~head_neuron_mask).sum().item() - - print(f"\nNeuron-Level Pruning:") + + print("\nNeuron-Level Pruning:") print(f" Neurons pruned: {neuron_pruned}") - print(f" Flexibility: Can prune ANY neurons") - print(f" Granularity: Individual neurons") - - print(f"\nHead-Level Pruning:") + print(" Flexibility: Can prune ANY neurons") + print(" Granularity: Individual neurons") + + print("\nHead-Level Pruning:") print(f" Neurons pruned: {head_equivalent_pruned} (= {head_equivalent_pruned//head_dim} heads × {head_dim})") print(f" Flexibility: Must prune in multiples of {head_dim}") - print(f" Granularity: Entire heads") - - print(f"\nKey Differences:") - print(f" ✓ Neuron-level: More flexible (any %, any neurons)") - print(f" ✓ Head-level: Cleaner (maintains head structure)") - print(f" ✓ Both supported by your framework!") + print(" Granularity: Entire heads") + + print("\nKey Differences:") + print(" ✓ Neuron-level: More flexible (any %, any neurons)") + print(" ✓ Head-level: Cleaner (maintains head structure)") + print(" ✓ Both supported by your framework!") def main(): @@ -266,35 +262,35 @@ def main(): print("=" * 80) print("Attention Layer Pruning: Neuron vs Head Level") print("=" * 80) - + device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f"\nDevice: {device}") - + # Load model (GPT-2 for demo, same principles for LLaMA) print("\nLoading model (GPT-2 for demo)...") model = AutoModelForCausalLM.from_pretrained('gpt2') model = model.to(device) model.eval() - - print(f"✓ Loaded GPT-2") + + print("✓ Loaded GPT-2") print(f" - Layers: {model.config.n_layer}") print(f" - Hidden size: {model.config.n_embd}") print(f" - Attention heads: {model.config.n_head}") - + # Analyze structure analyze_attention_structure(model) - + # Demonstrate neuron-level pruning neuron_mask, neuron_scores = neuron_level_pruning_demo(model, layer_idx=0) - + # Demonstrate head-level pruning # (Skip for GPT-2 since it has combined QKV, but would work for LLaMA) head_mask, head_scores = head_level_pruning_demo(model, layer_idx=0) - + # Compare if head_mask is not None: compare_approaches(neuron_mask, head_mask) - + # Summary print("\n" + "=" * 80) print("Summary: You Can Prune Attention at BOTH Levels!") diff --git a/examples/quick_demo.py b/examples/quick_demo.py index 462167e4..0459145c 100644 --- a/examples/quick_demo.py +++ b/examples/quick_demo.py @@ -6,13 +6,13 @@ Usage: python quick_demo.py - + No configuration needed - this script runs with default settings and creates its own model. Requirements: - PyTorch - alignment package installed - + Output: - Console output showing: * Model structure @@ -24,8 +24,9 @@ import torch import torch.nn as nn -from alignment.models import ModelWrapper + from alignment.metrics import get_metric +from alignment.models import ModelWrapper def main(): @@ -37,55 +38,55 @@ def main(): nn.ReLU(), nn.Linear(128, 10) ) - + print("Model created with 3 linear layers") - + # 2. Wrap the model to track activations wrapped_model = ModelWrapper(model) print(f"\nTracked layers: {wrapped_model.tracked_layers}") - + # 3. Generate some dummy data batch_size = 32 inputs = torch.randn(batch_size, 784) - + # 4. Forward pass with activation tracking outputs, activations = wrapped_model.forward_with_activations(inputs) print(f"\nCollected activations from {len(activations)} points") print(f"Activation keys: {list(activations.keys())}") - + # 5. Get layer weights weights = wrapped_model.get_layer_weights() print(f"Extracted weights from {len(weights)} layers") - + # 6. Compute alignment metrics print("\n" + "="*60) print("Computing Alignment Metrics") print("="*60) - + # Rayleigh Quotient RQMetric = get_metric('rayleigh_quotient') rq_metric = RQMetric() # Instantiate the metric - + for layer_name in wrapped_model.tracked_layers: # Get inputs and weights for this layer layer_inputs = activations[f"{layer_name}_input"] layer_weights = weights[layer_name] - + # Compute RQ scores scores = rq_metric.compute(inputs=layer_inputs, weights=layer_weights) - + print(f"\nLayer: {layer_name}") print(f" Input shape: {layer_inputs.shape}") print(f" Weight shape: {layer_weights.shape}") print(f" RQ scores: mean={scores.mean():.4f}, std={scores.std():.4f}") print(f" Min neuron score: {scores.min():.4f}") print(f" Max neuron score: {scores.max():.4f}") - + # 7. Try other metrics print("\n" + "="*60) print("Trying Other Metrics") print("="*60) - + # Weight cosine similarity WeightSimMetric = get_metric('weight_cosine_similarity') weight_sim = WeightSimMetric() # Instantiate @@ -93,35 +94,35 @@ def main(): layer_weights = weights[layer_name] sim_scores = weight_sim.compute(weights=layer_weights) print(f"\nWeight Cosine Similarity ({layer_name}): mean={sim_scores.mean():.4f}") - + # 8. Demonstrate pruning print("\n" + "="*60) print("Pruning Demo") print("="*60) - - from alignment.pruning import get_pruning_strategy, PruningConfig - + + from alignment.pruning import PruningConfig, get_pruning_strategy + # Use magnitude-based pruning config = PruningConfig(amount=0.5, pruning_mode='low') strategy = get_pruning_strategy('magnitude', config=config) - + # Apply pruning to first layer first_layer = model[0] # First linear layer mask = strategy.prune(first_layer) - - print(f"\nPruning 50% of weights in first layer") + + print("\nPruning 50% of weights in first layer") print(f"Sparsity achieved: {(mask == 0).float().mean():.2%}") - + # Apply mask first_layer.weight.data *= mask - + # Test forward pass after pruning pruned_outputs = model(inputs) print(f"\nOutput shape after pruning: {pruned_outputs.shape}") print("Pruning applied successfully!") - + print("\nDemo completed successfully!") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/run_experiment.py b/scripts/run_experiment.py index ccb6bc41..9a78ea71 100644 --- a/scripts/run_experiment.py +++ b/scripts/run_experiment.py @@ -14,19 +14,14 @@ """ import argparse +import json import logging import os import sys -import json -import yaml -from pathlib import Path from datetime import datetime -from typing import Dict, List, Any, Optional, Tuple +from pathlib import Path -import torch -import torch.nn as nn -import numpy as np -from tqdm import tqdm +import yaml # Add the src directory to Python path current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -34,8 +29,10 @@ # Import from the alignment package from alignment.experiments.general_alignment import GeneralAlignmentExperiment +from alignment.pruning.experiments.cascading_layer import ( + CascadingLayerPruningExperiment, +) from alignment.pruning.experiments.layer_wise import LayerIsolatedPruningExperiment -from alignment.pruning.experiments.cascading_layer import CascadingLayerPruningExperiment logger = logging.getLogger(__name__) @@ -44,7 +41,7 @@ def load_config(config_path, overrides=None): """Load and merge configuration.""" with open(config_path, 'r') as f: config = yaml.safe_load(f) - + # Apply overrides if overrides: for key, value in overrides.items(): @@ -55,7 +52,7 @@ def load_config(config_path, overrides=None): current[k] = {} current = current[k] current[keys[-1]] = value - + return config @@ -63,11 +60,11 @@ def create_experiment_config(unified_config): """Convert unified config to experiment config object.""" from alignment.experiments.base import ExperimentConfig from alignment.experiments.general_alignment import GeneralAlignmentConfig - + # Extract model config and handle different naming conventions model_config = unified_config.get('model', {}) model_name = model_config.get('name', model_config.get('architecture', 'mlp')) - + # Extract dataset config dataset_config = unified_config.get('dataset', {}) if isinstance(dataset_config, str): @@ -76,7 +73,7 @@ def create_experiment_config(unified_config): dataset_config = {'name': dataset_name} else: dataset_name = dataset_config.get('name', dataset_config.get('dataset', 'mnist')) - + # Build base parameters common to all experiment types base_params = { 'name': unified_config.get('experiment_name', 'unified_experiment'), @@ -88,7 +85,7 @@ def create_experiment_config(unified_config): 'num_workers': dataset_config.get('num_workers', unified_config.get('data', {}).get('num_workers', 4)), 'metrics': unified_config.get('alignment', {}).get('metrics', unified_config.get('analysis', {}).get('metrics', ['rayleigh_quotient'])), } - + # Build model config with proper parameter names model_kwargs = {} if model_name == 'mlp': @@ -109,9 +106,9 @@ def create_experiment_config(unified_config): else: model_kwargs['input_dim'] = model_config.get('input_dim', 784) model_kwargs['output_dim'] = model_config.get('output_dim', 10) - + base_params['model_config'] = model_kwargs - + # Add training config if present training_config = unified_config.get('training', {}) if training_config: @@ -123,15 +120,15 @@ def create_experiment_config(unified_config): else: base_params['learning_rate'] = training_config.get('learning_rate', 0.001) base_params['optimizer'] = training_config.get('optimizer', 'adam') - + # Infer experiment type based on enabled blocks pruning_cfg = unified_config.get('pruning', {}) dropout_cfg = unified_config.get('dropout', {}) - + pruning_enabled = pruning_cfg.get('enabled', False) cascading_scope = pruning_cfg.get('scope', 'layer') == 'cascading' dropout_enabled = dropout_cfg.get('enabled', False) - + # Determine if we need specialized experiment types or GeneralAlignmentConfig if cascading_scope and pruning_enabled: # For cascading layer pruning, use base ExperimentConfig @@ -149,14 +146,14 @@ def create_experiment_config(unified_config): general_params['aggregate_metrics'] = unified_config.get('aggregate_metrics', True) general_params['save_individual_networks'] = unified_config.get('save_individual_networks', False) general_params['save_checkpoints'] = unified_config.get('save_checkpoints', False) - + config = GeneralAlignmentConfig(**general_params) - + # Set flags based on enabled blocks config.do_train = training_config.get('epochs', training_config.get('do_train', 0)) > 0 config.do_dropout_analysis = dropout_enabled config.do_pruning_experiments = pruning_enabled - + # Determine inferred type for logging if pruning_enabled: inferred_type = 'standard_pruning' @@ -164,12 +161,12 @@ def create_experiment_config(unified_config): inferred_type = 'progressive_dropout' else: inferred_type = 'alignment_analysis' - + # Handle plot generation visualization_config = unified_config.get('visualization', {}) output_config = unified_config.get('output', {}) analysis_config = unified_config.get('analysis', {}) - + # Default to True if not explicitly set to False generate_plots = True if 'generate_plots' in visualization_config: @@ -178,66 +175,66 @@ def create_experiment_config(unified_config): generate_plots = output_config['generate_plots'] elif 'generate_plots' in analysis_config: generate_plots = analysis_config['generate_plots'] - + config.generate_plots = generate_plots logger.info(f"Plot generation enabled: {config.generate_plots}") - + # Pruning specific configuration if pruning_enabled: # Get algorithms algorithms = pruning_cfg.get('algorithms', ['magnitude']) config.pruning_strategies = algorithms if isinstance(algorithms, list) else [algorithms] - + # Get sparsity levels sparsity_levels = pruning_cfg.get('sparsity_levels', [0.0, 0.1, 0.3, 0.5, 0.7, 0.9]) config.pruning_amounts = sparsity_levels - + # Get selection modes selection_modes = pruning_cfg.get('selection_modes', pruning_cfg.get('selection_mode', ['low'])) config.pruning_selection_mode = selection_modes if isinstance(selection_modes, list) else [selection_modes] - + # Fine-tuning settings config.fine_tune_after_pruning = pruning_cfg.get('fine_tune_after_pruning', True) config.fine_tune_epochs = pruning_cfg.get('fine_tune_epochs', 5) config.fine_tune_learning_rate = pruning_cfg.get('fine_tune_learning_rate', 0.0001) - + # Scope settings scope = pruning_cfg.get('scope', 'layer') config.pruning_scope = scope - + # Alignment metric settings config.pruning_alignment_metric = pruning_cfg.get('alignment_metric', 'rayleigh_quotient') config.pruning_hybrid_alpha = pruning_cfg.get('hybrid_alpha', 0.5) - + # Ultra-parallel evaluation settings config.use_ultra_parallel_eval = pruning_cfg.get('use_ultra_parallel_eval', False) config.eval_batches = pruning_cfg.get('eval_batches', None) - + logger.info(f"Pruning enabled: algorithms={config.pruning_strategies}, levels={config.pruning_amounts}, modes={config.pruning_selection_mode}") logger.info(f"Ultra-parallel eval: {config.use_ultra_parallel_eval}, eval_batches: {config.eval_batches}") - + # Dropout specific configuration if dropout_enabled: config.dropout_rates = dropout_cfg.get('rates', [0.0, 0.1, 0.3, 0.5, 0.7, 0.9]) logger.info(f"Dropout enabled: rates={config.dropout_rates}") - + # Common configuration for all experiment types if not isinstance(config, GeneralAlignmentConfig): # Ensure plot generation is enabled for other experiment types too visualization_config = unified_config.get('visualization', {}) output_config = unified_config.get('output', {}) config.generate_plots = visualization_config.get('generate_plots', output_config.get('generate_plots', True)) - + # Add additional attributes for compatibility config.training_config = training_config config.train_model = config.training_config.get('epochs', 0) > 0 config.alignment_metrics = config.metrics config.apply_pruning = pruning_enabled - + # Legacy support for pruning_analysis and network_compression blocks pruning_analysis = unified_config.get('pruning_analysis', {}) network_compression = unified_config.get('network_compression', {}) - + # Use the appropriate config based on what's enabled if pruning_analysis.get('enabled', False): active_pruning_config = pruning_analysis @@ -249,12 +246,12 @@ def create_experiment_config(unified_config): # Fallback to pruning config or empty active_pruning_config = pruning_cfg if pruning_enabled else {} config.pruning_strategy = pruning_cfg.get('algorithms', ['magnitude'])[0] if pruning_enabled else 'magnitude' - + config.pruning_config = active_pruning_config config.analysis_config = unified_config.get('visualization', unified_config.get('analysis', {})) config.eval_model = True config.cnn_mode = model_config.get('cnn_mode', 'unfold') - + # Set dropout rates (handle multiple sources) if hasattr(config, 'dropout_rates'): # Already set for GeneralAlignmentConfig @@ -263,7 +260,7 @@ def create_experiment_config(unified_config): config.dropout_rates = pruning_analysis.get('dropout_rates', [0.0, 0.1, 0.3, 0.5, 0.7, 0.9]) else: config.dropout_rates = dropout_cfg.get('rates', [0.0, 0.1, 0.3, 0.5, 0.7, 0.9]) - + # Handle selection modes for compatibility if pruning_analysis.get('enabled', False): selection_mode = pruning_analysis.get('selection_strategies', ['low']) @@ -271,19 +268,19 @@ def create_experiment_config(unified_config): selection_mode = [network_compression.get('selection_strategy', 'low')] else: selection_mode = pruning_cfg.get('selection_modes', ['low']) if pruning_enabled else ['low'] - + config.pruning_modes = selection_mode if isinstance(selection_mode, list) else [selection_mode] - + config.cascade_direction = unified_config.get('experiment_specific', {}).get('cascade_direction', 'forward') config.recompute_scores = True - + # Note: Output directories are now set in main() after creating timestamped folders config.plot_dpi = unified_config.get('visualization', {}).get('plot_dpi', unified_config.get('output', {}).get('plot_dpi', 300)) - + # Log the inferred experiment type logger.info(f"Inferred experiment type: {inferred_type} based on config blocks") config._inferred_experiment_type = inferred_type # Store for later use - + return config @@ -294,57 +291,57 @@ def main(): parser.add_argument('--device', type=str, help='Override device') parser.add_argument('--seed', type=int, help='Override seed') parser.add_argument('--output-dir', type=str, help='Override output directory') - + args, unknown = parser.parse_known_args() - + # Parse additional overrides overrides = {} if args.device: overrides['device'] = args.device if args.seed: overrides['seed'] = args.seed - + # Load config using the proper config loader from alignment.configs.config_loader import load_config as proper_load_config config = proper_load_config(args.config) - + # Apply overrides to the loaded config if overrides: for key, value in overrides.items(): if hasattr(config, key): setattr(config, key, value) - + # Create timestamped output directory timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") experiment_name = getattr(config, 'name', 'experiment') - + if args.output_dir: output_dir = Path(args.output_dir) else: # Create a unique directory with experiment name and timestamp output_dir = Path(f"results/{experiment_name}_{timestamp}") - + output_dir.mkdir(parents=True, exist_ok=True) - + # Save the configuration used config_save_path = output_dir / 'experiment_config.yaml' config.save(config_save_path) - + # Update config with timestamped directories config.checkpoint_dir = str(output_dir / 'checkpoints') config.log_dir = str(output_dir / 'logs') config.experiment_dir = str(output_dir) # Add experiment_dir for compatibility - + # Create plots directory in results folder (not in logs) plots_dir = output_dir / 'plots' config.plots_dir = str(plots_dir) # Add plots_dir for visualization - + # Ensure directories exist Path(config.checkpoint_dir).mkdir(parents=True, exist_ok=True) Path(config.log_dir).mkdir(parents=True, exist_ok=True) plots_dir.mkdir(parents=True, exist_ok=True) logger.info(f"Created plots directory: {plots_dir}") - + # Setup logging to both file and console log_file = output_dir / 'experiment.log' logging.basicConfig( @@ -355,10 +352,10 @@ def main(): logging.StreamHandler() ] ) - + # Print experiment info print(f"\n{'='*60}") - print(f"Running Alignment Experiment") + print("Running Alignment Experiment") print(f"{'='*60}") print(f"Configuration: {args.config}") print(f"Output directory: {output_dir}") @@ -366,13 +363,13 @@ def main(): print(f"Plot generation: {getattr(config, 'generate_plots', True)}") print(f"Plots directory: {plots_dir}") print(f"{'='*60}\n") - + # Use the inferred experiment type from config experiment_type = getattr(config, '_inferred_experiment_type', 'alignment_analysis') - + logger.info(f"Running {experiment_type} experiment") - - + + # Create experiment based on inferred type if experiment_type in ['standard_pruning', 'progressive_dropout', 'alignment_analysis']: experiment = GeneralAlignmentExperiment(config) @@ -382,14 +379,14 @@ def main(): experiment = CascadingLayerPruningExperiment(config) else: raise ValueError(f"Unknown experiment type: {experiment_type}") - - + + # Run experiment results = experiment.run() - + # Save results with timestamp results_file = output_dir / f'results_{timestamp}.json' - + # Convert numpy arrays to lists for JSON serialization def convert_to_serializable(obj): if hasattr(obj, 'tolist'): @@ -399,12 +396,12 @@ def convert_to_serializable(obj): elif isinstance(obj, list): return [convert_to_serializable(i) for i in obj] return obj - + serializable_results = convert_to_serializable(results) - + with open(results_file, 'w') as f: json.dump(serializable_results, f, indent=2) - + # Create experiment summary summary_file = output_dir / 'experiment_summary.txt' with open(summary_file, 'w') as f: @@ -414,19 +411,19 @@ def convert_to_serializable(obj): f.write(f"Experiment Type: {experiment_type} (inferred from config blocks)\n") f.write(f"Plot Generation: {getattr(config, 'generate_plots', True)}\n") f.write("=" * 50 + "\n\n") - + # Add results summary if 'test_results' in results: f.write("Final Model Performance:\n") f.write(f" - Accuracy: {results['test_results'].get('final_accuracy', 'N/A'):.2f}%\n") f.write(f" - Loss: {results['test_results'].get('final_loss', 'N/A'):.4f}\n\n") - + if 'pruning_results' in results and results['pruning_results']: f.write("Pruning Experiments:\n") strategies = results['pruning_results'].get('strategies', {}) f.write(f" - Strategies tested: {list(strategies.keys())}\n") f.write(f" - Plots saved in: {config.log_dir}/plots/\n") - + # List plots created plots_created = list(plots_dir.glob('*')) if plots_created: @@ -436,28 +433,28 @@ def convert_to_serializable(obj): f.write(f" - {plot_file.name}\n") else: f.write("\nNo plots were generated.\n") - + f.write("\nGenerated Files:\n") for file_path in sorted(output_dir.rglob('*')): if file_path.is_file(): relative_path = file_path.relative_to(output_dir) f.write(f" - {relative_path}\n") - + # Print completion message print(f"\n{'='*60}") print("Experiment Complete!") print(f"{'='*60}") - + if 'test_results' in results: print(f"Final model accuracy: {results['test_results'].get('final_accuracy', 'N/A'):.2f}%") print(f"Final model loss: {results['test_results'].get('final_loss', 'N/A'):.4f}") - + print(f"\nAll results saved in: {output_dir}") print(f" - Configuration: {config_save_path}") print(f" - Results: {results_file}") print(f" - Summary: {summary_file}") print(f" - Logs: {log_file}") - + # Check and report on plots plots_created = list(plots_dir.glob('*')) if plots_created: @@ -466,13 +463,13 @@ def convert_to_serializable(obj): if plot_file.is_file(): print(f" * {plot_file.name}") else: - print(f" - No plots generated (check generate_plots setting and experiment configuration)") + print(" - No plots generated (check generate_plots setting and experiment configuration)") print(f" * Pruning enabled: {getattr(config, 'do_pruning_experiments', False)}") print(f" * Plot generation: {getattr(config, 'generate_plots', False)}") print(f" * Pruning results in output: {'pruning_results' in results}") - + print(f"{'='*60}\n") if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/src/alignment/__init__.py b/src/alignment/__init__.py index 205b0035..dea0bd96 100644 --- a/src/alignment/__init__.py +++ b/src/alignment/__init__.py @@ -10,27 +10,27 @@ # Core functionality from .core.base import BaseMetric from .core.registry import METRIC_REGISTRY -from .models import ModelWrapper - -# Metrics -from .metrics import get_metric, list_metrics # Data processing from .data.processing import BatchMetricProcessor -# Pruning -from .pruning import PruningConfig, get_pruning_strategy - # Experiment tracking from .experiments.tracking import create_tracker +# Metrics +from .metrics import get_metric, list_metrics +from .models import ModelWrapper + +# Pruning +from .pruning import PruningConfig, get_pruning_strategy + # Services (NEW in v0.2.0) from .services import ( ActivationCaptureService, ActivationData, - NodeScoringService, CompositeScores, MaskOperations, + NodeScoringService, ) # Visualization @@ -43,33 +43,33 @@ __all__ = [ # Core "ModelWrapper", - "BaseMetric", + "BaseMetric", "METRIC_REGISTRY", - + # Metrics "get_metric", "list_metrics", - + # Data processing "BatchMetricProcessor", - + # Services (NEW in v0.2.0) "ActivationCaptureService", "ActivationData", "NodeScoringService", "CompositeScores", "MaskOperations", - + # Pruning "PruningConfig", "get_pruning_strategy", - + # Experiment tracking "create_tracker", - + # Visualization "AlignmentVisualizer", - + # Version "__version__" -] \ No newline at end of file +] diff --git a/src/alignment/analysis/__init__.py b/src/alignment/analysis/__init__.py index 5a3401e0..051e90fc 100644 --- a/src/alignment/analysis/__init__.py +++ b/src/alignment/analysis/__init__.py @@ -10,23 +10,13 @@ """ # Aggregation -from .aggregation import ( - ResultAggregator, - MetricAggregator, - LayerAggregator, -) +from .aggregation import LayerAggregator, MetricAggregator, ResultAggregator # Unified Reporting -from .unified_reporter import ( - UnifiedReporter, - generate_quick_report, -) +from .unified_reporter import UnifiedReporter, generate_quick_report # Unified Visualization -from .visualization.unified_visualizer import ( - UnifiedVisualizer, - plot_quick_summary, -) +from .visualization.unified_visualizer import UnifiedVisualizer, plot_quick_summary __all__ = [ # Aggregation @@ -39,4 +29,4 @@ # Visualization 'UnifiedVisualizer', 'plot_quick_summary', -] \ No newline at end of file +] diff --git a/src/alignment/analysis/aggregation/__init__.py b/src/alignment/analysis/aggregation/__init__.py index 083196f7..84e56b12 100644 --- a/src/alignment/analysis/aggregation/__init__.py +++ b/src/alignment/analysis/aggregation/__init__.py @@ -2,12 +2,12 @@ Aggregation utilities for experiment analysis. """ -from .results import ResultAggregator -from .metrics import MetricAggregator from .layers import LayerAggregator +from .metrics import MetricAggregator +from .results import ResultAggregator __all__ = [ 'ResultAggregator', 'MetricAggregator', 'LayerAggregator', -] \ No newline at end of file +] diff --git a/src/alignment/analysis/aggregation/layers.py b/src/alignment/analysis/aggregation/layers.py index a8f74623..321b8e40 100644 --- a/src/alignment/analysis/aggregation/layers.py +++ b/src/alignment/analysis/aggregation/layers.py @@ -2,10 +2,11 @@ Layer-wise metric aggregation for analyzing patterns across network layers. """ -from typing import Dict, List, Tuple +import logging from collections import defaultdict +from typing import Dict, List, Tuple + import numpy as np -import logging logger = logging.getLogger(__name__) @@ -14,15 +15,15 @@ class LayerAggregator: """ Aggregates metrics by layer to analyze layer-wise patterns. """ - + def __init__(self): """Initialize layer aggregator.""" self.layer_metrics = defaultdict(lambda: defaultdict(list)) - + def add_metrics(self, metrics: Dict[str, Dict[str, float]]): """ Add metrics from a single evaluation. - + Args: metrics: Dictionary of metrics by name and layer """ @@ -30,20 +31,20 @@ def add_metrics(self, metrics: Dict[str, Dict[str, float]]): if isinstance(layer_values, dict): for layer_name, value in layer_values.items(): self.layer_metrics[layer_name][metric_name].append(value) - + def get_layer_summary(self, layer_name: str) -> Dict[str, Dict[str, float]]: """ Get summary statistics for a specific layer. - + Args: layer_name: Name of the layer - + Returns: Dictionary mapping metric names to statistics """ if layer_name not in self.layer_metrics: return {} - + summary = {} for metric_name, values in self.layer_metrics[layer_name].items(): if values: @@ -55,9 +56,9 @@ def get_layer_summary(self, layer_name: str) -> Dict[str, Dict[str, float]]: 'max': float(np.max(values_array)), 'count': len(values) } - + return summary - + def rank_layers( self, metric_name: str, @@ -66,21 +67,21 @@ def rank_layers( ) -> List[Tuple[str, float]]: """ Rank layers by a specific metric. - + Args: metric_name: Metric to rank by criterion: Statistic to use ('mean', 'max', 'min', 'std') ascending: Whether to sort in ascending order - + Returns: List of (layer_name, value) tuples """ layer_values = [] - + for layer_name, metrics in self.layer_metrics.items(): if metric_name in metrics and metrics[metric_name]: values_array = np.array(metrics[metric_name]) - + if criterion == 'mean': value = np.mean(values_array) elif criterion == 'max': @@ -91,14 +92,14 @@ def rank_layers( value = np.std(values_array) else: raise ValueError(f"Unknown criterion: {criterion}") - + layer_values.append((layer_name, float(value))) - + # Sort by value layer_values.sort(key=lambda x: x[1], reverse=not ascending) - + return layer_values - + def find_anomalous_layers( self, metric_name: str, @@ -106,35 +107,35 @@ def find_anomalous_layers( ) -> List[str]: """ Find layers with anomalous metric values. - + Args: metric_name: Metric to analyze threshold_std: Number of standard deviations for anomaly detection - + Returns: List of anomalous layer names """ # Collect all values all_values = [] layer_means = {} - + for layer_name, metrics in self.layer_metrics.items(): if metric_name in metrics and metrics[metric_name]: mean_value = np.mean(metrics[metric_name]) layer_means[layer_name] = mean_value all_values.append(mean_value) - + if len(all_values) < 3: return [] - + # Compute global statistics global_mean = np.mean(all_values) global_std = np.std(all_values) - + # Find anomalous layers anomalous = [] for layer_name, mean_value in layer_means.items(): if abs(mean_value - global_mean) > threshold_std * global_std: anomalous.append(layer_name) - - return anomalous \ No newline at end of file + + return anomalous diff --git a/src/alignment/analysis/aggregation/metrics.py b/src/alignment/analysis/aggregation/metrics.py index ac9b2941..a3d8c571 100644 --- a/src/alignment/analysis/aggregation/metrics.py +++ b/src/alignment/analysis/aggregation/metrics.py @@ -2,10 +2,11 @@ Metric aggregation utilities for tracking metrics over time. """ -from typing import Dict, List, Tuple, Any +import logging from collections import defaultdict +from typing import Any, Dict, List, Tuple + import numpy as np -import logging logger = logging.getLogger(__name__) @@ -13,32 +14,32 @@ class MetricAggregator: """ Aggregates metrics across time steps or iterations. - + Useful for analyzing metric evolution during training or experiments. """ - + def __init__(self): """Initialize metric aggregator.""" self.metrics_over_time = defaultdict(lambda: defaultdict(list)) self.steps = [] - + def add_step(self, step: int, metrics: Dict[str, Dict[str, float]]): """ Add metrics from a single step. - + Args: step: Step/iteration number metrics: Dictionary of metrics by name and layer """ self.steps.append(step) - + for metric_name, layer_values in metrics.items(): if isinstance(layer_values, dict): for layer_name, value in layer_values.items(): self.metrics_over_time[metric_name][layer_name].append(value) else: self.metrics_over_time[metric_name]['value'].append(layer_values) - + def get_metric_evolution( self, metric_name: str, @@ -46,23 +47,23 @@ def get_metric_evolution( ) -> Tuple[List[int], List[float]]: """ Get the evolution of a metric over time. - + Args: metric_name: Name of the metric layer_name: Name of the layer - + Returns: Tuple of (steps, values) """ if metric_name not in self.metrics_over_time: return [], [] - + if layer_name not in self.metrics_over_time[metric_name]: return [], [] - + values = self.metrics_over_time[metric_name][layer_name] return self.steps[:len(values)], values - + def compute_trends( self, metric_name: str, @@ -71,41 +72,41 @@ def compute_trends( ) -> Dict[str, Any]: """ Compute trend statistics for a metric. - + Args: metric_name: Name of the metric layer_name: Name of the layer window_size: Window size for moving average - + Returns: Dictionary with trend statistics """ steps, values = self.get_metric_evolution(metric_name, layer_name) - + if len(values) < 2: return {} - + values_array = np.array(values) - + # Compute moving average if len(values) >= window_size: - moving_avg = np.convolve(values_array, - np.ones(window_size) / window_size, + moving_avg = np.convolve(values_array, + np.ones(window_size) / window_size, mode='valid') else: moving_avg = values_array - + # Compute linear trend coeffs = np.polyfit(range(len(values)), values_array, 1) slope = coeffs[0] - + # Find change points if len(values) > 2: diffs = np.diff(values_array) change_points = np.where(np.abs(diffs) > 2 * np.std(diffs))[0] else: change_points = [] - + return { 'initial_value': float(values[0]), 'final_value': float(values[-1]), @@ -115,4 +116,4 @@ def compute_trends( 'percent_change': float((values[-1] - values[0]) / (values[0] + 1e-8) * 100), 'moving_average': moving_avg.tolist() if len(moving_avg) > 0 else [], 'change_points': change_points.tolist() - } \ No newline at end of file + } diff --git a/src/alignment/analysis/aggregation/results.py b/src/alignment/analysis/aggregation/results.py index 1b6c8d25..e282643d 100644 --- a/src/alignment/analysis/aggregation/results.py +++ b/src/alignment/analysis/aggregation/results.py @@ -2,12 +2,13 @@ Result aggregation utilities for analyzing experiment outputs. """ -from typing import Dict, List, Optional, Any, Union, Tuple +import json +import logging from pathlib import Path +from typing import Any, Dict, List, Optional, Union + import numpy as np import pandas as pd -import json -import logging logger = logging.getLogger(__name__) @@ -15,19 +16,19 @@ class ResultAggregator: """ Aggregates results from multiple experiments or runs. - + This class provides utilities for: - Loading results from multiple sources - Computing statistics across runs - Extracting specific metrics - Comparing experiments """ - + def __init__(self): """Initialize result aggregator.""" self.results = {} self.metadata = {} - + def add_results( self, name: str, @@ -36,7 +37,7 @@ def add_results( ): """ Add results from an experiment. - + Args: name: Experiment name/identifier results: Experiment results dictionary @@ -46,11 +47,11 @@ def add_results( if metadata: self.metadata[name] = metadata logger.info(f"Added results for experiment: {name}") - + def load_from_file(self, path: Union[str, Path], name: Optional[str] = None): """ Load results from a JSON file. - + Args: path: Path to results file name: Name to use (defaults to filename) @@ -58,16 +59,16 @@ def load_from_file(self, path: Union[str, Path], name: Optional[str] = None): path = Path(path) if not name: name = path.stem - + with open(path, 'r') as f: results = json.load(f) - + self.add_results(name, results) - + def load_from_directory(self, directory: Union[str, Path], pattern: str = "*_results.json"): """ Load all matching result files from a directory. - + Args: directory: Directory containing result files pattern: Glob pattern for result files @@ -75,9 +76,9 @@ def load_from_directory(self, directory: Union[str, Path], pattern: str = "*_res directory = Path(directory) for result_file in directory.glob(pattern): self.load_from_file(result_file) - + logger.info(f"Loaded {len(self.results)} result files from {directory}") - + def get_metric_values( self, metric_name: str, @@ -86,35 +87,35 @@ def get_metric_values( ) -> Dict[str, Union[float, Dict[str, float]]]: """ Extract specific metric values across experiments. - + Args: metric_name: Name of the metric layer_name: Specific layer (None for all layers) experiment_names: Experiments to include (None for all) - + Returns: Dictionary mapping experiment names to metric values """ if experiment_names is None: experiment_names = list(self.results.keys()) - + metric_values = {} - + for exp_name in experiment_names: if exp_name not in self.results: continue - + exp_results = self.results[exp_name] - + # Navigate to metrics if 'metrics' in exp_results: metrics = exp_results['metrics'] - + # Get final metrics (last step) if metrics: last_step = max(int(k) for k in metrics.keys()) step_metrics = metrics[str(last_step)] - + if metric_name in step_metrics: if layer_name: value = step_metrics[metric_name].get(layer_name) @@ -122,9 +123,9 @@ def get_metric_values( metric_values[exp_name] = value else: metric_values[exp_name] = step_metrics[metric_name] - + return metric_values - + def compute_statistics( self, metric_name: str, @@ -133,31 +134,31 @@ def compute_statistics( ) -> Dict[str, float]: """ Compute statistics for a metric across experiments. - + Args: metric_name: Name of the metric layer_name: Layer to analyze experiment_pattern: Pattern to filter experiments - + Returns: Dictionary with statistics (mean, std, min, max, etc.) """ # Filter experiments if experiment_pattern: - exp_names = [name for name in self.results.keys() + exp_names = [name for name in self.results.keys() if experiment_pattern in name] else: exp_names = list(self.results.keys()) - + # Get metric values values_dict = self.get_metric_values(metric_name, layer_name, exp_names) values = list(values_dict.values()) - + if not values: return {} - + values_array = np.array(values) - + return { 'mean': float(np.mean(values_array)), 'std': float(np.std(values_array)), @@ -168,7 +169,7 @@ def compute_statistics( 'q3': float(np.percentile(values_array, 75)), 'count': len(values) } - + def to_dataframe( self, metrics: Optional[List[str]] = None, @@ -176,43 +177,43 @@ def to_dataframe( ) -> pd.DataFrame: """ Convert results to a pandas DataFrame. - + Args: metrics: Metrics to include (None for all) layers: Layers to include (None for all) - + Returns: DataFrame with experiments as rows and metric/layer combinations as columns """ data = [] - + for exp_name, results in self.results.items(): row = {'experiment': exp_name} - + # Add metadata if exp_name in self.metadata: row.update(self.metadata[exp_name]) - + # Add metrics if 'metrics' in results and results['metrics']: # Get final metrics last_step = max(int(k) for k in results['metrics'].keys()) step_metrics = results['metrics'][str(last_step)] - + for metric_name, layer_values in step_metrics.items(): if metrics and metric_name not in metrics: continue - + if isinstance(layer_values, dict): for layer_name, value in layer_values.items(): if layers and layer_name not in layers: continue - + col_name = f"{metric_name}_{layer_name}" row[col_name] = value else: row[metric_name] = layer_values - + data.append(row) - - return pd.DataFrame(data) \ No newline at end of file + + return pd.DataFrame(data) diff --git a/src/alignment/analysis/dynamic_scoring.py b/src/alignment/analysis/dynamic_scoring.py index 456664ac..9c79f091 100644 --- a/src/alignment/analysis/dynamic_scoring.py +++ b/src/alignment/analysis/dynamic_scoring.py @@ -5,10 +5,10 @@ with loss changes to identify truly important neurons. """ -import torch -import numpy as np -from typing import Dict, List, Optional, Tuple import logging +from typing import Dict, List, Optional + +import torch logger = logging.getLogger(__name__) @@ -16,13 +16,13 @@ class DynamicScoreAggregator: """ Aggregate scores from training history with loss correlation. - + Combines: - Final scores (current state) - Score evolution (trend over training) - Loss correlation (impact on optimization) - Stability (consistency) - + Example: >>> aggregator = DynamicScoreAggregator() >>> dynamic_scores = aggregator.aggregate( @@ -30,7 +30,7 @@ class DynamicScoreAggregator: ... loss_history=training_losses ... ) """ - + def __init__( self, weight_final: float = 0.4, @@ -40,7 +40,7 @@ def __init__( ): """ Initialize dynamic score aggregator. - + Args: weight_final: Weight for final score value weight_trend: Weight for trend (increase/decrease) @@ -51,14 +51,14 @@ def __init__( self.weight_trend = weight_trend self.weight_loss_corr = weight_loss_corr self.weight_stability = weight_stability - + # Normalize weights total = sum([weight_final, weight_trend, weight_loss_corr, weight_stability]) self.weight_final /= total self.weight_trend /= total self.weight_loss_corr /= total self.weight_stability /= total - + def aggregate( self, score_history: Dict[str, Dict[str, List[float]]], @@ -68,40 +68,40 @@ def aggregate( ) -> torch.Tensor: """ Aggregate scores for a layer using training dynamics. - + Args: score_history: From AlignmentMetricsCallback.get_history() loss_history: Training loss at each step layer_name: Layer to process metric_name: Which metric to aggregate - + Returns: Dynamic scores per neuron [num_neurons] """ if layer_name not in score_history['history']: raise ValueError(f"No history for layer {layer_name}") - + if metric_name not in score_history['history'][layer_name]: raise ValueError(f"No {metric_name} history for {layer_name}") - + # Get score evolution scores_over_time = score_history['history'][layer_name][metric_name] # This is list of scalar means - need per-neuron history # For now, work with what we have - + # If we have per-neuron history (not yet implemented in callback): - # scores_over_time = [step1_scores, step2_scores, ...] + # scores_over_time = [step1_scores, step2_scores, ...] # where each is [num_neurons] - + # For now, provide framework for when per-neuron tracking is added logger.warning( "Current callback tracks scalar means. " "For per-neuron dynamic scoring, need to track full tensors." ) - + # Return placeholder return torch.tensor(scores_over_time[-1]) # Final value - + def compute_loss_correlation( self, score_evolution: torch.Tensor, # [num_steps, num_neurons] @@ -109,100 +109,100 @@ def compute_loss_correlation( ) -> torch.Tensor: """ Compute correlation between each neuron's score and training loss. - + High positive correlation: Neuron's importance grew as loss decreased → Neuron is important for learning - + Negative/low correlation: Neuron's importance didn't track loss → Neuron might be less critical - + Args: score_evolution: Score over time per neuron loss_evolution: Loss over time - + Returns: Correlation per neuron [num_neurons] """ num_steps, num_neurons = score_evolution.shape - + # Convert loss to tensor loss_tensor = torch.tensor(loss_evolution, dtype=torch.float32) - + # Compute correlation for each neuron correlations = torch.zeros(num_neurons) - + for neuron_idx in range(num_neurons): neuron_scores = score_evolution[:, neuron_idx] - + # Pearson correlation corr = torch.corrcoef(torch.stack([neuron_scores, loss_tensor]))[0, 1] - + correlations[neuron_idx] = corr.abs() # Use absolute value - + return correlations - + def compute_trend( self, score_evolution: torch.Tensor # [num_steps, num_neurons] ) -> torch.Tensor: """ Compute trend (increasing/decreasing) for each neuron. - + Positive trend: Importance increased → likely important Negative trend: Importance decreased → less critical - + Returns: Trend per neuron [num_neurons] """ # Simple: final - initial - trend = score_evolution[-1] - score_evolution[0] - + score_evolution[-1] - score_evolution[0] + # More sophisticated: linear regression slope num_steps, num_neurons = score_evolution.shape time_steps = torch.arange(num_steps, dtype=torch.float32) - + trends_fitted = torch.zeros(num_neurons) - + for neuron_idx in range(num_neurons): y = score_evolution[:, neuron_idx] - + # Fit line: y = a + b*t # b = cov(t, y) / var(t) mean_t = time_steps.mean() mean_y = y.mean() - + cov_ty = ((time_steps - mean_t) * (y - mean_y)).sum() var_t = ((time_steps - mean_t) ** 2).sum() - + slope = cov_ty / (var_t + 1e-8) trends_fitted[neuron_idx] = slope - + return trends_fitted - + def compute_stability( self, score_evolution: torch.Tensor # [num_steps, num_neurons] ) -> torch.Tensor: """ Compute stability (inverse variance) for each neuron. - + Low variance: Consistently important → reliable signal High variance: Fluctuating → less reliable - + Returns: Stability per neuron [num_neurons] """ # Variance over time variance = score_evolution.var(dim=0) # [num_neurons] - + # Stability = inverse variance stability = 1.0 / (variance + 1e-4) - + # Normalize to [0, 1] stability = (stability - stability.min()) / (stability.max() - stability.min() + 1e-8) - + return stability - + def aggregate_full( self, score_evolution: torch.Tensor, # [num_steps, num_neurons] @@ -210,11 +210,11 @@ def aggregate_full( ) -> torch.Tensor: """ Full aggregation using all components. - + Args: score_evolution: Score history per neuron loss_evolution: Loss history - + Returns: Aggregated dynamic scores [num_neurons] """ @@ -223,16 +223,16 @@ def aggregate_full( trends = self.compute_trend(score_evolution) loss_corr = self.compute_loss_correlation(score_evolution, loss_evolution) stability = self.compute_stability(score_evolution) - + # Normalize each component to [0, 1] def normalize(x): return (x - x.min()) / (x.max() - x.min() + 1e-8) - + final_norm = normalize(final_scores) trend_norm = normalize(trends + trends.abs().max()) # Make positive loss_corr_norm = normalize(loss_corr) stability_norm = stability # Already normalized - + # Weighted combination dynamic_scores = ( self.weight_final * final_norm + @@ -240,39 +240,39 @@ def normalize(x): self.weight_loss_corr * loss_corr_norm + self.weight_stability * stability_norm ) - + return dynamic_scores class TrainingAwareScoring: """ Enhanced scoring using full training history. - + Requires per-neuron tracking during training (not just scalar means). """ - + @staticmethod def enhance_callback_for_per_neuron_tracking(): """ Instructions for enhancing callback to track per-neuron evolution. - + Current callback tracks: scalar mean per step Enhanced version should track: full tensor per step (memory intensive!) - + Modification needed in AlignmentMetricsCallback: - + ```python # Instead of: score_value = scores.mean().item() self.history[layer][metric].append(score_value) - + # Do: if self.track_per_neuron: self.history[layer][metric].append(scores.cpu()) # Full tensor else: self.history[layer][metric].append(scores.mean().item()) ``` - + Then dynamic scoring becomes very powerful! """ pass @@ -287,18 +287,18 @@ def compute_dynamic_importance( ) -> torch.Tensor: """ Convenience function for dynamic importance computation. - + Args: score_history: Training history from callback loss_history: Loss values during training layer_name: Layer to process metric_name: Metric to use aggregation_weights: Optional custom weights - + Returns: Dynamic importance scores """ aggregator = DynamicScoreAggregator(**(aggregation_weights or {})) - + return aggregator.aggregate(score_history, loss_history, layer_name, metric_name) diff --git a/src/alignment/analysis/unified_reporter.py b/src/alignment/analysis/unified_reporter.py index f589e224..3a6e0769 100644 --- a/src/alignment/analysis/unified_reporter.py +++ b/src/alignment/analysis/unified_reporter.py @@ -5,12 +5,13 @@ providing a single interface for generating reports in various formats. """ -from typing import Dict, List, Optional, Any, Union -from pathlib import Path -import pandas as pd import json import logging from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import pandas as pd logger = logging.getLogger(__name__) @@ -22,14 +23,14 @@ class UnifiedReporter: - Markdown - JSON - LaTeX (optional) - + This consolidates functionality from HTMLReporter, MarkdownReporter, and JSONReporter. """ - + def __init__(self, title: str = "Alignment Analysis Report"): """ Initialize unified reporter. - + Args: title: Report title """ @@ -43,11 +44,11 @@ def __init__(self, title: str = "Alignment Analysis Report"): "created": datetime.now().isoformat(), "version": "1.0" } - + def add_section(self, name: str, content: Any, section_type: str = "text"): """ Add a section to the report. - + Args: name: Section name content: Section content (text, DataFrame, dict, etc.) @@ -58,11 +59,11 @@ def add_section(self, name: str, content: Any, section_type: str = "text"): "content": content, "type": section_type }) - + def add_figure(self, figure_path: Union[str, Path], caption: str = "", width: Optional[str] = None): """ Add a figure to the report. - + Args: figure_path: Path to the figure caption: Figure caption @@ -73,11 +74,11 @@ def add_figure(self, figure_path: Union[str, Path], caption: str = "", width: Op "caption": caption, "width": width }) - + def add_table(self, name: str, data: Union[pd.DataFrame, Dict, List]): """ Add a table to the report. - + Args: name: Table name data: Table data (DataFrame, dict, or list of dicts) @@ -88,36 +89,36 @@ def add_table(self, name: str, data: Union[pd.DataFrame, Dict, List]): df = pd.DataFrame(data) else: df = data - + self.tables.append({ "name": name, "data": df }) - + def add_metadata(self, key: str, value: Any): """Add metadata to the report.""" self.metadata[key] = value - + # ========== HTML Generation ========== - + def generate_html(self, output_path: Union[str, Path], include_toc: bool = True): """ Generate HTML report. - + Args: output_path: Path to save the HTML file include_toc: Whether to include table of contents """ html = self._generate_html_header(include_toc) - + # Add table of contents if include_toc: html += self._generate_html_toc() - + # Add sections for section in self.sections: html += self._generate_html_section(section) - + # Add tables if self.tables: html += '

Tables

' @@ -125,23 +126,23 @@ def generate_html(self, output_path: Union[str, Path], include_toc: bool = True) html += f'

{table["name"]}

' html += table["data"].to_html(classes='data-table', index=False) html += '
' - + # Add figures if self.figures: html += '

Figures

' for fig in self.figures: html += self._generate_html_figure(fig) html += '
' - + html += self._generate_html_footer() - + # Save output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(html) - + logger.info(f"Generated HTML report: {output_path}") - + def _generate_html_header(self, include_toc: bool) -> str: """Generate HTML header with styles.""" return f""" @@ -246,40 +247,40 @@ def _generate_html_header(self, include_toc: bool) -> str: Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
Version: {self.metadata.get('version', '1.0')} """ - + # Add custom metadata for key, value in self.metadata.items(): if key not in ['title', 'created', 'version']: html += f"
{key.title()}: {value}" - + html += "" return html - + def _generate_html_toc(self) -> str: """Generate table of contents.""" toc = '

Table of Contents

    ' - + for i, section in enumerate(self.sections): section_id = f"section-{i}" toc += f'
  • {section["name"]}
  • ' - + if self.tables: toc += '
  • Tables
  • ' - + if self.figures: toc += '
  • Figures
  • ' - + toc += '
' return toc - + def _generate_html_section(self, section: Dict) -> str: """Generate HTML for a section.""" section_id = f"section-{self.sections.index(section)}" html = f'

{section["name"]}

' - + content = section["content"] section_type = section["type"] - + if section_type == "text": html += f"

{content}

" elif section_type == "code": @@ -294,42 +295,42 @@ def _generate_html_section(self, section: Dict) -> str: elif section_type == "table": if isinstance(content, pd.DataFrame): html += content.to_html(classes='data-table', index=False) - + html += '
' return html - + def _generate_html_figure(self, fig: Dict) -> str: """Generate HTML for a figure.""" html = '
' - + width_attr = f'style="width: {fig["width"]}"' if fig.get("width") else "" html += f'{fig[' - + if fig["caption"]: html += f'
{fig["caption"]}
' - + html += '
' return html - + def _generate_html_footer(self) -> str: """Generate HTML footer.""" return """ """ - + # ========== Markdown Generation ========== - + def generate_markdown(self, output_path: Union[str, Path], include_toc: bool = True): """ Generate Markdown report. - + Args: output_path: Path to save the Markdown file include_toc: Whether to include table of contents """ md = f"# {self.title}\n\n" md += f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n" - + # Add metadata if self.metadata: md += "## Metadata\n\n" @@ -337,7 +338,7 @@ def generate_markdown(self, output_path: Union[str, Path], include_toc: bool = T if key not in ['title', 'created']: md += f"- **{key.title()}**: {value}\n" md += "\n" - + # Add table of contents if include_toc: md += "## Table of Contents\n\n" @@ -348,14 +349,14 @@ def generate_markdown(self, output_path: Union[str, Path], include_toc: bool = T if self.figures: md += "- [Figures](#figures)\n" md += "\n" - + # Add sections for section in self.sections: md += f"## {section['name']}\n\n" - + content = section["content"] section_type = section["type"] - + if section_type == "text": md += f"{content}\n\n" elif section_type == "code": @@ -370,14 +371,14 @@ def generate_markdown(self, output_path: Union[str, Path], include_toc: bool = T elif section_type == "table": if isinstance(content, pd.DataFrame): md += content.to_markdown(index=False) + "\n\n" - + # Add tables if self.tables: md += "## Tables\n\n" for table in self.tables: md += f"### {table['name']}\n\n" md += table["data"].to_markdown(index=False) + "\n\n" - + # Add figures if self.figures: md += "## Figures\n\n" @@ -386,20 +387,20 @@ def generate_markdown(self, output_path: Union[str, Path], include_toc: bool = T if fig['caption']: md += f"*{fig['caption']}*\n" md += "\n" - + # Save output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(md) - + logger.info(f"Generated Markdown report: {output_path}") - + # ========== JSON Generation ========== - + def generate_json(self, output_path: Union[str, Path], pretty: bool = True): """ Generate JSON report. - + Args: output_path: Path to save the JSON file pretty: Whether to pretty-print the JSON @@ -410,14 +411,14 @@ def generate_json(self, output_path: Union[str, Path], pretty: bool = True): "tables": [], "figures": [] } - + # Add sections for section in self.sections: section_data = { "name": section["name"], "type": section["type"] } - + content = section["content"] if isinstance(content, pd.DataFrame): section_data["content"] = content.to_dict('records') @@ -425,57 +426,57 @@ def generate_json(self, output_path: Union[str, Path], pretty: bool = True): section_data["content"] = content else: section_data["content"] = str(content) - + report_data["sections"].append(section_data) - + # Add tables for table in self.tables: report_data["tables"].append({ "name": table["name"], "data": table["data"].to_dict('records') }) - + # Add figures report_data["figures"] = self.figures - + # Save output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) - + with open(output_path, 'w') as f: if pretty: json.dump(report_data, f, indent=2, default=str) else: json.dump(report_data, f, default=str) - + logger.info(f"Generated JSON report: {output_path}") - + # ========== Multi-format Generation ========== - + def generate_all(self, output_dir: Union[str, Path], basename: str = "report"): """ Generate reports in all supported formats. - + Args: output_dir: Directory to save reports basename: Base filename (without extension) """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - + # Generate all formats self.generate_html(output_dir / f"{basename}.html") self.generate_markdown(output_dir / f"{basename}.md") self.generate_json(output_dir / f"{basename}.json") - + logger.info(f"Generated all report formats in: {output_dir}") - + # ========== Convenience Methods ========== - + def add_dataframe(self, name: str, df: pd.DataFrame, as_section: bool = False): """ Add a DataFrame to the report. - + Args: name: Name for the DataFrame df: The DataFrame @@ -485,11 +486,11 @@ def add_dataframe(self, name: str, df: pd.DataFrame, as_section: bool = False): self.add_section(name, df, section_type="data") else: self.add_table(name, df) - + def add_metrics(self, metrics: Dict[str, Any], section_name: str = "Metrics"): """ Add metrics dictionary as a formatted section. - + Args: metrics: Dictionary of metrics section_name: Name for the metrics section @@ -503,18 +504,18 @@ def add_metrics(self, metrics: Dict[str, Any], section_name: str = "Metrics"): # Simple metrics df = pd.DataFrame([metrics]) self.add_section(section_name, df, section_type="data") - + def add_summary(self, summary_dict: Dict[str, Any]): """ Add a summary section with key-value pairs. - + Args: summary_dict: Dictionary of summary information """ content = "" for key, value in summary_dict.items(): content += f"**{key}**: {value}\n\n" - + self.add_section("Summary", content, section_type="text") @@ -527,7 +528,7 @@ def generate_quick_report( ): """ Generate a quick report from results dictionary. - + Args: results: Results dictionary output_path: Output path @@ -535,7 +536,7 @@ def generate_quick_report( format: Output format ('html', 'markdown', 'json', 'all') """ reporter = UnifiedReporter(title) - + # Add sections based on results content for key, value in results.items(): if isinstance(value, pd.DataFrame): @@ -544,7 +545,7 @@ def generate_quick_report( reporter.add_section(key, value, section_type="data") else: reporter.add_section(key, str(value), section_type="text") - + # Generate report if format == "all": output_dir = Path(output_path).parent @@ -557,4 +558,4 @@ def generate_quick_report( elif format == "json": reporter.generate_json(output_path) else: - raise ValueError(f"Unknown format: {format}") \ No newline at end of file + raise ValueError(f"Unknown format: {format}") diff --git a/src/alignment/analysis/visualization/__init__.py b/src/alignment/analysis/visualization/__init__.py index ec32d872..0b635f0d 100644 --- a/src/alignment/analysis/visualization/__init__.py +++ b/src/alignment/analysis/visualization/__init__.py @@ -1,12 +1,9 @@ """Visualization components for alignment analysis.""" # Unified interface -from .unified_visualizer import ( - UnifiedVisualizer, - plot_quick_summary, -) +from .unified_visualizer import UnifiedVisualizer, plot_quick_summary __all__ = [ 'UnifiedVisualizer', 'plot_quick_summary', -] \ No newline at end of file +] diff --git a/src/alignment/analysis/visualization/unified_visualizer.py b/src/alignment/analysis/visualization/unified_visualizer.py index b9891b4b..60296e44 100644 --- a/src/alignment/analysis/visualization/unified_visualizer.py +++ b/src/alignment/analysis/visualization/unified_visualizer.py @@ -5,17 +5,17 @@ providing a single interface for all visualization needs. """ -from typing import Dict, List, Optional, Any, Union, Tuple, Callable +import logging +from datetime import datetime from pathlib import Path -import numpy as np +from typing import Any, Dict, List, Optional, Tuple, Union + import matplotlib.pyplot as plt -from matplotlib.figure import Figure -from matplotlib.axes import Axes -from matplotlib.gridspec import GridSpec +import numpy as np import pandas as pd import torch -import logging -from datetime import datetime +from matplotlib.figure import Figure +from matplotlib.gridspec import GridSpec # Try to import seaborn, but make it optional try: @@ -34,14 +34,14 @@ class UnifiedVisualizer: - AlignmentVisualizer - PruningVisualizer - ComparisonVisualizer - + This provides a single interface for all visualization needs. """ - + def __init__(self, style: str = "seaborn-v0_8", figsize: Tuple[int, int] = (10, 6)): """ Initialize the unified visualizer. - + Args: style: Matplotlib style to use figsize: Default figure size @@ -54,16 +54,16 @@ def __init__(self, style: str = "seaborn-v0_8", figsize: Tuple[int, int] = (10, plt.style.use('seaborn-v0_8-darkgrid') except: plt.style.use('default') - + self.figsize = figsize - + # Define color palettes if HAS_SEABORN: self.colors = sns.color_palette("husl", 10) else: import matplotlib.cm as cm self.colors = [cm.tab10(i) for i in range(10)] - + # Extended colors for strategies self.strategy_colors = { 'magnitude': '#1f77b4', @@ -73,14 +73,14 @@ def __init__(self, style: str = "seaborn-v0_8", figsize: Tuple[int, int] = (10, 'low': '#9467bd', 'high': '#8c564b', } - + # Set global parameters plt.rcParams['figure.dpi'] = 100 plt.rcParams['savefig.dpi'] = 300 plt.rcParams['font.size'] = 10 - + # ========== Time Series Plots ========== - + def plot_metric_evolution( self, steps: List[int], @@ -94,7 +94,7 @@ def plot_metric_evolution( ) -> Figure: """ Plot the evolution of metrics over time with optional confidence intervals. - + Args: steps: List of step numbers values: Dictionary mapping series names to value lists @@ -105,23 +105,23 @@ def plot_metric_evolution( legend_title: Legend title show_confidence: Whether to show confidence intervals save_path: Optional path to save the figure - + Returns: Matplotlib figure """ fig, ax = plt.subplots(figsize=self.figsize) - + for i, (name, vals) in enumerate(values.items()): if name in ['mean', 'std']: continue - + color = self.colors[i % len(self.colors)] - + if isinstance(vals, dict) and 'mean' in vals: # Handle mean/std structure means = vals['mean'] ax.plot(steps[:len(means)], means, label=name, color=color, linewidth=2) - + if show_confidence and 'std' in vals: stds = vals['std'] means = np.array(means) @@ -131,22 +131,22 @@ def plot_metric_evolution( else: # Simple list of values ax.plot(steps[:len(vals)], vals, label=name, color=color, linewidth=2) - + ax.set_xlabel(xlabel, fontsize=12) ax.set_ylabel(ylabel, fontsize=12) ax.set_title(title, fontsize=14, fontweight='bold') ax.legend(title=legend_title, loc='best') ax.grid(True, alpha=0.3) - + plt.tight_layout() - + if save_path: fig.savefig(save_path, dpi=300, bbox_inches='tight') - + return fig - + # ========== Layer Analysis Plots ========== - + def plot_layer_scores( self, scores: Dict[str, Union[torch.Tensor, np.ndarray, List[float]]], @@ -157,31 +157,31 @@ def plot_layer_scores( ) -> Figure: """ Plot alignment scores across layers. - + Args: scores: Dictionary of layer_name -> scores metric_name: Name of the metric plot_type: Type of plot ('violin', 'box', 'bar') save_path: Path to save the plot show_statistics: Whether to show mean/std statistics - + Returns: Matplotlib figure """ fig, ax = plt.subplots(figsize=self.figsize) - + layer_names = list(scores.keys()) data = [] - + for layer_name, layer_scores in scores.items(): if isinstance(layer_scores, torch.Tensor): layer_scores = layer_scores.cpu().numpy() elif not isinstance(layer_scores, np.ndarray): layer_scores = np.array(layer_scores) data.append(layer_scores) - + positions = range(len(layer_names)) - + if plot_type == 'violin': parts = ax.violinplot(data, positions=positions, showmeans=True, showextrema=True) for pc in parts['bodies']: @@ -193,42 +193,42 @@ def plot_layer_scores( means = [np.mean(d) for d in data] stds = [np.std(d) for d in data] bars = ax.bar(positions, means, yerr=stds, capsize=5) - + # Color bars by value norm = plt.Normalize(min(means), max(means)) sm = plt.cm.ScalarMappable(cmap='coolwarm', norm=norm) for bar, val in zip(bars, means): bar.set_color(sm.to_rgba(val)) - + if plot_type != 'bar': ax.set_xticks(positions) ax.set_xticklabels(layer_names, rotation=45, ha='right') - + ax.set_xlabel('Layer') ax.set_ylabel(f'{metric_name} Score') ax.set_title(f'{metric_name} Distribution Across Layers') - + if show_statistics and plot_type != 'bar': stats_text = [] for i, (name, layer_scores) in enumerate(zip(layer_names, data)): mean = np.mean(layer_scores) std = np.std(layer_scores) stats_text.append(f"{name}: μ={mean:.3f}, σ={std:.3f}") - + textstr = '\n'.join(stats_text) props = dict(boxstyle='round', facecolor='wheat', alpha=0.5) ax.text(0.02, 0.98, textstr, transform=ax.transAxes, fontsize=9, verticalalignment='top', bbox=props) - + plt.tight_layout() - + if save_path: fig.savefig(save_path, dpi=300, bbox_inches='tight') - + return fig - + # ========== Heatmaps ========== - + def plot_heatmap( self, data: Union[pd.DataFrame, np.ndarray, Dict[str, Dict[str, float]]], @@ -242,7 +242,7 @@ def plot_heatmap( ) -> Figure: """ Create a heatmap visualization. - + Args: data: Data to plot (DataFrame, array, or nested dict) title: Plot title @@ -252,7 +252,7 @@ def plot_heatmap( xlabel: X-axis label ylabel: Y-axis label save_path: Optional path to save the figure - + Returns: Matplotlib figure """ @@ -263,10 +263,10 @@ def plot_heatmap( df = pd.DataFrame(data) else: df = data - - fig, ax = plt.subplots(figsize=(max(12, len(df.columns) * 0.8), + + fig, ax = plt.subplots(figsize=(max(12, len(df.columns) * 0.8), max(8, len(df.index) * 0.5))) - + if HAS_SEABORN: sns.heatmap( df, @@ -280,38 +280,38 @@ def plot_heatmap( ) else: im = ax.imshow(df.values, cmap=cmap, aspect='auto') - + ax.set_xticks(np.arange(len(df.columns))) ax.set_yticks(np.arange(len(df.index))) ax.set_xticklabels(df.columns) ax.set_yticklabels(df.index) - + plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor") - + cbar = plt.colorbar(im, ax=ax) cbar.set_label('Value', rotation=270, labelpad=15) - + if annotate: for i in range(len(df.index)): for j in range(len(df.columns)): - text = ax.text(j, i, format(df.iloc[i, j], fmt), + ax.text(j, i, format(df.iloc[i, j], fmt), ha="center", va="center", color="black") - + ax.set_title(title, fontsize=14, fontweight='bold') if xlabel: ax.set_xlabel(xlabel) if ylabel: ax.set_ylabel(ylabel) - + plt.tight_layout() - + if save_path: fig.savefig(save_path, dpi=300, bbox_inches='tight') - + return fig - + # ========== Pruning Analysis ========== - + def plot_pruning_performance( self, results: Dict[str, Dict[float, Dict[str, float]]], @@ -322,32 +322,32 @@ def plot_pruning_performance( ) -> Figure: """ Plot performance metrics for multiple pruning strategies. - + Args: results: Nested dict of strategy -> sparsity -> metric -> value metrics: List of metrics to plot save_path: Path to save the plot title: Overall title for the plot show_confidence: Whether to show confidence intervals - + Returns: Matplotlib figure """ num_metrics = len(metrics) fig = plt.figure(figsize=(self.figsize[0], self.figsize[1] * num_metrics // 2)) - + gs = GridSpec(num_metrics, 1, figure=fig, hspace=0.3) - + for idx, metric in enumerate(metrics): ax = fig.add_subplot(gs[idx, 0]) - + for strategy, strategy_results in results.items(): sparsities = sorted(strategy_results.keys()) - + # Extract values means = [] stds = [] - + for sparsity in sparsities: if isinstance(strategy_results[sparsity], dict): if 'mean' in strategy_results[sparsity]: @@ -358,36 +358,36 @@ def plot_pruning_performance( means.append(strategy_results[sparsity].get(metric, 0)) else: means.append(strategy_results[sparsity]) - + # Plot color = self.strategy_colors.get(strategy, self.colors[0]) line = ax.plot(sparsities, means, 'o-', label=strategy, linewidth=2.5, markersize=8, color=color) - + if stds and show_confidence: means = np.array(means) stds = np.array(stds) ax.fill_between(sparsities, means - stds, means + stds, alpha=0.2, color=line[0].get_color()) - + ax.set_xlabel('Sparsity Level', fontsize=12) ax.set_ylabel(metric.capitalize(), fontsize=12) ax.set_title(f'{metric.capitalize()} vs Sparsity', fontsize=12) ax.legend(loc='best') ax.grid(True, alpha=0.3) - + if title: fig.suptitle(title, fontsize=16, fontweight='bold') - + plt.tight_layout() - + if save_path: fig.savefig(save_path, dpi=300, bbox_inches='tight') - + return fig - + # ========== Comparison Plots ========== - + def plot_radar_chart( self, data: Dict[str, Dict[str, float]], @@ -396,32 +396,32 @@ def plot_radar_chart( ) -> Figure: """ Create a radar chart comparing multiple metrics. - + Args: data: Nested dict: {series_name: {metric_name: value}} title: Plot title save_path: Optional path to save the figure - + Returns: Matplotlib figure """ series = list(data.keys()) metrics = list(next(iter(data.values())).keys()) num_vars = len(metrics) - + angles = np.linspace(0, 2 * np.pi, num_vars, endpoint=False).tolist() angles += angles[:1] - + fig, ax = plt.subplots(figsize=(10, 10), subplot_kw=dict(projection='polar')) - + for i, series_name in enumerate(series): values = [data[series_name][metric] for metric in metrics] values += values[:1] - + color = self.colors[i % len(self.colors)] ax.plot(angles, values, 'o-', linewidth=2, label=series_name, color=color) ax.fill(angles, values, alpha=0.1, color=color) - + ax.set_theta_offset(np.pi / 2) ax.set_theta_direction(-1) ax.set_xticks(angles[:-1]) @@ -430,16 +430,16 @@ def plot_radar_chart( ax.set_title(title, fontsize=16, fontweight='bold', pad=20) ax.legend(loc='upper right', bbox_to_anchor=(1.2, 1.1)) ax.grid(True) - + plt.tight_layout() - + if save_path: fig.savefig(save_path, dpi=300, bbox_inches='tight') - + return fig - + # ========== Comprehensive Reports ========== - + def create_comprehensive_report( self, results: Dict[str, Any], @@ -448,7 +448,7 @@ def create_comprehensive_report( ): """ Create a comprehensive visual report with multiple plots. - + Args: results: Dictionary containing all results output_dir: Directory to save the report @@ -456,12 +456,12 @@ def create_comprehensive_report( """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - + plots_dir = output_dir / "plots" plots_dir.mkdir(exist_ok=True) - + # Generate various plots based on available data - + # 1. Metric evolution if 'metrics_over_time' in results: for metric_name, values in results['metrics_over_time'].items(): @@ -472,7 +472,7 @@ def create_comprehensive_report( ylabel=metric_name, save_path=plots_dir / f"{metric_name}_evolution.png" ) - + # 2. Layer scores if 'layer_scores' in results: for metric_name, scores in results['layer_scores'].items(): @@ -481,7 +481,7 @@ def create_comprehensive_report( metric_name, save_path=plots_dir / f"{metric_name}_layers.png" ) - + # 3. Heatmaps if 'heatmap_data' in results: self.plot_heatmap( @@ -489,33 +489,33 @@ def create_comprehensive_report( title="Metrics Heatmap", save_path=plots_dir / "metrics_heatmap.png" ) - + # 4. Pruning results if 'pruning_results' in results: self.plot_pruning_performance( results['pruning_results'], save_path=plots_dir / "pruning_performance.png" ) - + # 5. Comparisons if 'comparison_data' in results: self.plot_radar_chart( results['comparison_data'], save_path=plots_dir / "comparison_radar.png" ) - + # Create summary statistics self._create_summary_statistics(results, output_dir) - + # Create README self._create_readme(experiment_name, results, output_dir) - + logger.info(f"Comprehensive report saved to {output_dir}") - + def _create_summary_statistics(self, results: Dict[str, Any], output_dir: Path): """Create summary statistics CSV.""" summary = [] - + if 'layer_scores' in results: for metric_name, layer_scores in results['layer_scores'].items(): for layer_name, scores in layer_scores.items(): @@ -523,7 +523,7 @@ def _create_summary_statistics(self, results: Dict[str, Any], output_dir: Path): scores = scores.cpu().numpy() elif not isinstance(scores, np.ndarray): scores = np.array(scores) - + summary.append({ 'Metric': metric_name, 'Layer': layer_name, @@ -533,11 +533,11 @@ def _create_summary_statistics(self, results: Dict[str, Any], output_dir: Path): 'Max': np.max(scores), 'Count': len(scores) }) - + if summary: df = pd.DataFrame(summary) df.to_csv(output_dir / "summary_statistics.csv", index=False) - + def _create_readme(self, experiment_name: str, results: Dict[str, Any], output_dir: Path): """Create README file for the report.""" readme_content = f"""# {experiment_name} Report @@ -562,7 +562,7 @@ def _create_readme(self, experiment_name: str, results: Dict[str, Any], output_d ## Experiment Summary """ - + # Add summary statistics if 'layer_scores' in results: readme_content += f"- Number of metrics: {len(results['layer_scores'])}\n" @@ -570,10 +570,10 @@ def _create_readme(self, experiment_name: str, results: Dict[str, Any], output_d for scores in results['layer_scores'].values(): all_layers.update(scores.keys()) readme_content += f"- Number of layers: {len(all_layers)}\n" - + if 'pruning_results' in results: readme_content += f"- Pruning strategies: {', '.join(results['pruning_results'].keys())}\n" - + with open(output_dir / "README.md", 'w') as f: f.write(readme_content) @@ -587,7 +587,7 @@ def plot_quick_summary( ): """Quick plotting function for immediate visualization.""" visualizer = UnifiedVisualizer() - + if isinstance(next(iter(scores.values())), (list, np.ndarray, torch.Tensor)): # Layer scores fig = visualizer.plot_layer_scores(scores, title, save_path=save_path) @@ -595,8 +595,8 @@ def plot_quick_summary( # Time series steps = list(range(len(next(iter(scores.values()))))) fig = visualizer.plot_metric_evolution(steps, scores, title=title, save_path=save_path) - + if not save_path: plt.show() - - return fig \ No newline at end of file + + return fig diff --git a/src/alignment/configs/__init__.py b/src/alignment/configs/__init__.py index 4a9bce87..3ac5f1f6 100644 --- a/src/alignment/configs/__init__.py +++ b/src/alignment/configs/__init__.py @@ -5,4 +5,4 @@ from .config_loader import load_config, save_config from .config_validator import validate_config -__all__ = ['load_config', 'save_config', 'validate_config'] \ No newline at end of file +__all__ = ['load_config', 'save_config', 'validate_config'] diff --git a/src/alignment/configs/config_loader.py b/src/alignment/configs/config_loader.py index ec622ed8..ff1d6a44 100644 --- a/src/alignment/configs/config_loader.py +++ b/src/alignment/configs/config_loader.py @@ -2,12 +2,13 @@ Configuration loading and saving utilities. """ -import os -import yaml import json -from pathlib import Path -from typing import Dict, Any, Union, Optional, List import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import yaml from ..experiments.base import ExperimentConfig @@ -17,22 +18,22 @@ def load_config(config_path: Union[str, Path]) -> ExperimentConfig: """ Load configuration from a YAML or JSON file. - + Args: config_path: Path to configuration file - + Returns: ExperimentConfig object - + Raises: FileNotFoundError: If config file doesn't exist ValueError: If config format is invalid """ config_path = Path(config_path) - + if not config_path.exists(): raise FileNotFoundError(f"Configuration file not found: {config_path}") - + # Load raw config if config_path.suffix in ['.yaml', '.yml']: with open(config_path, 'r') as f: @@ -42,13 +43,13 @@ def load_config(config_path: Union[str, Path]) -> ExperimentConfig: config_dict = json.load(f) else: raise ValueError(f"Unsupported config format: {config_path.suffix}") - + # Handle environment variable substitution config_dict = _substitute_env_vars(config_dict) - + # Map nested config to flat ExperimentConfig structure config_dict = _map_nested_to_flat_config(config_dict) - + # Create ExperimentConfig try: config = ExperimentConfig.from_dict(config_dict) @@ -58,11 +59,11 @@ def load_config(config_path: Union[str, Path]) -> ExperimentConfig: raise ValueError(f"Invalid configuration: {e}") -def save_config(config: ExperimentConfig, save_path: Union[str, Path], +def save_config(config: ExperimentConfig, save_path: Union[str, Path], format: str = 'yaml') -> None: """ Save configuration to a file. - + Args: config: ExperimentConfig object save_path: Path to save configuration @@ -70,9 +71,9 @@ def save_config(config: ExperimentConfig, save_path: Union[str, Path], """ save_path = Path(save_path) save_path.parent.mkdir(parents=True, exist_ok=True) - + config_dict = config.to_dict() - + if format == 'yaml': with open(save_path, 'w') as f: yaml.dump(config_dict, f, default_flow_style=False) @@ -81,22 +82,22 @@ def save_config(config: ExperimentConfig, save_path: Union[str, Path], json.dump(config_dict, f, indent=2) else: raise ValueError(f"Unsupported format: {format}") - + logger.info(f"Saved configuration to {save_path}") def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: """ Map nested YAML config structure to flat ExperimentConfig structure. - + Args: nested_config: Nested configuration from YAML - + Returns: Flat configuration suitable for ExperimentConfig """ flat_config = {} - + # Map experiment metadata if 'experiment_name' in nested_config: flat_config['name'] = nested_config['experiment_name'] @@ -104,15 +105,15 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: flat_config['name'] = nested_config['name'] else: flat_config['name'] = 'default_experiment' - + flat_config['description'] = nested_config.get('description', '') flat_config['tags'] = nested_config.get('tags', []) - + # Map other top-level fields flat_config['pretrained'] = nested_config.get('pretrained', False) if 'tracked_layers' in nested_config: flat_config['tracked_layers'] = nested_config['tracked_layers'] - + # Map dataset configuration if 'dataset' in nested_config: dataset = nested_config['dataset'] @@ -122,11 +123,11 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: flat_config['data_path'] = dataset.get('data_path') flat_config['batch_size'] = dataset.get('batch_size', 128) flat_config['num_workers'] = dataset.get('num_workers', 4) - + # Filter out DataLoader-specific parameters (not Dataset parameters) - dataloader_params = ['batch_size', 'num_workers', 'pin_memory', 'drop_last', + dataloader_params = ['batch_size', 'num_workers', 'pin_memory', 'drop_last', 'persistent_workers', 'prefetch_factor', 'name', 'dataset_name', 'data_path'] - flat_config['dataset_config'] = {k: v for k, v in dataset.items() + flat_config['dataset_config'] = {k: v for k, v in dataset.items() if k not in dataloader_params} else: # Handle flat structure where dataset fields are at top level @@ -136,14 +137,14 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: flat_config['batch_size'] = nested_config.get('batch_size', 128) flat_config['num_workers'] = nested_config.get('num_workers', 4) flat_config['dataset_config'] = nested_config.get('dataset_config', {}) - + # Map model configuration if 'model' in nested_config: model = nested_config['model'] model_name = model.get('name', model.get('model_name', 'mlp')) flat_config['model_name'] = model_name flat_config['model_config'] = {} - + # Handle different model types if 'mlp_params' in model: flat_config['model_config'].update(model['mlp_params']) @@ -155,7 +156,7 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: if external.get('source') == 'torchvision': flat_config['model_name'] = external.get('name_or_path', 'resnet18') flat_config['pretrained'] = external.get('pretrained', False) - + # Add common model params if 'output_dim' in model: flat_config['model_config']['output_dim'] = model['output_dim'] @@ -168,7 +169,7 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: model_name = nested_config.get('model_name', 'mlp') flat_config['model_name'] = model_name flat_config['model_config'] = nested_config.get('model_config', {}) - + # Handle different model types from flat structure if 'mlp_params' in nested_config: flat_config['model_config'].update(nested_config['mlp_params']) @@ -180,7 +181,7 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: if external.get('source') == 'torchvision': flat_config['model_name'] = external.get('name_or_path', 'resnet18') flat_config['pretrained'] = external.get('pretrained', False) - + # Add common model params from flat structure if 'output_dim' in nested_config: flat_config['model_config']['output_dim'] = nested_config['output_dim'] @@ -188,7 +189,7 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: flat_config['model_config']['dropout_rate'] = nested_config['dropout_rate'] if 'alignment_layers' in nested_config: flat_config['tracked_layers'] = list(nested_config['alignment_layers'].keys()) if isinstance(nested_config['alignment_layers'], dict) else nested_config['alignment_layers'] - + # Map training configuration if 'training' in nested_config: training = nested_config['training'] @@ -196,7 +197,7 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: flat_config['learning_rate'] = training.get('learning_rate', 0.001) flat_config['optimizer'] = training.get('optimizer', 'Adam').lower() flat_config['train_before_dropout'] = training.get('train_before_dropout', True) - + # Map alignment settings if 'alignment_settings' in nested_config: alignment = nested_config['alignment_settings'] @@ -212,24 +213,24 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: flat_config['scale_by_norm'] = alignment.get('scale_by_norm', False) flat_config['force_cpu_for_large_metric_ops'] = alignment.get('force_cpu_for_large_metric_ops', False) flat_config['cnn_rq_aggregation_op'] = alignment.get('cnn_rq_aggregation_op', 'mean') - + # Map pruning settings if 'pruning_settings' in nested_config: pruning = nested_config['pruning_settings'] flat_config['exclude_classification_layer'] = pruning.get('exclude_classification_layer', True) - + # Map other settings flat_config['device'] = nested_config.get('device', 'cuda') flat_config['seed'] = nested_config.get('seed', 42) - + # Map pruning configuration (check both top-level and nested 'pruning' block) pruning_block = nested_config.get('pruning', {}) - + # Map top-level analysis flags flat_config['do_pruning_experiments'] = pruning_block.get('enabled', nested_config.get('do_pruning_experiments', False)) flat_config['do_dropout_analysis'] = nested_config.get('dropout', {}).get('enabled', nested_config.get('do_dropout_analysis', False)) flat_config['do_eigenfeature_analysis'] = nested_config.get('do_eigenfeature_analysis', False) - + # Map pruning parameters (prioritize nested pruning block, fallback to top-level) flat_config['pruning_strategies'] = pruning_block.get('algorithms', nested_config.get('pruning_strategies', ['magnitude', 'random'])) flat_config['pruning_amounts'] = pruning_block.get('sparsity_levels', nested_config.get('pruning_amounts', [0.1, 0.3, 0.5, 0.7, 0.9])) @@ -237,56 +238,56 @@ def _map_nested_to_flat_config(nested_config: Dict[str, Any]) -> Dict[str, Any]: flat_config['fine_tune_after_pruning'] = pruning_block.get('fine_tune_after_pruning', nested_config.get('fine_tune_after_pruning', True)) flat_config['fine_tune_epochs'] = pruning_block.get('fine_tune_epochs', nested_config.get('fine_tune_epochs', 5)) flat_config['pruning_alignment_metric'] = pruning_block.get('alignment_metric', nested_config.get('pruning_alignment_metric', 'rayleigh_quotient')) - + # Map visualization settings flat_config['generate_plots'] = nested_config.get('generate_plots', True) flat_config['plot_format'] = nested_config.get('plot_format', 'png') flat_config['plot_dpi'] = nested_config.get('plot_dpi', 300) - + # Map checkpointing if 'checkpointing' in nested_config: checkpoint = nested_config['checkpointing'] flat_config['checkpoint_interval'] = checkpoint.get('checkpoint_frequency', 1) * 1000 # Convert to steps flat_config['save_best'] = checkpoint.get('save_checkpoints', True) - + # Map wandb settings if 'wandb' in nested_config: wandb = nested_config['wandb'] if wandb.get('use_wandb', False): flat_config['wandb_project'] = wandb.get('wandb_project') flat_config['wandb_entity'] = wandb.get('wandb_entity') - + # Map paths flat_config['log_dir'] = nested_config.get('results_path', './logs') flat_config['checkpoint_dir'] = os.path.join(flat_config['log_dir'], 'checkpoints') - + return flat_config def _substitute_env_vars(config_dict: Dict[str, Any]) -> Dict[str, Any]: """ Recursively substitute environment variables in config. - + Environment variables should be specified as ${VAR_NAME} or ${VAR_NAME:default}. - + Args: config_dict: Configuration dictionary - + Returns: Config dict with environment variables substituted """ import re - + def substitute_value(value): if isinstance(value, str): # Pattern for ${VAR} or ${VAR:default} pattern = r'\$\{([^}:]+)(?::([^}]*))?\}' - + def replacer(match): var_name = match.group(1) default = match.group(2) return os.environ.get(var_name, default if default is not None else match.group(0)) - + return re.sub(pattern, replacer, value) elif isinstance(value, dict): return {k: substitute_value(v) for k, v in value.items()} @@ -294,31 +295,31 @@ def replacer(match): return [substitute_value(item) for item in value] else: return value - + return substitute_value(config_dict) def merge_configs(base_config: Dict[str, Any], override_config: Dict[str, Any]) -> Dict[str, Any]: """ Merge two configuration dictionaries. - + Args: base_config: Base configuration override_config: Configuration to override base - + Returns: Merged configuration """ import copy - + result = copy.deepcopy(base_config) - + for key, value in override_config.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = merge_configs(result[key], value) else: result[key] = value - + return result @@ -329,23 +330,23 @@ def load_config_with_overrides( ) -> ExperimentConfig: """ Load configuration with optional overrides. - + Args: config_path: Path to base configuration overrides: Dictionary of overrides cli_args: Command-line arguments in format ["key=value", ...] - + Returns: ExperimentConfig with overrides applied """ # Load base config config = load_config(config_path) config_dict = config.to_dict() - + # Apply dictionary overrides if overrides: config_dict = merge_configs(config_dict, overrides) - + # Apply CLI overrides if cli_args: for arg in cli_args: @@ -356,7 +357,7 @@ def load_config_with_overrides( value = eval(value) except: pass # Keep as string - + # Handle nested keys (e.g., "model.hidden_dims=[300,200]") keys = key.split('.') target = config_dict @@ -365,5 +366,5 @@ def load_config_with_overrides( target[k] = {} target = target[k] target[keys[-1]] = value - - return ExperimentConfig.from_dict(config_dict) \ No newline at end of file + + return ExperimentConfig.from_dict(config_dict) diff --git a/src/alignment/configs/config_validator.py b/src/alignment/configs/config_validator.py index 42921fd3..15397a81 100644 --- a/src/alignment/configs/config_validator.py +++ b/src/alignment/configs/config_validator.py @@ -2,8 +2,8 @@ Configuration validation utilities. """ -from typing import Dict, Any, List, Optional import logging +from typing import Any, Dict, List logger = logging.getLogger(__name__) @@ -11,19 +11,19 @@ def validate_config(config_dict: Dict[str, Any]) -> List[str]: """ Validate configuration dictionary. - + Args: config_dict: Configuration dictionary to validate - + Returns: List of validation errors (empty if valid) """ errors = [] - + # Required fields if 'name' not in config_dict: errors.append("Missing required field: 'name'") - + # Model validation (dynamic from registry if available) if 'model_name' in config_dict: try: @@ -33,7 +33,7 @@ def validate_config(config_dict: Dict[str, Any]) -> List[str]: valid_models = ['mlp', 'cnn2p2', 'resnet18', 'resnet50'] if config_dict['model_name'] not in valid_models: errors.append(f"Invalid model_name: {config_dict['model_name']}. Must be one of {valid_models}") - + # Dataset validation (dynamic) if 'dataset_name' in config_dict: try: @@ -43,13 +43,13 @@ def validate_config(config_dict: Dict[str, Any]) -> List[str]: valid_datasets = ['mnist', 'cifar10', 'cifar100', 'imagenet'] if config_dict['dataset_name'] not in valid_datasets: errors.append(f"Invalid dataset_name: {config_dict['dataset_name']}. Must be one of {valid_datasets}") - + # Device validation if 'device' in config_dict: dev = str(config_dict['device']) if not (dev == 'cpu' or dev.startswith('cuda')): errors.append(f"Invalid device: {dev}. Must be 'cpu' or 'cuda[:N]'") - + # Numeric validations numeric_fields = { 'batch_size': (1, 10000), @@ -58,7 +58,7 @@ def validate_config(config_dict: Dict[str, Any]) -> List[str]: 'learning_rate': (1e-10, 10.0), 'seed': (0, 2**32-1) } - + for field, (min_val, max_val) in numeric_fields.items(): if field in config_dict: value = config_dict[field] @@ -66,7 +66,7 @@ def validate_config(config_dict: Dict[str, Any]) -> List[str]: errors.append(f"{field} must be numeric, got {type(value).__name__}") elif value < min_val or value > max_val: errors.append(f"{field} must be between {min_val} and {max_val}, got {value}") - + # List validations if 'metrics' in config_dict: if not isinstance(config_dict['metrics'], list): @@ -80,7 +80,7 @@ def validate_config(config_dict: Dict[str, Any]) -> List[str]: for metric in config_dict['metrics']: if valid_metrics and metric not in valid_metrics: errors.append(f"Invalid metric: {metric}. Must be one of {valid_metrics}") - + # Dropout fractions validation if 'dropout_fractions' in config_dict: if not isinstance(config_dict['dropout_fractions'], list): @@ -89,65 +89,65 @@ def validate_config(config_dict: Dict[str, Any]) -> List[str]: for frac in config_dict['dropout_fractions']: if not isinstance(frac, (int, float)) or frac < 0 or frac > 1: errors.append(f"Invalid dropout fraction: {frac}. Must be between 0 and 1") - + return errors def validate_experiment_config(config_dict: Dict[str, Any], experiment_type: str) -> List[str]: """ Validate configuration for specific experiment type. - + Args: config_dict: Configuration dictionary experiment_type: Type of experiment - + Returns: List of validation errors """ errors = validate_config(config_dict) - + # Experiment-specific validations if experiment_type in ['progressive_dropout', 'global_dropout']: if 'dropout_fractions' not in config_dict: errors.append("Progressive dropout requires 'dropout_fractions'") - + elif experiment_type == 'eigenvector': if 'num_components' in config_dict: if config_dict['num_components'] < 1: errors.append("'num_components' must be at least 1") - + elif experiment_type == 'layer_isolated': if 'pruning_percentages' not in config_dict: errors.append("Layer isolated pruning requires 'pruning_percentages'") - + return errors def check_compatibility(config_dict: Dict[str, Any]) -> List[str]: """ Check for compatibility issues in configuration. - + Args: config_dict: Configuration dictionary - + Returns: List of warnings """ warnings = [] - + # Model-dataset compatibility if config_dict.get('model_name') == 'cnn2p2' and config_dict.get('dataset_name') == 'mnist': model_config = config_dict.get('model_config', {}) if model_config.get('in_channels') != 1: warnings.append("CNN2P2 with MNIST should have in_channels=1") - + # Batch size warnings if config_dict.get('batch_size', 128) > 512 and config_dict.get('device', '').startswith('cpu'): warnings.append("Large batch size on CPU may be slow") - + # Memory warnings if config_dict.get('force_cpu_for_large_metric_ops', True) is False: if config_dict.get('model_name') in ['resnet50'] and config_dict.get('batch_size', 128) > 64: warnings.append("Large model with large batch size may cause GPU memory issues") - - return warnings \ No newline at end of file + + return warnings diff --git a/src/alignment/core/__init__.py b/src/alignment/core/__init__.py index 5dc7f05b..13698bd9 100644 --- a/src/alignment/core/__init__.py +++ b/src/alignment/core/__init__.py @@ -5,30 +5,25 @@ used throughout the framework. """ +from .base import BaseDataset, BaseExperiment, BaseMetric, BaseModel from .protocols import ( AlignmentMetric, - ModelWrapper as ModelWrapperProtocol, DatasetWrapper, Experiment, MetricAggregator, ResultReporter, ) +from .protocols import ModelWrapper as ModelWrapperProtocol from .registry import ( Registry, - register_metric, - register_model, - register_dataset, - register_experiment, - get_metric, - get_model, get_dataset, get_experiment, -) -from .base import ( - BaseMetric, - BaseModel, - BaseDataset, - BaseExperiment, + get_metric, + get_model, + register_dataset, + register_experiment, + register_metric, + register_model, ) __all__ = [ @@ -54,4 +49,4 @@ "BaseModel", "BaseDataset", "BaseExperiment", -] \ No newline at end of file +] diff --git a/src/alignment/core/base.py b/src/alignment/core/base.py index ce6ef6c5..199a198e 100644 --- a/src/alignment/core/base.py +++ b/src/alignment/core/base.py @@ -5,26 +5,27 @@ across all implementations. """ +import json +import logging from abc import ABC, abstractmethod -from typing import Optional, Dict, List, Any, Tuple, Union +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union + import torch -import torch.nn as nn import torch.distributed as dist +import torch.nn as nn from torch.utils.data import DataLoader -import logging -import json -from pathlib import Path logger = logging.getLogger(__name__) class BaseMetric(ABC): """Base class for all alignment metrics.""" - + def __init__(self, name: Optional[str] = None, **config: Any): """ Initialize the metric. - + Args: name: Optional custom name for the metric **config: Metric-specific configuration @@ -33,30 +34,30 @@ def __init__(self, name: Optional[str] = None, **config: Any): self.config = config self._force_cpu_for_large_ops = config.get('force_cpu_for_large_ops', True) self._cpu_threshold = config.get('cpu_threshold', 1e8) # 100M elements - + @property def name(self) -> str: """Unique name identifier for the metric.""" return self._name - + @property @abstractmethod def requires_inputs(self) -> bool: """Whether this metric requires layer inputs.""" pass - + @property @abstractmethod def requires_weights(self) -> bool: """Whether this metric requires layer weights.""" pass - + @property @abstractmethod def requires_outputs(self) -> bool: """Whether this metric requires layer outputs.""" pass - + @abstractmethod def compute( self, @@ -67,18 +68,18 @@ def compute( ) -> torch.Tensor: """ Compute the metric values. - + Args: inputs: Input activations to the layer [batch_size, input_features] weights: Layer weights [output_features, input_features] outputs: Output activations from the layer [batch_size, output_features] **kwargs: Additional metric-specific parameters - + Returns: Tensor of metric values, typically [output_features] for per-node metrics """ pass - + def compute_distributed( self, inputs: Optional[torch.Tensor] = None, @@ -90,7 +91,7 @@ def compute_distributed( ) -> torch.Tensor: """ Compute metric in distributed setting with automatic reduction. - + Args: inputs: Input activations (already distributed) weights: Layer weights @@ -98,35 +99,35 @@ def compute_distributed( world_size: Number of distributed processes rank: Rank of current process **kwargs: Additional parameters - + Returns: Metric values with proper distributed reduction """ # Compute local metric values local_values = self.compute(inputs, weights, outputs, **kwargs) - + if world_size > 1 and dist.is_initialized(): # Perform all-reduce to aggregate across processes dist.all_reduce(local_values, op=dist.ReduceOp.SUM) local_values = local_values / world_size - + return local_values - + def _should_use_cpu(self, *tensors: torch.Tensor) -> bool: """Check if computation should be moved to CPU based on tensor sizes.""" if not self._force_cpu_for_large_ops: return False - + total_elements = sum(t.numel() for t in tensors if t is not None) return total_elements > self._cpu_threshold - + def __repr__(self) -> str: return f"{self.__class__.__name__}(name='{self.name}')" class BaseModel(ABC): """Base class for model wrappers.""" - + def __init__( self, model: nn.Module, @@ -135,7 +136,7 @@ def __init__( ): """ Initialize the model wrapper. - + Args: model: PyTorch model to wrap tracked_layers: List of layer names to track @@ -146,20 +147,20 @@ def __init__( self.config = config self._activation_cache: Dict[str, torch.Tensor] = {} self._hooks: List[Any] = [] - + if self._tracked_layers: self._register_hooks() - + @property def model(self) -> nn.Module: """The underlying PyTorch model.""" return self._model - + @property def tracked_layers(self) -> List[str]: """List of layer names being tracked for alignment.""" return self._tracked_layers - + def _register_hooks(self) -> None: """Register forward hooks for activation collection.""" for name, module in self._model.named_modules(): @@ -168,41 +169,41 @@ def _register_hooks(self) -> None: self._create_activation_hook(name) ) self._hooks.append(hook) - + def _create_activation_hook(self, layer_name: str): """Create a forward hook for a specific layer.""" def hook(module, input, output): self._activation_cache[layer_name] = output.detach() return hook - + def _clear_hooks(self) -> None: """Remove all registered hooks.""" for hook in self._hooks: hook.remove() self._hooks.clear() - + def get_layer_activations( - self, + self, inputs: torch.Tensor, layers: Optional[List[str]] = None ) -> Dict[str, torch.Tensor]: """Get activations for specified layers.""" layers = layers or self._tracked_layers - + # Clear previous activations self._activation_cache.clear() - + # Forward pass to collect activations with torch.no_grad(): _ = self._model(inputs) - + # Return requested activations return { layer: self._activation_cache[layer] for layer in layers if layer in self._activation_cache } - + @abstractmethod def get_layer_weights( self, @@ -210,7 +211,7 @@ def get_layer_weights( ) -> Dict[str, torch.Tensor]: """Get weights for specified layers.""" pass - + def forward_with_activations( self, inputs: torch.Tensor @@ -220,7 +221,7 @@ def forward_with_activations( outputs = self._model(inputs) activations = self._activation_cache.copy() return outputs, activations - + def __del__(self): """Clean up hooks when object is destroyed.""" self._clear_hooks() @@ -228,7 +229,7 @@ def __del__(self): class BaseDataset(ABC): """Base class for dataset wrappers.""" - + def __init__( self, name: str, @@ -237,7 +238,7 @@ def __init__( ): """ Initialize the dataset wrapper. - + Args: name: Dataset name data_path: Path to dataset files @@ -246,24 +247,24 @@ def __init__( self._name = name self._data_path = data_path self.config = config - + @property def name(self) -> str: """Dataset name.""" return self._name - + @property @abstractmethod def num_classes(self) -> int: """Number of classes in the dataset.""" pass - + @property @abstractmethod def input_shape(self) -> Tuple[int, ...]: """Shape of a single input sample (excluding batch dimension).""" pass - + @abstractmethod def get_train_loader( self, @@ -274,7 +275,7 @@ def get_train_loader( ) -> DataLoader: """Get training data loader.""" pass - + @abstractmethod def get_val_loader( self, @@ -285,7 +286,7 @@ def get_val_loader( ) -> DataLoader: """Get validation data loader.""" pass - + @abstractmethod def get_test_loader( self, @@ -300,7 +301,7 @@ def get_test_loader( class BaseExperiment(ABC): """Base class for experiments.""" - + def __init__( self, name: str, @@ -309,7 +310,7 @@ def __init__( ): """ Initialize the experiment. - + Args: name: Experiment name config: Experiment configuration @@ -319,22 +320,22 @@ def __init__( self._config = config self._output_dir = Path(output_dir) if output_dir else Path("results") self._output_dir.mkdir(parents=True, exist_ok=True) - + @property def name(self) -> str: """Experiment name.""" return self._name - + @property def config(self) -> Dict[str, Any]: """Experiment configuration.""" return self._config - + @abstractmethod def setup(self) -> None: """Setup the experiment (called once before running).""" pass - + @abstractmethod def run( self, @@ -344,28 +345,28 @@ def run( ) -> Dict[str, Any]: """Run the experiment.""" pass - + def save_results(self, results: Dict[str, Any], filename: str = "results.json") -> None: """Save experiment results.""" output_path = self._output_dir / filename - + # Convert tensors to lists for JSON serialization serializable_results = self._make_serializable(results) - + with open(output_path, 'w') as f: json.dump(serializable_results, f, indent=2) - + logger.info(f"Saved results to {output_path}") - + def load_results(self, filename: str = "results.json") -> Dict[str, Any]: """Load experiment results.""" input_path = self._output_dir / filename - + with open(input_path, 'r') as f: results = json.load(f) - + return results - + def _make_serializable(self, obj: Any) -> Any: """Convert objects to JSON-serializable format.""" if isinstance(obj, torch.Tensor): @@ -376,7 +377,7 @@ def _make_serializable(self, obj: Any) -> Any: return [self._make_serializable(v) for v in obj] else: return obj - + def save_checkpoint( self, models: Union[nn.Module, List[nn.Module]], @@ -386,22 +387,22 @@ def save_checkpoint( """Save a checkpoint during experiment.""" checkpoint_dir = self._output_dir / "checkpoints" checkpoint_dir.mkdir(exist_ok=True) - + checkpoint_path = checkpoint_dir / f"checkpoint_epoch_{epoch}.pt" - + if not isinstance(models, list): models = [models] - + state = { 'epoch': epoch, 'models': [m.state_dict() for m in models], 'config': self._config, **additional_state } - + torch.save(state, checkpoint_path) logger.info(f"Saved checkpoint to {checkpoint_path}") - + def load_checkpoint( self, models: Union[nn.Module, List[nn.Module]], @@ -409,18 +410,18 @@ def load_checkpoint( ) -> Dict[str, Any]: """Load a checkpoint.""" checkpoint_path = self._output_dir / "checkpoints" / f"checkpoint_epoch_{epoch}.pt" - + if not checkpoint_path.exists(): raise FileNotFoundError(f"Checkpoint not found: {checkpoint_path}") - + state = torch.load(checkpoint_path) - + if not isinstance(models, list): models = [models] - + for model, state_dict in zip(models, state['models']): model.load_state_dict(state_dict) - + logger.info(f"Loaded checkpoint from {checkpoint_path}") - - return state \ No newline at end of file + + return state diff --git a/src/alignment/core/layer_detector.py b/src/alignment/core/layer_detector.py index f4fd6f80..0c1bfbd3 100644 --- a/src/alignment/core/layer_detector.py +++ b/src/alignment/core/layer_detector.py @@ -5,11 +5,11 @@ making the framework truly model-agnostic. """ -from typing import Dict, List, Optional, Tuple, Any -import torch -import torch.nn as nn import logging from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +import torch.nn as nn logger = logging.getLogger(__name__) @@ -29,20 +29,20 @@ class LayerInfo: class LayerDetector: """ Generic layer detection using structural analysis. - + Works for any model architecture without hard-coded naming patterns. Detects layer roles based on: - Layer type (Conv, Linear, etc.) - Dimension ratios - Position in network - Local graph structure - + Example: >>> detector = LayerDetector() >>> layers = detector.detect_all_layers(model) >>> ffn_layers = [l for l in layers if 'ffn' in l.role] """ - + def __init__( self, min_neurons: int = 1, @@ -51,7 +51,7 @@ def __init__( ): """ Initialize layer detector. - + Args: min_neurons: Minimum neurons/channels to track max_neurons: Maximum neurons/channels to track (None = no limit) @@ -60,7 +60,7 @@ def __init__( self.min_neurons = min_neurons self.max_neurons = max_neurons self.track_normalization = track_normalization - + def detect_all_layers( self, model: nn.Module, @@ -68,45 +68,45 @@ def detect_all_layers( ) -> List[LayerInfo]: """ Detect all trackable layers in a model. - + Args: model: PyTorch model include_roles: Filter by roles (None = all) - + Returns: List of LayerInfo objects """ all_layers = [] - + # Build module parent map parent_map = self._build_parent_map(model) - + # Analyze each module for name, module in model.named_modules(): # Detect layer type and role layer_type = self._classify_layer_type(module) - + if layer_type == 'skip': continue - + # Get dimensions in_dim, out_dim = self._get_dimensions(module) - + if in_dim is None or out_dim is None: continue - + # Filter by size if out_dim < self.min_neurons: continue if self.max_neurons and out_dim > self.max_neurons: continue - + # Infer role from structure role = self._infer_role(module, name, parent_map, layer_type) - + # Determine if trackable is_trackable = self._is_trackable(module, role) - + # Create LayerInfo layer_info = LayerInfo( name=name, @@ -117,20 +117,20 @@ def detect_all_layers( is_trackable=is_trackable, parent_block=parent_map.get(name) ) - + all_layers.append(layer_info) - + # Filter by requested roles if include_roles: all_layers = [l for l in all_layers if l.role in include_roles] - + # Filter by trackable all_layers = [l for l in all_layers if l.is_trackable] - + logger.info(f"Detected {len(all_layers)} trackable layers") - + return all_layers - + def _classify_layer_type(self, module: nn.Module) -> str: """Classify layer into basic categories.""" if isinstance(module, nn.Linear): @@ -147,28 +147,28 @@ def _classify_layer_type(self, module: nn.Module) -> str: return 'dropout' else: return 'skip' - + def _get_dimensions(self, module: nn.Module) -> Tuple[Optional[int], Optional[int]]: """Get input and output dimensions of a module.""" if isinstance(module, nn.Linear): return module.in_features, module.out_features - + elif isinstance(module, (nn.Conv2d, nn.Conv1d)): return module.in_channels, module.out_channels - + elif isinstance(module, nn.MultiheadAttention): return module.embed_dim, module.embed_dim - + elif hasattr(module, 'normalized_shape'): # LayerNorm if isinstance(module.normalized_shape, tuple): dim = module.normalized_shape[0] else: dim = module.normalized_shape return dim, dim - + else: return None, None - + def _infer_role( self, module: nn.Module, @@ -178,52 +178,52 @@ def _infer_role( ) -> str: """ Infer semantic role using structural cues (not names!). - + Returns: Role string like 'linear_general', 'ffn_expansion', 'attention_proj', etc. """ if layer_type == 'conv': return 'conv' - + elif layer_type == 'attention': return 'attention' - + elif layer_type == 'normalization': return 'normalization' - + elif layer_type == 'linear': # Infer Linear role from dimensions in_dim, out_dim = self._get_dimensions(module) - + # Check dimension ratios (model-agnostic!) ratio = out_dim / in_dim if in_dim > 0 else 1.0 - + if ratio > 2.5: # Expansion (likely FFN up_proj or gate_proj) return 'ffn_expansion' - + elif ratio < 0.4: # Contraction (likely FFN down_proj) return 'ffn_contraction' - + elif 0.9 <= ratio <= 1.1: # Same dimensions (likely attention Q/K/V/O or residual) # Check if part of attention block (heuristic) - parent = parent_map.get(name, '') - + parent_map.get(name, '') + # Look for attention-related siblings if self._has_attention_siblings(name, parent_map): return 'attention_projection' else: return 'linear_residual' - + else: # General linear layer return 'linear_general' - + else: return layer_type - + def _has_attention_siblings( self, layer_name: str, @@ -231,7 +231,7 @@ def _has_attention_siblings( ) -> bool: """ Check if layer has siblings that look like Q/K/V projections. - + Heuristic: If there are 3-4 Linear layers with same dimensions in the same parent block, likely attention. """ @@ -239,10 +239,10 @@ def _has_attention_siblings( parent = parent_map.get(layer_name) if not parent: return False - + # Find siblings (other layers with same parent) siblings = [name for name, p in parent_map.items() if p == parent] - + # Count Linear siblings with similar dimensions linear_siblings = 0 for sibling in siblings: @@ -250,46 +250,46 @@ def _has_attention_siblings( # In practice, would check actual modules if 'linear' in sibling.lower() or 'proj' in sibling.lower(): linear_siblings += 1 - + # If 3-4 Linear siblings, likely Q/K/V (+ maybe O) return linear_siblings >= 3 - + def _build_parent_map(self, model: nn.Module) -> Dict[str, str]: """Build map of module names to parent names.""" parent_map = {} - + for name, module in model.named_modules(): # Get parent name if '.' in name: parent = '.'.join(name.split('.')[:-1]) parent_map[name] = parent - + return parent_map - + def _is_trackable(self, module: nn.Module, role: str) -> bool: """Determine if layer should be tracked for metrics.""" # Skip non-parametric layers if not hasattr(module, 'weight') or module.weight is None: return False - + # Skip normalization unless requested if role == 'normalization' and not self.track_normalization: return False - + # Track everything else return True - + def group_by_role(self, layers: List[LayerInfo]) -> Dict[str, List[LayerInfo]]: """Group layers by their role.""" grouped = {} - + for layer in layers: if layer.role not in grouped: grouped[layer.role] = [] grouped[layer.role].append(layer) - + return grouped - + def get_layers_by_role( self, model: nn.Module, @@ -307,12 +307,12 @@ def detect_trackable_layers( ) -> List[str]: """ Convenience function to get trackable layer names. - + Args: model: PyTorch model min_neurons: Minimum size to track roles: Filter by roles (None = all) - + Returns: List of layer names suitable for tracking """ diff --git a/src/alignment/core/protocols.py b/src/alignment/core/protocols.py index 16132303..8e3e44f4 100644 --- a/src/alignment/core/protocols.py +++ b/src/alignment/core/protocols.py @@ -5,35 +5,36 @@ ensuring consistency and enabling easy extension of the framework. """ -from typing import Protocol, Optional, Dict, List, Any, Tuple, Union +from typing import Any, Dict, List, Optional, Protocol, Tuple, Union + import torch import torch.nn as nn -from torch.utils.data import DataLoader, Dataset +from torch.utils.data import DataLoader class AlignmentMetric(Protocol): """Protocol for all alignment metrics.""" - + @property def name(self) -> str: """Unique name identifier for the metric.""" ... - + @property def requires_inputs(self) -> bool: """Whether this metric requires layer inputs.""" ... - + @property def requires_weights(self) -> bool: """Whether this metric requires layer weights.""" ... - + @property def requires_outputs(self) -> bool: """Whether this metric requires layer outputs.""" ... - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -43,18 +44,18 @@ def compute( ) -> torch.Tensor: """ Compute the metric values. - + Args: inputs: Input activations to the layer [batch_size, input_features] weights: Layer weights [output_features, input_features] outputs: Output activations from the layer [batch_size, output_features] **kwargs: Additional metric-specific parameters - + Returns: Tensor of metric values, typically [output_features] for per-node metrics """ ... - + def compute_distributed( self, inputs: Optional[torch.Tensor] = None, @@ -70,48 +71,48 @@ def compute_distributed( class ModelWrapper(Protocol): """Protocol for model wrappers that support alignment analysis.""" - + @property def model(self) -> nn.Module: """The underlying PyTorch model.""" ... - + @property def tracked_layers(self) -> List[str]: """List of layer names being tracked for alignment.""" ... - + def get_layer_activations( - self, + self, inputs: torch.Tensor, layers: Optional[List[str]] = None ) -> Dict[str, torch.Tensor]: """ Get activations for specified layers. - + Args: inputs: Input tensor to the model layers: Specific layers to get activations for (None = all tracked) - + Returns: Dictionary mapping layer names to activation tensors """ ... - + def get_layer_weights( self, layers: Optional[List[str]] = None ) -> Dict[str, torch.Tensor]: """Get weights for specified layers.""" ... - + def forward_with_activations( self, inputs: torch.Tensor ) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]: """Forward pass that also returns intermediate activations.""" ... - + def apply_dropout_mask( self, dropout_masks: Dict[str, torch.Tensor], @@ -123,22 +124,22 @@ def apply_dropout_mask( class DatasetWrapper(Protocol): """Protocol for dataset wrappers.""" - + @property def name(self) -> str: """Dataset name.""" ... - + @property def num_classes(self) -> int: """Number of classes in the dataset.""" ... - + @property def input_shape(self) -> Tuple[int, ...]: """Shape of a single input sample (excluding batch dimension).""" ... - + def get_train_loader( self, batch_size: int, @@ -148,7 +149,7 @@ def get_train_loader( ) -> DataLoader: """Get training data loader.""" ... - + def get_val_loader( self, batch_size: int, @@ -158,7 +159,7 @@ def get_val_loader( ) -> DataLoader: """Get validation data loader.""" ... - + def get_test_loader( self, batch_size: int, @@ -172,21 +173,21 @@ def get_test_loader( class Experiment(Protocol): """Protocol for experiments.""" - + @property def name(self) -> str: """Experiment name.""" ... - + @property def config(self) -> Dict[str, Any]: """Experiment configuration.""" ... - + def setup(self) -> None: """Setup the experiment (called once before running).""" ... - + def run( self, models: Union[nn.Module, List[nn.Module]], @@ -195,21 +196,21 @@ def run( ) -> Dict[str, Any]: """ Run the experiment. - + Args: models: Model or list of models to experiment on dataset: Dataset to use **kwargs: Additional experiment-specific parameters - + Returns: Dictionary of experiment results """ ... - + def save_results(self, results: Dict[str, Any], path: str) -> None: """Save experiment results.""" ... - + def load_results(self, path: str) -> Dict[str, Any]: """Load experiment results.""" ... @@ -217,12 +218,12 @@ def load_results(self, path: str) -> Dict[str, Any]: class MetricAggregator(Protocol): """Protocol for metric aggregation strategies.""" - + @property def name(self) -> str: """Aggregator name.""" ... - + def aggregate( self, metrics: Dict[str, torch.Tensor], @@ -231,12 +232,12 @@ def aggregate( ) -> Union[torch.Tensor, Dict[str, torch.Tensor]]: """ Aggregate metrics across layers or other dimensions. - + Args: metrics: Dictionary of metric values by layer mode: Aggregation mode ("layer", "global", "network", etc.) **kwargs: Additional aggregation parameters - + Returns: Aggregated metrics """ @@ -245,12 +246,12 @@ def aggregate( class ResultReporter(Protocol): """Protocol for result reporting and visualization.""" - + @property def name(self) -> str: """Reporter name.""" ... - + def report( self, results: Dict[str, Any], @@ -259,14 +260,14 @@ def report( ) -> None: """ Generate report from results. - + Args: results: Experiment results to report output_path: Optional path to save report **kwargs: Additional reporting parameters """ ... - + def visualize( self, results: Dict[str, Any], @@ -274,4 +275,4 @@ def visualize( **kwargs: Any ) -> Any: """Generate visualizations from results.""" - ... \ No newline at end of file + ... diff --git a/src/alignment/core/registry.py b/src/alignment/core/registry.py index ee5056e7..713b7aaa 100644 --- a/src/alignment/core/registry.py +++ b/src/alignment/core/registry.py @@ -5,9 +5,8 @@ datasets, and experiments, making them easily discoverable and instantiable. """ -from typing import Dict, Type, Any, Optional, Callable, TypeVar, Union, List import logging -from functools import wraps +from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union logger = logging.getLogger(__name__) @@ -16,18 +15,18 @@ class Registry: """Generic registry for framework components.""" - + def __init__(self, name: str): """ Initialize a registry. - + Args: name: Name of the registry (e.g., "metrics", "models") """ self.name = name self._registry: Dict[str, Type[Any]] = {} self._metadata: Dict[str, Dict[str, Any]] = {} - + def register( self, name: str, @@ -36,14 +35,14 @@ def register( ) -> Union[Callable[[Type[T]], Type[T]], Type[T]]: """ Register a class in the registry. - + Can be used as a decorator or called directly. - + Args: name: Name to register the class under cls: Class to register (if not using as decorator) **metadata: Additional metadata to store with the registration - + Returns: Registered class or decorator function """ @@ -54,31 +53,31 @@ def decorator(cls_to_register: Type[T]) -> Type[T]: ) self._registry[name] = cls_to_register self._metadata[name] = metadata - + # Add registry info to the class setattr(cls_to_register, '_registry_name', name) setattr(cls_to_register, '_registry', self.name) - + logger.debug(f"Registered '{name}' in {self.name} registry") return cls_to_register - + if cls is None: # Used as decorator return decorator else: # Direct registration return decorator(cls) - + def get(self, name: str) -> Type[Any]: """ Get a registered class by name. - + Args: name: Name of the registered class - + Returns: The registered class - + Raises: KeyError: If name is not registered """ @@ -89,33 +88,33 @@ def get(self, name: str) -> Type[Any]: f"Available: {available}" ) return self._registry[name] - + def get_metadata(self, name: str) -> Dict[str, Any]: """Get metadata for a registered class.""" return self._metadata.get(name, {}) - + def list(self) -> List[str]: """List all registered names.""" return list(self._registry.keys()) - + def create(self, name: str, **kwargs: Any) -> Any: """ Create an instance of a registered class. - + Args: name: Name of the registered class **kwargs: Arguments to pass to the class constructor - + Returns: Instance of the registered class """ cls = self.get(name) return cls(**kwargs) - + def __contains__(self, name: str) -> bool: """Check if a name is registered.""" return name in self._registry - + def __len__(self) -> int: """Get number of registered items.""" return len(self._registry) @@ -196,17 +195,17 @@ def get_reporter(name: str, **kwargs: Any) -> Any: def discover_and_register(module_path: str, registry_type: str = "all") -> None: """ Auto-discover and register components from a module. - + Args: module_path: Python module path to scan registry_type: Type of components to register ("all", "metrics", etc.) """ import importlib import pkgutil - + try: module = importlib.import_module(module_path) - + # Recursively walk through submodules for importer, modname, ispkg in pkgutil.walk_packages( path=module.__path__, @@ -218,6 +217,6 @@ def discover_and_register(module_path: str, registry_type: str = "all") -> None: logger.debug(f"Imported module: {modname}") except Exception as e: logger.warning(f"Failed to import {modname}: {e}") - + except Exception as e: - logger.error(f"Failed to discover components from {module_path}: {e}") \ No newline at end of file + logger.error(f"Failed to discover components from {module_path}: {e}") diff --git a/src/alignment/data/__init__.py b/src/alignment/data/__init__.py index 1affc42e..8a813650 100644 --- a/src/alignment/data/__init__.py +++ b/src/alignment/data/__init__.py @@ -6,18 +6,18 @@ """ from alignment.data.base import BaseDataset, DatasetWrapper +from alignment.data.datasets import get_dataset from alignment.data.loaders import ( + DataLoaderConfig, create_data_loader, create_distributed_loader, - DataLoaderConfig, ) -from alignment.data.datasets import get_dataset # Import dataset implementations when they're created try: - from alignment.data.datasets.mnist import MNISTDataset from alignment.data.datasets.cifar import CIFAR10Dataset, CIFAR100Dataset from alignment.data.datasets.imagenet import ImageNetDataset + from alignment.data.datasets.mnist import MNISTDataset except ImportError: pass # Datasets will be implemented next diff --git a/src/alignment/data/base.py b/src/alignment/data/base.py index 1731291e..1b2f11f8 100644 --- a/src/alignment/data/base.py +++ b/src/alignment/data/base.py @@ -6,14 +6,15 @@ ensuring consistency across different datasets. """ -from typing import Dict, Tuple, Optional, Any, List, Union, Callable -import torch -from torch.utils.data import Dataset, DataLoader, Sampler -import torch.distributed as dist -from abc import ABC, abstractmethod import logging -import numpy as np +from abc import ABC, abstractmethod from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +import numpy as np +import torch +import torch.distributed as dist +from torch.utils.data import DataLoader, Dataset, Sampler from alignment.core.base import BaseDataset as CoreBaseDataset @@ -227,7 +228,7 @@ def get_class_balanced_sampler(self) -> Sampler: Sampler for class-balanced training """ from torch.utils.data import WeightedRandomSampler - + # Get class counts if hasattr(self._dataset, 'targets'): targets = torch.tensor(self._dataset.targets) diff --git a/src/alignment/data/datasets/__init__.py b/src/alignment/data/datasets/__init__.py index a253d1b2..aa9fa2ff 100644 --- a/src/alignment/data/datasets/__init__.py +++ b/src/alignment/data/datasets/__init__.py @@ -5,24 +5,20 @@ various dataset types without code duplication. """ -from typing import Tuple, Optional -import torch.utils.data +from typing import Optional, Tuple -from alignment.data.datasets.unified_dataset import ( - UnifiedDataset, - DATASET_CONFIGS, -) +import torch.utils.data +# Import for backward compatibility - these are now created dynamically +# but we import them to make them available at module level +from alignment.core.registry import DATASET_REGISTRY from alignment.data.datasets.text_datasets import ( + C4Dataset, TextDataset, WikiTextDataset, - C4Dataset, load_text_dataset, ) - -# Import for backward compatibility - these are now created dynamically -# but we import them to make them available at module level -from alignment.core.registry import DATASET_REGISTRY +from alignment.data.datasets.unified_dataset import DATASET_CONFIGS, UnifiedDataset # Get dynamically created dataset classes MNISTDataset = DATASET_REGISTRY.get("mnist") diff --git a/src/alignment/data/datasets/unified_dataset.py b/src/alignment/data/datasets/unified_dataset.py index 417346ce..1ab23b15 100644 --- a/src/alignment/data/datasets/unified_dataset.py +++ b/src/alignment/data/datasets/unified_dataset.py @@ -5,15 +5,16 @@ various dataset types without code duplication. """ -from typing import Optional, List, Callable, Tuple, Dict, Any, Union +import logging +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + import torch from torch.utils.data import Dataset from torchvision import datasets, transforms -from pathlib import Path -import logging -from alignment.data.base import BaseDataset from alignment.core.registry import register_dataset +from alignment.data.base import BaseDataset logger = logging.getLogger(__name__) diff --git a/src/alignment/data/loaders.py b/src/alignment/data/loaders.py index e6bd3dc5..004b89ae 100644 --- a/src/alignment/data/loaders.py +++ b/src/alignment/data/loaders.py @@ -5,12 +5,18 @@ proper configuration for distributed training and memory efficiency. """ -from typing import Optional, Any, Dict, Union +import logging from dataclasses import dataclass +from typing import Any, Dict, Optional, Union + import torch import torch.distributed as dist -from torch.utils.data import DataLoader, DistributedSampler, RandomSampler, SequentialSampler -import logging +from torch.utils.data import ( + DataLoader, + DistributedSampler, + RandomSampler, + SequentialSampler, +) logger = logging.getLogger(__name__) diff --git a/src/alignment/data/processing/__init__.py b/src/alignment/data/processing/__init__.py index 1828fefd..f10144bf 100644 --- a/src/alignment/data/processing/__init__.py +++ b/src/alignment/data/processing/__init__.py @@ -7,17 +7,14 @@ """ from .batch import BatchMetricProcessor +from .covariance import CovarianceEstimator, estimate_covariance from .layers import ( + AttentionPreprocessor, + CNNPreprocessor, LayerPreprocessor, LinearPreprocessor, - CNNPreprocessor, - AttentionPreprocessor, - preprocess_layer_activations, get_preprocessor, -) -from .covariance import ( - CovarianceEstimator, - estimate_covariance, + preprocess_layer_activations, ) __all__ = [ diff --git a/src/alignment/data/processing/batch.py b/src/alignment/data/processing/batch.py index d9143c52..37827bb2 100644 --- a/src/alignment/data/processing/batch.py +++ b/src/alignment/data/processing/batch.py @@ -2,10 +2,11 @@ Batch processing utilities for efficient metric computation on large datasets. """ +import logging +from typing import Any, Callable, Dict, List, Optional, Tuple + import torch -from typing import Dict, List, Optional, Callable, Tuple, Any from torch.utils.data import DataLoader -import logging from tqdm import tqdm logger = logging.getLogger(__name__) @@ -264,9 +265,10 @@ def compute_metrics_parallel( Returns: Results dictionary """ - import torch.multiprocessing as mp from concurrent.futures import ProcessPoolExecutor, as_completed - + + import torch.multiprocessing as mp + # Determine devices if devices is None: if torch.cuda.is_available(): diff --git a/src/alignment/data/processing/layers.py b/src/alignment/data/processing/layers.py index 76eb1225..bb1efa80 100644 --- a/src/alignment/data/processing/layers.py +++ b/src/alignment/data/processing/layers.py @@ -5,11 +5,12 @@ including convolutional, linear, and other specialized layers. """ -from typing import Dict, Any, Optional, Union, Tuple, List +import logging from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple, Union + import torch import torch.nn as nn -import logging logger = logging.getLogger(__name__) diff --git a/src/alignment/evaluation.py b/src/alignment/evaluation.py index 76b8b16c..27d6fd06 100644 --- a/src/alignment/evaluation.py +++ b/src/alignment/evaluation.py @@ -4,10 +4,11 @@ Provides standard evaluation functions for different model types and tasks. """ +import logging +from typing import Dict, Optional + import torch import torch.nn as nn -from typing import Optional, Dict, Any, Callable -import logging logger = logging.getLogger(__name__) @@ -20,40 +21,40 @@ def evaluate_classification( ) -> Dict[str, float]: """ Evaluate classification model. - + Args: model: Model to evaluate data_loader: Validation/test dataloader device: Device to use criterion: Loss function (default: CrossEntropyLoss) - + Returns: Dict with 'loss' and 'accuracy' """ if criterion is None: criterion = nn.CrossEntropyLoss() - + model.eval() total_loss = 0.0 correct = 0 total = 0 - + with torch.no_grad(): for inputs, targets in data_loader: inputs, targets = inputs.to(device), targets.to(device) - + outputs = model(inputs) loss = criterion(outputs, targets) - + total_loss += loss.item() - + _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() - + avg_loss = total_loss / len(data_loader) accuracy = 100. * correct / total - + return { 'loss': avg_loss, 'accuracy': accuracy @@ -68,25 +69,25 @@ def evaluate_perplexity( ) -> Dict[str, float]: """ Evaluate language model perplexity. - + Args: model: Language model data_loader: Text dataloader with 'input_ids' and 'labels' device: Device to use max_batches: Maximum batches to evaluate (None = all) - + Returns: Dict with 'perplexity' and 'loss' """ model.eval() total_loss = 0.0 total_tokens = 0 - + with torch.no_grad(): for batch_idx, batch in enumerate(data_loader): if max_batches and batch_idx >= max_batches: break - + # Handle different batch formats if isinstance(batch, dict): input_ids = batch['input_ids'].to(device) @@ -95,10 +96,10 @@ def evaluate_perplexity( input_ids, labels = batch input_ids = input_ids.to(device) labels = labels.to(device) - + # Forward pass outputs = model(input_ids, labels=labels) - + # Get loss if hasattr(outputs, 'loss'): loss = outputs.loss @@ -109,15 +110,15 @@ def evaluate_perplexity( logits.view(-1, logits.size(-1)), labels.view(-1) ) - + # Accumulate batch_tokens = (labels != -100).sum().item() # Count non-padding tokens total_loss += loss.item() * batch_tokens total_tokens += batch_tokens - + avg_loss = total_loss / total_tokens perplexity = torch.exp(torch.tensor(avg_loss)).item() - + return { 'perplexity': perplexity, 'loss': avg_loss @@ -133,26 +134,26 @@ def evaluate_model( ) -> Dict[str, float]: """ General evaluation function that dispatches to task-specific evaluators. - + Args: model: Model to evaluate data_loader: Dataloader task: Task type ('classification', 'language_modeling', 'regression') device: Device **kwargs: Additional arguments for specific evaluators - + Returns: Evaluation metrics dictionary """ if task == 'classification': return evaluate_classification(model, data_loader, device, **kwargs) - + elif task in ['language_modeling', 'lm', 'causal_lm']: return evaluate_perplexity(model, data_loader, device, **kwargs) - + elif task == 'regression': return evaluate_regression(model, data_loader, device, **kwargs) - + else: raise ValueError(f"Unknown task: {task}") @@ -165,38 +166,38 @@ def evaluate_regression( ) -> Dict[str, float]: """ Evaluate regression model. - + Args: model: Regression model data_loader: Dataloader device: Device criterion: Loss function (default: MSELoss) - + Returns: Dict with 'mse' and 'mae' """ if criterion is None: criterion = nn.MSELoss() - + model.eval() total_mse = 0.0 total_mae = 0.0 total = 0 - + with torch.no_grad(): for inputs, targets in data_loader: inputs, targets = inputs.to(device), targets.to(device) - + outputs = model(inputs) - + mse = nn.MSELoss()(outputs, targets).item() mae = nn.L1Loss()(outputs, targets).item() - + batch_size = inputs.size(0) total_mse += mse * batch_size total_mae += mae * batch_size total += batch_size - + return { 'mse': total_mse / total, 'mae': total_mae / total @@ -206,20 +207,20 @@ def evaluate_regression( class EvaluationManager: """ Manager for handling multiple evaluation metrics. - + Useful for tracking multiple metrics during experiments. """ - + def __init__(self, task: str = 'classification'): """ Initialize evaluation manager. - + Args: task: Primary task type """ self.task = task self.history = [] - + def evaluate( self, model: nn.Module, @@ -230,43 +231,43 @@ def evaluate( ) -> Dict[str, float]: """ Evaluate and record results. - + Args: model: Model to evaluate data_loader: Dataloader device: Device step: Training step number (for tracking) **kwargs: Additional arguments - + Returns: Evaluation metrics """ results = evaluate_model(model, data_loader, self.task, device, **kwargs) - + if step is not None: results['step'] = step - + self.history.append(results) - + return results - + def get_history(self) -> list: """Get evaluation history.""" return self.history - + def get_best(self, metric: str = 'accuracy') -> Dict[str, float]: """ Get best result based on metric. - + Args: metric: Metric to optimize ('accuracy', 'perplexity', 'loss') - + Returns: Best evaluation result """ if not self.history: return {} - + if metric in ['perplexity', 'loss', 'mse', 'mae']: # Lower is better return min(self.history, key=lambda x: x.get(metric, float('inf'))) diff --git a/src/alignment/experiments/__init__.py b/src/alignment/experiments/__init__.py index afd24683..05a8b0a5 100644 --- a/src/alignment/experiments/__init__.py +++ b/src/alignment/experiments/__init__.py @@ -6,26 +6,26 @@ """ from .base import BaseExperiment, ExperimentConfig -from .general_alignment import GeneralAlignmentExperiment, GeneralAlignmentConfig -from .llm_experiments import LLMAlignmentExperiment # Configuration components from .config_components import ( - TrainingConfig, - PruningConfig, - EvaluationConfig, CNNConfig, + EvaluationConfig, MultiNetworkConfig, + PruningConfig, + TrainingConfig, + create_backward_compatible_config, create_config_from_dict, - create_backward_compatible_config ) +from .general_alignment import GeneralAlignmentConfig, GeneralAlignmentExperiment +from .llm_experiments import LLMAlignmentExperiment # Training utilities from .training_utils import ( + convert_training_history, create_experiment_trainer, - train_with_metrics, evaluate_with_metrics, - convert_training_history + train_with_metrics, ) __all__ = [ diff --git a/src/alignment/experiments/base.py b/src/alignment/experiments/base.py index e862c9b4..18817f51 100644 --- a/src/alignment/experiments/base.py +++ b/src/alignment/experiments/base.py @@ -5,20 +5,21 @@ handling common functionality like checkpointing, logging, and metrics. """ -from typing import Dict, List, Optional, Any, Union, Callable -from dataclasses import dataclass, field -from pathlib import Path -import torch -import logging -from abc import ABC, abstractmethod import json +import logging import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Union + +import torch from alignment.core.base import BaseExperiment as CoreBaseExperiment -from alignment.core.registry import get_metric, get_model, get_dataset, DATASET_REGISTRY -from alignment.models import ModelWrapper +from alignment.core.registry import DATASET_REGISTRY, get_dataset, get_metric, get_model from alignment.data.loaders import create_distributed_loader +from alignment.models import ModelWrapper logger = logging.getLogger(__name__) @@ -236,7 +237,7 @@ def _initialize_model(self): # Try to get model from registry first try: from alignment.core.registry import MODEL_REGISTRY - + # Handle parameter mapping for specific models model_kwargs = self.config.model_config.copy() diff --git a/src/alignment/experiments/config_components.py b/src/alignment/experiments/config_components.py index 14c3e6ec..9b0fd2ea 100644 --- a/src/alignment/experiments/config_components.py +++ b/src/alignment/experiments/config_components.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass, field -from typing import List, Optional, Dict, Any +from typing import Any, Dict, List, Optional @dataclass diff --git a/src/alignment/experiments/general_alignment.py b/src/alignment/experiments/general_alignment.py index e84015b6..ac3f4785 100644 --- a/src/alignment/experiments/general_alignment.py +++ b/src/alignment/experiments/general_alignment.py @@ -9,25 +9,30 @@ - Generate comprehensive visualizations """ -from typing import Dict, List, Optional, Any, Tuple -import torch -import torch.nn as nn -import numpy as np +import copy import logging -from dataclasses import dataclass, field -from pathlib import Path import multiprocessing as mp -import copy import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import torch +import torch.nn as nn -from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.core.registry import register_experiment +from alignment.data.processing import preprocess_layer_activations +from alignment.experiments.base import BaseExperiment, ExperimentConfig +from alignment.metrics.rayleigh.rayleigh_quotient import RayleighQuotient from alignment.models import ModelWrapper from alignment.pruning.base import PruningConfig +from alignment.pruning.strategies import ( + MagnitudePruning, + ParallelBatchPruning, + RandomPruning, +) from alignment.services import ActivationCaptureService, MaskOperations -from alignment.pruning.strategies import MagnitudePruning, RandomPruning, ParallelBatchPruning -from alignment.metrics.rayleigh.rayleigh_quotient import RayleighQuotient -from alignment.data.processing import preprocess_layer_activations logger = logging.getLogger(__name__) @@ -1010,12 +1015,19 @@ def _pruning_experiments_single(self) -> Dict[str, Any]: if strategy_name == "magnitude": if pruning_config.global_pruning: - from alignment.pruning.strategies import GlobalMagnitudePruning + from alignment.pruning.strategies import ( + GlobalMagnitudePruning, + ) strategy = GlobalMagnitudePruning(config=pruning_config) else: strategy = MagnitudePruning(config=pruning_config) elif strategy_name == "alignment": - from alignment.pruning.strategies import AlignmentPruning, GlobalAlignmentPruning, CascadingAlignmentPruning + from alignment.pruning.strategies import ( + AlignmentPruning, + CascadingAlignmentPruning, + GlobalAlignmentPruning, + ) + # Get the alignment metric from config (default to rayleigh_quotient) alignment_metric = getattr(self.config, 'pruning_alignment_metric', 'rayleigh_quotient') @@ -1040,7 +1052,9 @@ def _pruning_experiments_single(self) -> Dict[str, Any]: elif strategy_name == "cascading_alignment": # Legacy cascading_alignment handling logger.warning("'cascading_alignment' algorithm is deprecated. Use algorithms=['alignment'] with scope='cascading'") - from alignment.pruning.strategies import CascadingAlignmentPruning + from alignment.pruning.strategies import ( + CascadingAlignmentPruning, + ) alignment_metric = getattr(self.config, 'pruning_alignment_metric', 'rayleigh_quotient') pruning_config.structured = True strategy = CascadingAlignmentPruning( @@ -2284,9 +2298,10 @@ def _generate_visualizations(self): # Pruning experiments - now enhanced with before/after comparisons if self.pruning_results and "strategies" in self.pruning_results: - from alignment.analysis.visualization import UnifiedVisualizer import matplotlib.pyplot as plt - + + from alignment.analysis.visualization import UnifiedVisualizer + # Group results by algorithm (for multi-selection mode comparison) algorithm_results = {} @@ -2502,7 +2517,10 @@ def _create_pruning_strategy(self, strategy_name: str, pruning_config: PruningCo else: return MagnitudePruning(config=pruning_config) elif strategy_name == "alignment": - from alignment.pruning.strategies import AlignmentPruning, GlobalAlignmentPruning + from alignment.pruning.strategies import ( + AlignmentPruning, + GlobalAlignmentPruning, + ) alignment_metric = getattr(self.config, 'pruning_alignment_metric', 'rayleigh_quotient') if self.config.pruning_scope == 'global': diff --git a/src/alignment/experiments/llm_experiments.py b/src/alignment/experiments/llm_experiments.py index a113c0d4..3d8225f2 100644 --- a/src/alignment/experiments/llm_experiments.py +++ b/src/alignment/experiments/llm_experiments.py @@ -9,17 +9,18 @@ - Multi-metric comparison """ -from typing import Dict, List, Optional, Any, Tuple +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + import torch import torch.nn as nn -from pathlib import Path -import logging -from .base import BaseExperiment -from ..models.transformers import TransformerWrapperEnhanced as TransformerWrapper from ..metrics import get_metric +from ..models.transformers import TransformerWrapperEnhanced as TransformerWrapper from ..pruning import AlignmentPruning, PruningConfig from ..training.base import BaseTrainer +from .base import BaseExperiment logger = logging.getLogger(__name__) diff --git a/src/alignment/experiments/runner.py b/src/alignment/experiments/runner.py index 6e709721..55be3b1b 100644 --- a/src/alignment/experiments/runner.py +++ b/src/alignment/experiments/runner.py @@ -2,17 +2,18 @@ Experiment runner for managing and executing experiments. """ -from typing import Dict, List, Optional, Any, Union, Type -from pathlib import Path -import torch +import json import logging -from datetime import datetime import multiprocessing as mp from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor -import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Type, Union + +import torch -from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.core.registry import get_experiment +from alignment.experiments.base import BaseExperiment, ExperimentConfig logger = logging.getLogger(__name__) diff --git a/src/alignment/experiments/tracking/__init__.py b/src/alignment/experiments/tracking/__init__.py index ef7d9b51..a1ceb609 100644 --- a/src/alignment/experiments/tracking/__init__.py +++ b/src/alignment/experiments/tracking/__init__.py @@ -1,11 +1,11 @@ """Experiment tracking utilities for the alignment framework.""" from .base import ( + DummyTracker, ExperimentTracker, - WandBTracker, - TensorBoardTracker, MultiTracker, - DummyTracker, + TensorBoardTracker, + WandBTracker, create_tracker, ) diff --git a/src/alignment/experiments/tracking/base.py b/src/alignment/experiments/tracking/base.py index c8030237..e409f6fd 100644 --- a/src/alignment/experiments/tracking/base.py +++ b/src/alignment/experiments/tracking/base.py @@ -2,12 +2,13 @@ Experiment tracking integration for alignment metrics. """ -import torch -from typing import Dict, Any, Optional, List, Union +import json import logging from pathlib import Path -import json +from typing import Any, Dict, List, Optional, Union + import numpy as np +import torch logger = logging.getLogger(__name__) diff --git a/src/alignment/experiments/training_utils.py b/src/alignment/experiments/training_utils.py index 6133400b..af4ca06c 100644 --- a/src/alignment/experiments/training_utils.py +++ b/src/alignment/experiments/training_utils.py @@ -2,10 +2,11 @@ Utility functions for integrating ExperimentTrainer into experiments. """ -from typing import Dict, Any, Optional, Union, List +from dataclasses import asdict +from typing import Any, Dict, List, Optional, Union + import torch import torch.nn as nn -from dataclasses import asdict from alignment.training import ExperimentTrainer, ExperimentTrainingConfig diff --git a/src/alignment/external/BROJA_2PID/BROJA_2PID.py b/src/alignment/external/BROJA_2PID/BROJA_2PID.py index 72097b9c..eff37017 100644 --- a/src/alignment/external/BROJA_2PID/BROJA_2PID.py +++ b/src/alignment/external/BROJA_2PID/BROJA_2PID.py @@ -20,14 +20,14 @@ # Please cite this paper when you use this software (cf. README.md) ############################################################################################################## -import ecos -from scipy import sparse -import numpy as np -from numpy import linalg as LA +import logging import math +import time from collections import defaultdict -import time -import logging + +import ecos +import numpy as np +from scipy import sparse log = math.log2 ln = math.log @@ -104,7 +104,7 @@ def create_model(self): m = len(self.b_xy) + len(self.b_xz) n_vars = 3*n n_cons = n+m - + # # Create the equations: Ax = b # @@ -258,7 +258,7 @@ def condYmutinf(self): mysum = 0. for x in self.X: for z in self.Z: - if not (x,z) in self.b_xz.keys(): continue + if (x,z) not in self.b_xz.keys(): continue for y in self.Y: if (x,y,z) in self.idx_of_trip.keys(): i = q_vidx(self.idx_of_trip[ (x,y,z) ]) @@ -277,7 +277,7 @@ def condZmutinf(self): mysum = 0. for x in self.X: for y in self.Y: - if not (x,y) in self.b_xy.keys(): continue + if (x,y) not in self.b_xy.keys(): continue for z in self.Z: if (x,y,z) in self.idx_of_trip.keys(): i = q_vidx(self.idx_of_trip[ (x,y,z) ]) @@ -295,7 +295,7 @@ def entropy_X(self,pdf): for x in self.X: psum = 0. for y in self.Y: - if not (x,y) in self.b_xy: continue + if (x,y) not in self.b_xy: continue for z in self.Z: if (x,y,z) in pdf.keys(): psum += pdf[(x,y,z)] @@ -344,7 +344,7 @@ def condentropy__orig(self,pdf): def dual_value(self): return -np.dot(self.sol_lambda, self.b) #^ dual_value() - + def check_feasibility(self): # returns pair (p,d) of primal/dual infeasibility (maxima) # Primal infeasiblility # --------------------- @@ -381,7 +381,7 @@ def check_feasibility(self): # returns pair (p,d) of primal/dual infeasibility ( #^ fox xz primal_infeasability = max(max_violation_of_eqn,max_q_negativity) - + # Dual infeasiblility # ------------------- idx_of_xy = dict() @@ -403,14 +403,14 @@ def check_feasibility(self): # returns pair (p,d) of primal/dual infeasibility ( #^ for dual_infeasability = 0. - + # Compute mu_*yz # mu_xyz: dual variable of the coupling constraints mu_yz = defaultdict(lambda: 0.) for j,xyz in enumerate(self.trip_of_idx): x,y,z = xyz mu_yz[(y,z)] += self.sol_lambda[j] - + for i,xyz in enumerate(self.trip_of_idx): x,y,z = xyz @@ -427,7 +427,7 @@ def check_feasibility(self): # returns pair (p,d) of primal/dual infeasibility ( ) #^ for - + # for i,xyz in enumerate(self.trip_of_idx): # x,y,z = xyz # mu_yz = 0. @@ -441,7 +441,7 @@ def check_feasibility(self): # returns pair (p,d) of primal/dual infeasibility ( # u,v,w = uvw # if v == y and w == z: # mu_yz += self.sol_lambda[j] - + # # Find the most violated dual ieq # dual_infeasability = max( dual_infeasability, -self.sol_lambda[xy_idx] # - self.sol_lambda[xz_idx] @@ -483,7 +483,7 @@ def I_Y(p): if r > 0 : marg_x[x] += r marg_y[y] += r - + for xy,t in b_xy.items(): x,y = xy if t > 0: mysum += t * log( t / ( marg_x[x]*marg_y[y] ) ) @@ -501,7 +501,7 @@ def I_Z(p): if r > 0 : marg_x[x] += r marg_z[z] += r - + for xz,t in b_xz.items(): x,z = xz if t > 0: mysum += t * log( t / ( marg_x[x]*marg_z[z] ) ) @@ -518,7 +518,7 @@ def I_YZ(p): if r > 0 : marg_x[x] += r marg_yz[(y,z)] += r - + for xyz,t in p.items(): x,y,z = xyz if t > 0: mysum += t * log( t / ( marg_x[x]*marg_yz[(y,z)] ) ) @@ -572,7 +572,7 @@ def pid(pdf_dirty, cone_solver="ECOS", output=0, **solver_args): logger.info("BROJA_2PID: Starting solver...") if output > 1: # print("BROJA_2PID: Starting solver.") logger.debug("BROJA_2PID: Starting solver (verbose). Details to follow.") # Using debug for output > 1 - + retval = solver.solve() if retval != "success": # print("\\nCone Programming solver failed to find (near) optimal solution.\\nPlease report the input probability density function to abdullah.makkeh@gmail.com\\n") @@ -611,10 +611,10 @@ def pid(pdf_dirty, cone_solver="ECOS", output=0, **solver_args): return_data["UIZ"] = ( condYmutinf ) * bits return_data["CI"] = ( condent - condent__orig ) * bits - itic = time.process_time() + itic = time.process_time() primal_infeas,dual_infeas = solver.check_feasibility() itoc = time.process_time() - if output > 0: # print("Time to check optimiality conditions: ",itoc - itic,"secs") + if output > 0: # print("Time to check optimiality conditions: ",itoc - itic,"secs") logger.info(f"BROJA_2PID: Time to check optimiality conditions: {itoc - itic:.4f} secs") return_data["Num_err"] = (primal_infeas, dual_infeas, max(-condent*ln(2) - dual_val, 0.0)) return_data["Solver"] = "ECOS http://www.embotech.com/ECOS" diff --git a/src/alignment/external/__init__.py b/src/alignment/external/__init__.py index b7bc34be..ea195470 100644 --- a/src/alignment/external/__init__.py +++ b/src/alignment/external/__init__.py @@ -7,4 +7,4 @@ from .BROJA_2PID import BROJA_2PID __all__ = ['BROJA_2PID'] except ImportError: - __all__ = [] \ No newline at end of file + __all__ = [] diff --git a/src/alignment/infrastructure/__init__.py b/src/alignment/infrastructure/__init__.py index 754286e3..e846e6ba 100644 --- a/src/alignment/infrastructure/__init__.py +++ b/src/alignment/infrastructure/__init__.py @@ -6,52 +6,47 @@ """ # Computing infrastructure -from .computing import ( - # Distributed +from .computing import ( # Distributed; GPU optimization; JIT compilation DistributedConfig, DistributedTrainer, - setup_distributed, - cleanup_distributed, - is_main_process, - get_world_size, - get_rank, - # GPU optimization GPUOptimizer, - optimize_gpu_memory, - get_gpu_memory_stats, - # JIT compilation JITCompiler, + cleanup_distributed, compile_model, + get_gpu_memory_stats, + get_rank, + get_world_size, + is_main_process, + optimize_gpu_memory, optimize_trace, -) - -# Storage infrastructure -from .storage import ( - # Checkpointing - CheckpointManager, - save_checkpoint, - load_checkpoint, - cleanup_old_checkpoints, - # Logging - setup_logging, - get_logger, - log_metrics, - log_experiment_config, + setup_distributed, ) # Configuration infrastructure from .configuration import ( Config, + DataConfig, ExperimentConfig, MetricConfig, ModelConfig, - DataConfig, load_config, - save_config, merge_configs, + save_config, validate_config, ) +# Storage infrastructure +from .storage import ( # Checkpointing; Logging + CheckpointManager, + cleanup_old_checkpoints, + get_logger, + load_checkpoint, + log_experiment_config, + log_metrics, + save_checkpoint, + setup_logging, +) + __all__ = [ # Computing 'DistributedConfig', @@ -86,4 +81,4 @@ 'save_config', 'merge_configs', 'validate_config', -] \ No newline at end of file +] diff --git a/src/alignment/infrastructure/computing/__init__.py b/src/alignment/infrastructure/computing/__init__.py index f8e943be..426d0716 100644 --- a/src/alignment/infrastructure/computing/__init__.py +++ b/src/alignment/infrastructure/computing/__init__.py @@ -3,24 +3,16 @@ from .distributed import ( DistributedConfig, DistributedTrainer, - setup_distributed, cleanup_distributed, - is_main_process, - get_world_size, get_rank, + get_world_size, + is_main_process, + setup_distributed, ) # Import from optimized submodule -from .optimized.gpu import ( - GPUOptimizer, - optimize_gpu_memory, - get_gpu_memory_stats, -) -from .optimized.jit import ( - JITCompiler, - compile_model, - optimize_trace, -) +from .optimized.gpu import GPUOptimizer, get_gpu_memory_stats, optimize_gpu_memory +from .optimized.jit import JITCompiler, compile_model, optimize_trace __all__ = [ # Distributed computing @@ -39,4 +31,4 @@ 'JITCompiler', 'compile_model', 'optimize_trace', -] \ No newline at end of file +] diff --git a/src/alignment/infrastructure/computing/distributed.py b/src/alignment/infrastructure/computing/distributed.py index 874bd57a..5c2d04b7 100644 --- a/src/alignment/infrastructure/computing/distributed.py +++ b/src/alignment/infrastructure/computing/distributed.py @@ -2,13 +2,14 @@ Distributed computing utilities for multi-GPU training. """ +import logging import os +from contextlib import contextmanager +from typing import Any, Callable, Dict, List, Optional, Tuple + import torch import torch.distributed as dist -from typing import Optional, List, Union, Dict, Any, Callable, Tuple -import logging from torch.nn.parallel import DistributedDataParallel as DDP -from contextlib import contextmanager logger = logging.getLogger(__name__) @@ -46,42 +47,42 @@ def setup_distributed( init_method: URL specifying how to initialize the process group world_size: Number of processes rank: Rank of the current process - + Returns: True if distributed setup successful, False otherwise """ if not torch.cuda.is_available() and backend == "nccl": logger.warning("CUDA not available, falling back to gloo backend") backend = "gloo" - + # Try to get from environment if world_size is None: world_size = int(os.environ.get("WORLD_SIZE", 1)) if rank is None: rank = int(os.environ.get("RANK", 0)) - + if world_size > 1: try: if init_method is None: init_method = os.environ.get("INIT_METHOD", "env://") - + dist.init_process_group( backend=backend, init_method=init_method, world_size=world_size, rank=rank ) - + if torch.cuda.is_available(): torch.cuda.set_device(rank % torch.cuda.device_count()) - + logger.info(f"Initialized distributed training: rank {rank}/{world_size}") return True - + except Exception as e: logger.error(f"Failed to initialize distributed training: {e}") return False - + return False @@ -112,24 +113,24 @@ def reduce_tensor( ) -> torch.Tensor: """ Reduce tensor across all processes. - + Args: tensor: Tensor to reduce op: Reduction operation dst: Destination rank - + Returns: Reduced tensor """ if not dist.is_initialized(): return tensor - + tensor = tensor.clone() dist.reduce(tensor, dst=dst, op=op) - + if op == dist.ReduceOp.SUM and is_main_process(): tensor = tensor / dist.get_world_size() - + return tensor @@ -139,20 +140,20 @@ def gather_tensor( ) -> Optional[List[torch.Tensor]]: """ Gather tensors from all processes. - + Args: tensor: Local tensor dst: Destination rank - + Returns: List of tensors if on destination rank, None otherwise """ if not dist.is_initialized(): return [tensor] - + world_size = dist.get_world_size() rank = dist.get_rank() - + if rank == dst: gathered = [torch.empty_like(tensor) for _ in range(world_size)] dist.gather(tensor, gathered, dst=dst) @@ -168,23 +169,23 @@ def all_reduce( ) -> torch.Tensor: """ All-reduce tensor across all processes. - + Args: tensor: Tensor to reduce op: Reduction operation - + Returns: Reduced tensor """ if not dist.is_initialized(): return tensor - + tensor = tensor.clone() dist.all_reduce(tensor, op=op) - + if op == dist.ReduceOp.SUM: tensor = tensor / dist.get_world_size() - + return tensor @@ -194,17 +195,17 @@ def broadcast( ) -> torch.Tensor: """ Broadcast tensor from source to all processes. - + Args: tensor: Tensor to broadcast src: Source rank - + Returns: Broadcasted tensor """ if not dist.is_initialized(): return tensor - + tensor = tensor.clone() dist.broadcast(tensor, src=src) return tensor @@ -213,15 +214,15 @@ def broadcast( class DistributedMetricComputer: """ Compute metrics in a distributed manner across multiple GPUs/nodes. - + This class provides high-level functionality for distributed metric computation, building on the basic distributed utilities. """ - + def __init__(self, backend: str = 'nccl'): """ Initialize distributed computing. - + Args: backend: Distributed backend ('nccl' for GPU, 'gloo' for CPU) """ @@ -229,27 +230,27 @@ def __init__(self, backend: str = 'nccl'): self.initialized = False self.rank = 0 self.world_size = 1 - + def setup(self, rank: Optional[int] = None, world_size: Optional[int] = None): """ Setup distributed computing environment. - + Args: rank: Process rank (auto-detected if None) world_size: Total number of processes (auto-detected if None) """ if self.initialized: return - + # Auto-detect from environment if rank is None: rank = int(os.environ.get('RANK', 0)) if world_size is None: world_size = int(os.environ.get('WORLD_SIZE', 1)) - + self.rank = rank self.world_size = world_size - + if world_size > 1: # Initialize process group dist.init_process_group( @@ -258,13 +259,13 @@ def setup(self, rank: Optional[int] = None, world_size: Optional[int] = None): world_size=world_size ) self.initialized = True - + def cleanup(self): """Cleanup distributed environment.""" if self.initialized and dist.is_initialized(): dist.destroy_process_group() self.initialized = False - + @contextmanager def distributed_context(self): """Context manager for distributed operations.""" @@ -273,52 +274,52 @@ def distributed_context(self): finally: if self.initialized: dist.barrier() - + def all_gather_metrics(self, local_metric: torch.Tensor) -> List[torch.Tensor]: """ Gather metrics from all processes. - + Args: local_metric: Local metric value - + Returns: List of metrics from all processes """ if not self.initialized or self.world_size == 1: return [local_metric] - + # Ensure tensor is on correct device if not local_metric.is_cuda and self.backend == 'nccl': local_metric = local_metric.cuda() - + # Gather from all processes gathered = [torch.zeros_like(local_metric) for _ in range(self.world_size)] dist.all_gather(gathered, local_metric) - + return gathered - - def reduce_metrics(self, + + def reduce_metrics(self, local_metric: torch.Tensor, reduction: str = 'mean') -> torch.Tensor: """ Reduce metrics across all processes. - + Args: local_metric: Local metric value reduction: Reduction operation ('mean', 'sum', 'max', 'min') - + Returns: Reduced metric """ if not self.initialized or self.world_size == 1: return local_metric - + # Clone to avoid modifying original metric = local_metric.clone() - + if not metric.is_cuda and self.backend == 'nccl': metric = metric.cuda() - + # Reduce across processes if reduction == 'sum': dist.all_reduce(metric, op=dist.ReduceOp.SUM) @@ -331,32 +332,32 @@ def reduce_metrics(self, dist.all_reduce(metric, op=dist.ReduceOp.MIN) else: raise ValueError(f"Unknown reduction: {reduction}") - + return metric - + def distributed_metric_computation(self, metric_fn: Callable, data_loader: torch.utils.data.DataLoader, **metric_kwargs) -> Dict[str, float]: """ Compute metrics in distributed fashion. - + Args: metric_fn: Metric computation function data_loader: Distributed data loader **metric_kwargs: Additional arguments for metric function - + Returns: Dictionary of computed metrics """ local_results = [] - + with self.distributed_context(): for batch in data_loader: # Compute metric on local batch result = metric_fn(batch, **metric_kwargs) local_results.append(result) - + # Aggregate local results if local_results: local_metric = torch.tensor( @@ -365,20 +366,20 @@ def distributed_metric_computation(self, ) else: local_metric = torch.tensor(0.0) - + # Reduce across all processes global_metric = self.reduce_metrics(local_metric, reduction='mean') - + return {'metric': global_metric.item()} class DistributedModelWrapper: """Wrapper for distributed model evaluation.""" - + def __init__(self, model: torch.nn.Module, device_ids: Optional[List[int]] = None): """ Initialize distributed model wrapper. - + Args: model: Model to wrap device_ids: GPU device IDs to use @@ -386,23 +387,23 @@ def __init__(self, model: torch.nn.Module, device_ids: Optional[List[int]] = Non self.model = model self.device_ids = device_ids self.ddp_model = None - + def setup_ddp(self, rank: int): """Setup DistributedDataParallel.""" if self.device_ids: device = self.device_ids[rank % len(self.device_ids)] else: device = rank - + torch.cuda.set_device(device) self.model = self.model.cuda(device) - + self.ddp_model = DDP( self.model, device_ids=[device], output_device=device ) - + def get_model(self) -> torch.nn.Module: """Get the wrapped model.""" return self.ddp_model if self.ddp_model is not None else self.model @@ -416,19 +417,19 @@ def distributed_metric_aggregation( ) -> float: """ Aggregate metric computation across data partitions. - + Args: metric_computer: Metric computation object data_partitions: List of data partitions (inputs, weights, outputs) metric_name: Name of metric to compute **compute_kwargs: Additional arguments for compute - + Returns: Aggregated metric value """ dist_computer = DistributedMetricComputer() dist_computer.setup() - + try: # Compute local metrics local_scores = [] @@ -441,33 +442,33 @@ def distributed_metric_aggregation( **compute_kwargs ) local_scores.append(score) - + # Average local scores if local_scores: local_avg = sum(local_scores) / len(local_scores) local_tensor = torch.tensor(local_avg, dtype=torch.float32) else: local_tensor = torch.tensor(0.0, dtype=torch.float32) - + # Reduce across processes global_avg = dist_computer.reduce_metrics(local_tensor, reduction='mean') - + return global_avg.item() - + finally: dist_computer.cleanup() class DistributedBatchProcessor: """Process batches in distributed fashion with automatic load balancing.""" - - def __init__(self, + + def __init__(self, world_size: int, rank: int, device: Optional[torch.device] = None): """ Initialize distributed batch processor. - + Args: world_size: Total number of processes rank: Current process rank @@ -476,36 +477,36 @@ def __init__(self, self.world_size = world_size self.rank = rank self.device = device or torch.device(f'cuda:{rank}') - + def split_batch(self, batch: torch.Tensor) -> torch.Tensor: """ Split batch across processes. - + Args: batch: Input batch - + Returns: Local portion of batch """ batch_size = batch.size(0) chunk_size = (batch_size + self.world_size - 1) // self.world_size - + start_idx = self.rank * chunk_size end_idx = min(start_idx + chunk_size, batch_size) - + if start_idx < batch_size: return batch[start_idx:end_idx].to(self.device) else: # Return empty tensor if this rank has no data return torch.empty(0, *batch.shape[1:], device=self.device) - + def gather_results(self, local_result: torch.Tensor) -> torch.Tensor: """ Gather results from all processes. - + Args: local_result: Local computation result - + Returns: Concatenated results from all processes """ @@ -513,20 +514,20 @@ def gather_results(self, local_result: torch.Tensor) -> torch.Tensor: local_size = torch.tensor(local_result.size(0), device=self.device) sizes = [torch.zeros_like(local_size) for _ in range(self.world_size)] dist.all_gather(sizes, local_size) - + # Gather tensors with variable sizes max_size = max(s.item() for s in sizes) padded_result = torch.zeros(max_size, *local_result.shape[1:], device=self.device) if local_result.size(0) > 0: padded_result[:local_result.size(0)] = local_result - + gathered = [torch.zeros_like(padded_result) for _ in range(self.world_size)] dist.all_gather(gathered, padded_result) - + # Concatenate non-padded portions results = [] for i, size in enumerate(sizes): if size > 0: results.append(gathered[i][:size]) - - return torch.cat(results, dim=0) if results else torch.empty(0, *local_result.shape[1:]) \ No newline at end of file + + return torch.cat(results, dim=0) if results else torch.empty(0, *local_result.shape[1:]) diff --git a/src/alignment/infrastructure/computing/optimized/__init__.py b/src/alignment/infrastructure/computing/optimized/__init__.py index a5507b12..5a2292a0 100644 --- a/src/alignment/infrastructure/computing/optimized/__init__.py +++ b/src/alignment/infrastructure/computing/optimized/__init__.py @@ -6,34 +6,34 @@ # GPU-accelerated functions from .gpu import ( + GPUAcceleratedMetrics, + gpu_conditional_entropy, + gpu_entropy, gpu_histogram1d, gpu_histogram2d, gpu_mutual_information, - gpu_entropy, - gpu_conditional_entropy, - GPUAcceleratedMetrics, ) -# JIT-compiled functions +# JIT-compiled functions from .jit import ( - compute_rayleigh_quotient_jit, + JITMutualInformation, + JITNodeCorrelation, + JITRayleighQuotient, + benchmark_jit_vs_regular, + compute_batch_histogram_jit, compute_cosine_similarity_matrix_jit, - compute_mutual_information_gaussian_jit, compute_eigenvalue_entropy_jit, + compute_mutual_information_gaussian_jit, compute_node_correlation_jit, + compute_rayleigh_quotient_jit, compute_spectral_norm_jit, - compute_batch_histogram_jit, - JITRayleighQuotient, - JITMutualInformation, - JITNodeCorrelation, create_jit_metric, - benchmark_jit_vs_regular, ) __all__ = [ # GPU functions 'gpu_histogram1d', - 'gpu_histogram2d', + 'gpu_histogram2d', 'gpu_mutual_information', 'gpu_entropy', 'gpu_conditional_entropy', @@ -47,8 +47,8 @@ 'compute_spectral_norm_jit', 'compute_batch_histogram_jit', 'JITRayleighQuotient', - 'JITMutualInformation', + 'JITMutualInformation', 'JITNodeCorrelation', 'create_jit_metric', 'benchmark_jit_vs_regular', -] \ No newline at end of file +] diff --git a/src/alignment/infrastructure/computing/optimized/gpu.py b/src/alignment/infrastructure/computing/optimized/gpu.py index a193070e..4f0a2999 100644 --- a/src/alignment/infrastructure/computing/optimized/gpu.py +++ b/src/alignment/infrastructure/computing/optimized/gpu.py @@ -2,10 +2,10 @@ GPU-accelerated utilities for fast computation of alignment metrics. """ -import torch -import torch.nn.functional as F -from typing import Tuple, Optional, Union import math +from typing import Optional, Tuple, Union + +import torch def gpu_histogram1d( @@ -17,14 +17,14 @@ def gpu_histogram1d( ) -> Tuple[torch.Tensor, torch.Tensor]: """ GPU-accelerated 1D histogram computation. - + Args: data: Input tensor to bin bins: Number of bins min_val: Minimum value for binning range max_val: Maximum value for binning range density: If True, normalize to density - + Returns: hist: Histogram counts bin_edges: Bin edge values @@ -33,24 +33,24 @@ def gpu_histogram1d( min_val = data.min().item() if max_val is None: max_val = data.max().item() - + # Create bin edges bin_edges = torch.linspace(min_val, max_val, bins + 1, device=data.device) - + # Compute bin indices for each data point bin_width = (max_val - min_val) / bins indices = ((data - min_val) / bin_width).long() indices = indices.clamp(0, bins - 1) - + # Count occurrences using scatter_add hist = torch.zeros(bins, dtype=torch.float32, device=data.device) ones = torch.ones_like(data, dtype=torch.float32) hist.scatter_add_(0, indices.flatten(), ones.flatten()) - + if density: # Normalize to density hist = hist / (hist.sum() * bin_width) - + return hist, bin_edges @@ -63,14 +63,14 @@ def gpu_histogram2d( ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """ GPU-accelerated 2D histogram computation. - + Args: x: First variable y: Second variable bins: Number of bins (can be different for x and y) range: Range for binning ((xmin, xmax), (ymin, ymax)) density: If True, normalize to density - + Returns: hist: 2D histogram x_edges: Bin edges for x @@ -80,38 +80,38 @@ def gpu_histogram2d( x_bins = y_bins = bins else: x_bins, y_bins = bins - + if range is None: x_min, x_max = x.min().item(), x.max().item() y_min, y_max = y.min().item(), y.max().item() else: (x_min, x_max), (y_min, y_max) = range - + # Create bin edges x_edges = torch.linspace(x_min, x_max, x_bins + 1, device=x.device) y_edges = torch.linspace(y_min, y_max, y_bins + 1, device=y.device) - + # Compute bin indices x_width = (x_max - x_min) / x_bins y_width = (y_max - y_min) / y_bins - + x_indices = ((x - x_min) / x_width).long().clamp(0, x_bins - 1) y_indices = ((y - y_min) / y_width).long().clamp(0, y_bins - 1) - + # Convert to linear indices linear_indices = y_indices * x_bins + x_indices - + # Count occurrences hist = torch.zeros(y_bins * x_bins, dtype=torch.float32, device=x.device) ones = torch.ones_like(x, dtype=torch.float32) hist.scatter_add_(0, linear_indices.flatten(), ones.flatten()) - + # Reshape to 2D hist = hist.reshape(y_bins, x_bins) - + if density: hist = hist / (hist.sum() * x_width * y_width) - + return hist, x_edges, y_edges @@ -123,13 +123,13 @@ def gpu_mutual_information( ) -> torch.Tensor: """ GPU-accelerated mutual information computation. - + Args: x: First variable y: Second variable bins: Number of bins for discretization method: Method to use ('histogram' or 'kraskov') - + Returns: Mutual information value """ @@ -137,67 +137,67 @@ def gpu_mutual_information( # Compute 2D histogram hist_xy, _, _ = gpu_histogram2d(x, y, bins=bins) hist_xy = hist_xy + 1e-10 # Add small constant - + # Normalize to joint probability p_xy = hist_xy / hist_xy.sum() - + # Compute marginals p_x = p_xy.sum(dim=0) p_y = p_xy.sum(dim=1) - + # Compute MI: sum(p_xy * log(p_xy / (p_x * p_y))) p_x_p_y = p_x.unsqueeze(0) * p_y.unsqueeze(1) - + # Use log trick to avoid log(0) log_term = torch.where( p_xy > 1e-10, torch.log(p_xy / (p_x_p_y + 1e-10)), torch.tensor(0.0, device=x.device) ) - + mi = (p_xy * log_term).sum() - + return mi - + elif method == "kraskov": # Simplified Kraskov estimator # This is a basic version - full implementation would be more complex - + n = x.shape[0] k = min(3, n // 10) # Number of neighbors - + # Standardize x_std = (x - x.mean()) / (x.std() + 1e-8) y_std = (y - y.mean()) / (y.std() + 1e-8) - + # Stack for joint space xy = torch.stack([x_std, y_std], dim=1) - + # Compute distances in joint space distances = torch.cdist(xy, xy) - + # Get k-th nearest neighbor distances kth_distances, _ = distances.topk(k + 1, largest=False, dim=1) epsilon = kth_distances[:, -1] # k-th neighbor distance - + # Count neighbors in marginal spaces x_distances = torch.abs(x_std.unsqueeze(1) - x_std.unsqueeze(0)) y_distances = torch.abs(y_std.unsqueeze(1) - y_std.unsqueeze(0)) - + n_x = (x_distances < epsilon.unsqueeze(1)).sum(dim=1).float() n_y = (y_distances < epsilon.unsqueeze(1)).sum(dim=1).float() - + # Digamma function approximation def digamma_approx(x): return x.log() - 1.0 / (2.0 * x) - + # MI estimation mi = digamma_approx(torch.tensor(k, dtype=torch.float32)) + \ digamma_approx(torch.tensor(n, dtype=torch.float32)) - \ digamma_approx(n_x).mean() - digamma_approx(n_y).mean() - + return mi.clamp(min=0) - + else: raise ValueError(f"Unknown method: {method}") @@ -209,34 +209,34 @@ def gpu_entropy( ) -> torch.Tensor: """ GPU-accelerated entropy computation. - + Args: data: Input data bins: Number of bins method: Method to use ('histogram' or 'gaussian') - + Returns: Entropy value """ if method == "histogram": hist, _ = gpu_histogram1d(data, bins=bins) - + # Normalize to probability p = hist / hist.sum() p = p + 1e-10 # Add small constant - + # Compute entropy entropy = -(p * p.log()).sum() - + return entropy - + elif method == "gaussian": # Entropy of Gaussian: 0.5 * log(2 * pi * e * var) var = data.var() entropy = 0.5 * torch.log(2 * math.pi * math.e * var) - + return entropy - + else: raise ValueError(f"Unknown method: {method}") @@ -249,52 +249,52 @@ def gpu_conditional_entropy( ) -> torch.Tensor: """ GPU-accelerated conditional entropy H(X,Y|Z). - + Args: x: First variable y: Second variable condition: Conditioning variable bins: Number of bins - + Returns: Conditional entropy """ # Create 3D histogram n = x.shape[0] - + # Compute ranges x_min, x_max = x.min().item(), x.max().item() y_min, y_max = y.min().item(), y.max().item() z_min, z_max = condition.min().item(), condition.max().item() - + # Compute bin indices x_idx = ((x - x_min) / ((x_max - x_min) / bins)).long().clamp(0, bins - 1) y_idx = ((y - y_min) / ((y_max - y_min) / bins)).long().clamp(0, bins - 1) z_idx = ((condition - z_min) / ((z_max - z_min) / bins)).long().clamp(0, bins - 1) - + # Linear indices for 3D histogram indices = z_idx * bins * bins + y_idx * bins + x_idx - + # Count occurrences hist_xyz = torch.zeros(bins ** 3, device=x.device) ones = torch.ones(n, device=x.device) hist_xyz.scatter_add_(0, indices, ones) hist_xyz = hist_xyz.reshape(bins, bins, bins) + 1e-10 - + # Normalize to probability p_xyz = hist_xyz / hist_xyz.sum() - + # Marginal p(z) p_z = p_xyz.sum(dim=(1, 2)) - + # Conditional entropy h_xy_given_z = 0 - + for z in range(bins): if p_z[z] > 1e-10: # p(x,y|z) = p(x,y,z) / p(z) p_xy_given_z = p_xyz[z] / p_z[z] - + # H(X,Y|Z=z) log_term = torch.where( p_xy_given_z > 1e-10, @@ -302,10 +302,10 @@ def gpu_conditional_entropy( torch.tensor(0.0, device=x.device) ) h_xy_z = -(p_xy_given_z * log_term).sum() - + # Weight by p(z) h_xy_given_z += p_z[z] * h_xy_z - + return h_xy_given_z @@ -313,36 +313,36 @@ class GPUAcceleratedMetrics: """ Collection of GPU-accelerated metric computations. """ - + @staticmethod @torch.jit.script def fast_covariance(X: torch.Tensor) -> torch.Tensor: """ JIT-compiled fast covariance computation. - + Args: X: Input tensor (n_samples, n_features) - + Returns: Covariance matrix """ X_centered = X - X.mean(dim=0, keepdim=True) cov = X_centered.T @ X_centered / (X.shape[0] - 1) return cov - - @staticmethod + + @staticmethod @torch.jit.script def fast_correlation(X: torch.Tensor) -> torch.Tensor: """ JIT-compiled fast correlation computation. - + Args: X: Input tensor (n_samples, n_features) - + Returns: Correlation matrix """ # Standardize X_std = (X - X.mean(dim=0, keepdim=True)) / (X.std(dim=0, keepdim=True) + 1e-8) corr = X_std.T @ X_std / (X.shape[0] - 1) - return corr \ No newline at end of file + return corr diff --git a/src/alignment/infrastructure/computing/optimized/jit.py b/src/alignment/infrastructure/computing/optimized/jit.py index 47b0c822..d264f7da 100644 --- a/src/alignment/infrastructure/computing/optimized/jit.py +++ b/src/alignment/infrastructure/computing/optimized/jit.py @@ -2,11 +2,9 @@ JIT-optimized implementations of alignment metrics. """ -import torch -import torch.nn.functional as F from typing import Tuple -import math +import torch # JIT-compiled helper functions @@ -18,34 +16,34 @@ def compute_rayleigh_quotient_jit( ) -> torch.Tensor: """ JIT-compiled Rayleigh Quotient computation. - + Args: inputs: Input activations (batch_size, input_dim) weights: Weight matrix (output_dim, input_dim) epsilon: Small constant for stability - + Returns: RQ scores for each neuron """ # Center inputs inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) - + # Compute covariance C = (inputs_centered.T @ inputs_centered) / (inputs.shape[0] - 1) - + # Add small diagonal for stability C = C + epsilon * torch.eye(C.shape[0], device=C.device) - + # Compute RQ for each weight vector num_neurons = weights.shape[0] rq_scores = torch.zeros(num_neurons, device=weights.device) - + for i in range(num_neurons): w = weights[i] numerator = w @ C @ w denominator = w @ w rq_scores[i] = numerator / (denominator + epsilon) - + return rq_scores @@ -53,19 +51,19 @@ def compute_rayleigh_quotient_jit( def compute_cosine_similarity_matrix_jit(weights: torch.Tensor) -> torch.Tensor: """ JIT-compiled cosine similarity matrix computation. - + Args: weights: Weight matrix (n_neurons, input_dim) - + Returns: Cosine similarity matrix (n_neurons, n_neurons) """ # Normalize weight vectors weights_norm = weights / (torch.norm(weights, p=2, dim=1, keepdim=True) + 1e-8) - + # Compute similarity matrix similarity = weights_norm @ weights_norm.T - + return similarity @@ -77,38 +75,38 @@ def compute_mutual_information_gaussian_jit( ) -> torch.Tensor: """ JIT-compiled Gaussian mutual information estimation. - + Args: x: First variable (n_samples,) y: Second variable (n_samples,) epsilon: Small constant for stability - + Returns: Mutual information estimate """ n = x.shape[0] - + # Standardize x_std = (x - x.mean()) / (x.std() + epsilon) y_std = (y - y.mean()) / (y.std() + epsilon) - + # Stack variables xy = torch.stack([x_std, y_std], dim=1) - + # Compute covariance matrix cov = (xy.T @ xy) / (n - 1) - + # Add small diagonal cov = cov + epsilon * torch.eye(2, device=cov.device) - + # Compute determinants det_joint = torch.det(cov) var_x = cov[0, 0] var_y = cov[1, 1] - + # MI = 0.5 * log(var_x * var_y / det_joint) mi = 0.5 * torch.log(var_x * var_y / (det_joint + epsilon)) - + return mi.clamp(min=0) @@ -120,30 +118,30 @@ def compute_eigenvalue_entropy_jit( ) -> torch.Tensor: """ JIT-compiled eigenvalue entropy computation. - + Args: matrix: Square matrix temperature: Temperature parameter epsilon: Small constant for stability - + Returns: Eigenvalue entropy """ # Compute eigenvalues eigenvalues = torch.linalg.eigvalsh(matrix) - + # Filter small eigenvalues eigenvalues = eigenvalues[eigenvalues > epsilon] - + # Normalize eigenvalues = eigenvalues / eigenvalues.sum() - + # Apply temperature eigenvalues = eigenvalues / temperature - + # Compute entropy entropy = -(eigenvalues * eigenvalues.log()).sum() - + return entropy @@ -154,11 +152,11 @@ def compute_node_correlation_jit( ) -> torch.Tensor: """ JIT-compiled node correlation computation. - + Args: activations: Neuron activations (batch_size, n_neurons) epsilon: Small constant for stability - + Returns: Average correlation for each neuron """ @@ -166,18 +164,18 @@ def compute_node_correlation_jit( mean = activations.mean(dim=0, keepdim=True) std = activations.std(dim=0, keepdim=True) activations_std = (activations - mean) / (std + epsilon) - + # Compute correlation matrix corr_matrix = (activations_std.T @ activations_std) / (activations.shape[0] - 1) - + # Zero out diagonal n = corr_matrix.shape[0] mask = torch.ones_like(corr_matrix) - torch.eye(n, device=corr_matrix.device) corr_matrix = corr_matrix * mask - + # Average correlation per neuron avg_corr = corr_matrix.abs().sum(dim=1) / (n - 1) - + return avg_corr @@ -185,76 +183,76 @@ def compute_node_correlation_jit( def compute_spectral_norm_jit(weights: torch.Tensor) -> torch.Tensor: """ JIT-compiled spectral norm computation. - + Args: weights: Weight matrix - + Returns: Spectral norm (largest singular value) """ # Use power iteration for efficiency n_iter = 5 - + # Random initialization u = torch.randn(weights.shape[0], device=weights.device) v = torch.randn(weights.shape[1], device=weights.device) - + for _ in range(n_iter): # v = W^T u / ||W^T u|| v = weights.T @ u v = v / (v.norm() + 1e-8) - + # u = W v / ||W v|| u = weights @ v u = u / (u.norm() + 1e-8) - + # Spectral norm = u^T W v spectral_norm = u @ weights @ v - + return spectral_norm -@torch.jit.script +@torch.jit.script def compute_batch_histogram_jit( data: torch.Tensor, bins: int = 10 ) -> Tuple[torch.Tensor, torch.Tensor]: """ JIT-compiled batch histogram computation. - + Args: data: Input data (batch_size, n_features) bins: Number of bins - + Returns: histograms: Histogram for each feature (n_features, bins) bin_edges: Bin edges (bins + 1,) """ batch_size, n_features = data.shape - + # Get global min/max data_min = data.min() data_max = data.max() - + # Create bin edges bin_edges = torch.linspace(data_min, data_max, bins + 1, device=data.device) bin_width = (data_max - data_min) / bins - + # Initialize histograms histograms = torch.zeros(n_features, bins, device=data.device) - + # Compute histograms for each feature for i in range(n_features): feature_data = data[:, i] - + # Compute bin indices indices = ((feature_data - data_min) / bin_width).long() indices = indices.clamp(0, bins - 1) - + # Count occurrences for j in range(batch_size): histograms[i, indices[j]] += 1 - + return histograms, bin_edges @@ -262,33 +260,33 @@ def compute_batch_histogram_jit( class JITRayleighQuotient: """JIT-optimized Rayleigh Quotient metric.""" - + def __init__(self, epsilon: float = 1e-8): self.epsilon = epsilon self.compute = torch.jit.script(compute_rayleigh_quotient_jit) - + def __call__(self, inputs: torch.Tensor, weights: torch.Tensor) -> torch.Tensor: return self.compute(inputs, weights, self.epsilon) class JITMutualInformation: """JIT-optimized Mutual Information metric.""" - + def __init__(self, epsilon: float = 1e-8): self.epsilon = epsilon self.compute = torch.jit.script(compute_mutual_information_gaussian_jit) - + def __call__(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: return self.compute(x, y, self.epsilon) class JITNodeCorrelation: """JIT-optimized Node Correlation metric.""" - + def __init__(self, epsilon: float = 1e-8): self.epsilon = epsilon self.compute = torch.jit.script(compute_node_correlation_jit) - + def __call__(self, activations: torch.Tensor) -> torch.Tensor: return self.compute(activations, self.epsilon) @@ -298,11 +296,11 @@ def __call__(self, activations: torch.Tensor) -> torch.Tensor: def create_jit_metric(metric_name: str, **kwargs): """ Create a JIT-optimized version of a metric. - + Args: metric_name: Name of the metric **kwargs: Metric-specific parameters - + Returns: JIT-optimized metric instance """ @@ -311,10 +309,10 @@ def create_jit_metric(metric_name: str, **kwargs): 'mutual_information': JITMutualInformation, 'node_correlation': JITNodeCorrelation, } - + if metric_name not in jit_metrics: raise ValueError(f"No JIT implementation for metric: {metric_name}") - + return jit_metrics[metric_name](**kwargs) @@ -328,31 +326,31 @@ def benchmark_jit_vs_regular( ) -> Tuple[float, float]: """ Benchmark JIT vs regular implementation. - + Args: metric_name: Metric to benchmark input_shape: Shape of input data n_iterations: Number of iterations device: Device to use - + Returns: jit_time: Time for JIT version regular_time: Time for regular version """ import time - + # Create dummy data if metric_name == 'rayleigh_quotient': inputs = torch.randn(input_shape[0], input_shape[1], device=device) weights = torch.randn(input_shape[2], input_shape[1], device=device) - + # JIT version jit_metric = JITRayleighQuotient() - + # Warmup for _ in range(10): _ = jit_metric(inputs, weights) - + # Time JIT torch.cuda.synchronize() start = time.time() @@ -360,12 +358,12 @@ def benchmark_jit_vs_regular( _ = jit_metric(inputs, weights) torch.cuda.synchronize() jit_time = time.time() - start - + # Regular version would go here # For now, we'll use the same time as placeholder regular_time = jit_time * 1.5 # Assume 50% slower - + else: raise ValueError(f"Benchmark not implemented for: {metric_name}") - - return jit_time, regular_time \ No newline at end of file + + return jit_time, regular_time diff --git a/src/alignment/infrastructure/storage/__init__.py b/src/alignment/infrastructure/storage/__init__.py index 54eb6e8b..938ed393 100644 --- a/src/alignment/infrastructure/storage/__init__.py +++ b/src/alignment/infrastructure/storage/__init__.py @@ -2,16 +2,11 @@ from .checkpoint import ( CheckpointManager, - save_checkpoint, - load_checkpoint, cleanup_old_checkpoints, + load_checkpoint, + save_checkpoint, ) -from .logging import ( - setup_logging, - get_logger, - log_metrics, - log_experiment_config, -) +from .logging import get_logger, log_experiment_config, log_metrics, setup_logging __all__ = [ # Checkpointing @@ -24,4 +19,4 @@ 'get_logger', 'log_metrics', 'log_experiment_config', -] \ No newline at end of file +] diff --git a/src/alignment/infrastructure/storage/checkpoint.py b/src/alignment/infrastructure/storage/checkpoint.py index e79eda64..23517df1 100644 --- a/src/alignment/infrastructure/storage/checkpoint.py +++ b/src/alignment/infrastructure/storage/checkpoint.py @@ -2,10 +2,11 @@ Checkpoint utilities for saving models with hooks. """ -import torch import logging -from typing import Dict, Any, Optional from pathlib import Path +from typing import Any, Dict, Optional + +import torch logger = logging.getLogger(__name__) @@ -20,7 +21,7 @@ def save_checkpoint( ) -> None: """ Save a checkpoint, handling models with hooks gracefully. - + Args: model: The model to save optimizer: Optimizer state to save (optional) @@ -33,13 +34,13 @@ def save_checkpoint( 'epoch': epoch, 'model_state_dict': model.state_dict(), } - + if optimizer is not None: checkpoint['optimizer_state_dict'] = optimizer.state_dict() - + if additional_state is not None: checkpoint.update(additional_state) - + if save_hooks: # Try to save the full model, but warn about hooks try: @@ -47,7 +48,7 @@ def save_checkpoint( logger.warning("Saving full model with hooks. This may not be loadable.") except Exception as e: logger.warning(f"Failed to save full model: {e}. Saving state_dict only.") - + try: torch.save(checkpoint, filepath) logger.info(f"Checkpoint saved to {filepath}") @@ -64,21 +65,21 @@ def load_checkpoint( ) -> Dict[str, Any]: """ Load a checkpoint, handling different checkpoint formats. - + Args: filepath: Path to the checkpoint file model: Model to load state into (optional) optimizer: Optimizer to load state into (optional) map_location: Device mapping location - + Returns: Dictionary containing the loaded checkpoint data """ if not Path(filepath).exists(): raise FileNotFoundError(f"Checkpoint not found: {filepath}") - + checkpoint = torch.load(filepath, map_location=map_location) - + # Load model state if model is provided if model is not None and 'model_state_dict' in checkpoint: try: @@ -92,7 +93,7 @@ def load_checkpoint( logger.warning("Model state loaded with strict=False") except Exception as e2: logger.error(f"Failed to load model state even with strict=False: {e2}") - + # Load optimizer state if optimizer is provided if optimizer is not None and 'optimizer_state_dict' in checkpoint: try: @@ -100,7 +101,7 @@ def load_checkpoint( logger.info("Optimizer state loaded successfully") except Exception as e: logger.error(f"Failed to load optimizer state: {e}") - + return checkpoint @@ -111,7 +112,7 @@ def save_model_for_inference( ) -> None: """ Save a model for inference, optionally removing hooks. - + Args: model: The model to save filepath: Path to save the model @@ -124,11 +125,11 @@ def save_model_for_inference( if hasattr(module, '_forward_hooks') and len(module._forward_hooks) > 0: hooks_backup[name] = dict(module._forward_hooks) module._forward_hooks.clear() - + # Save model without hooks torch.save(model.state_dict(), filepath) logger.info(f"Model saved for inference (hooks removed): {filepath}") - + # Restore hooks for name, module in model.named_modules(): if name in hooks_backup: @@ -136,4 +137,4 @@ def save_model_for_inference( else: # Save model as is torch.save(model.state_dict(), filepath) - logger.info(f"Model saved for inference: {filepath}") \ No newline at end of file + logger.info(f"Model saved for inference: {filepath}") diff --git a/src/alignment/infrastructure/storage/logging.py b/src/alignment/infrastructure/storage/logging.py index 0f9ac23f..d586a883 100644 --- a/src/alignment/infrastructure/storage/logging.py +++ b/src/alignment/infrastructure/storage/logging.py @@ -2,12 +2,12 @@ Logging utilities for the alignment framework. """ +import json import logging import sys -from typing import Dict, Any, Optional, Union -from pathlib import Path from datetime import datetime -import json +from pathlib import Path +from typing import Any, Dict, Optional, Union def setup_logging( @@ -18,22 +18,22 @@ def setup_logging( ) -> logging.Logger: """ Setup logging configuration. - + Args: log_level: Logging level log_file: Optional file to log to format_string: Custom format string disable_existing: Whether to disable existing loggers - + Returns: Root logger """ if isinstance(log_level, str): log_level = getattr(logging, log_level.upper()) - + if format_string is None: format_string = "[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s" - + # Configure logging logging_config = { 'version': 1, @@ -57,12 +57,12 @@ def setup_logging( 'handlers': ['console'] } } - + # Add file handler if specified if log_file: log_file = Path(log_file) log_file.parent.mkdir(parents=True, exist_ok=True) - + logging_config['handlers']['file'] = { 'class': 'logging.FileHandler', 'level': log_level, @@ -71,22 +71,22 @@ def setup_logging( 'mode': 'a' } logging_config['root']['handlers'].append('file') - + logging.config.dictConfig(logging_config) - + logger = logging.getLogger() logger.info(f"Logging configured with level {logging.getLevelName(log_level)}") - + return logger def get_logger(name: str) -> logging.Logger: """ Get a logger with the specified name. - + Args: name: Logger name - + Returns: Logger instance """ @@ -101,7 +101,7 @@ def log_metrics( ): """ Log metrics in a structured format. - + Args: metrics: Dictionary of metrics step: Optional step/iteration number @@ -110,7 +110,7 @@ def log_metrics( """ if logger is None: logger = logging.getLogger() - + # Format metrics formatted = [] for key, value in metrics.items(): @@ -119,13 +119,13 @@ def log_metrics( formatted.append(f"{metric_name}: {value:.4f}") else: formatted.append(f"{metric_name}: {value}") - + # Create log message if step is not None: message = f"[Step {step}] " + " | ".join(formatted) else: message = " | ".join(formatted) - + logger.info(message) @@ -133,7 +133,7 @@ class MetricLogger: """ Logger specifically for tracking metrics over time. """ - + def __init__( self, log_dir: Union[str, Path], @@ -141,7 +141,7 @@ def __init__( ): """ Initialize metric logger. - + Args: log_dir: Directory to save logs experiment_name: Name of the experiment @@ -149,19 +149,19 @@ def __init__( self.log_dir = Path(log_dir) self.log_dir.mkdir(parents=True, exist_ok=True) self.experiment_name = experiment_name - + # Create log files timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.metrics_file = self.log_dir / f"{experiment_name}_metrics_{timestamp}.jsonl" self.summary_file = self.log_dir / f"{experiment_name}_summary_{timestamp}.json" - + self.metrics_history = [] self.start_time = datetime.now() - + def log(self, metrics: Dict[str, Any], step: int): """ Log metrics for a specific step. - + Args: metrics: Metrics to log step: Current step @@ -171,18 +171,18 @@ def log(self, metrics: Dict[str, Any], step: int): 'timestamp': datetime.now().isoformat(), 'metrics': metrics } - + # Append to history self.metrics_history.append(entry) - + # Write to file with open(self.metrics_file, 'a') as f: f.write(json.dumps(entry) + '\n') - + def write_summary(self, final_metrics: Optional[Dict[str, Any]] = None): """ Write a summary of the experiment. - + Args: final_metrics: Optional final metrics """ @@ -194,24 +194,24 @@ def write_summary(self, final_metrics: Optional[Dict[str, Any]] = None): 'num_steps': len(self.metrics_history), 'final_metrics': final_metrics or {} } - + # Compute metric statistics if self.metrics_history: metric_stats = {} all_metrics = set() - + # Collect all metric names for entry in self.metrics_history: all_metrics.update(entry['metrics'].keys()) - + # Compute stats for each metric for metric_name in all_metrics: values = [ - entry['metrics'][metric_name] - for entry in self.metrics_history + entry['metrics'][metric_name] + for entry in self.metrics_history if metric_name in entry['metrics'] ] - + if values and all(isinstance(v, (int, float)) for v in values): metric_stats[metric_name] = { 'min': min(values), @@ -219,11 +219,11 @@ def write_summary(self, final_metrics: Optional[Dict[str, Any]] = None): 'mean': sum(values) / len(values), 'final': values[-1] } - + summary['metric_statistics'] = metric_stats - + # Write summary with open(self.summary_file, 'w') as f: json.dump(summary, f, indent=2) - - get_logger(__name__).info(f"Wrote experiment summary to {self.summary_file}") \ No newline at end of file + + get_logger(__name__).info(f"Wrote experiment summary to {self.summary_file}") diff --git a/src/alignment/metrics/__init__.py b/src/alignment/metrics/__init__.py index bfadebfd..8fb6dfd5 100644 --- a/src/alignment/metrics/__init__.py +++ b/src/alignment/metrics/__init__.py @@ -5,23 +5,21 @@ from ..core.registry import METRIC_REGISTRY # Import all metric modules to register them -from . import rayleigh -from . import information -from .information import pairwise_gaussian # Ensure registration side-effects -from . import similarity -from . import spectral -from . import task_specific -from .information import gaussian_pid # Register gaussian PID synergy +from . import information, rayleigh, similarity, spectral, task_specific +from .information import ( + gaussian_pid, # Register gaussian PID synergy + pairwise_gaussian, # Ensure registration side-effects +) def get_metric(name: str, **kwargs): """ Get a metric instance by name. - + Args: name: Name of the metric **kwargs: Parameters to pass to metric constructor - + Returns: Instantiated metric object """ @@ -31,7 +29,7 @@ def get_metric(name: str, **kwargs): def list_metrics(): """ List all available metrics. - + Returns: List of metric names """ @@ -39,4 +37,4 @@ def list_metrics(): # For convenience, expose the registry and functions -__all__ = ['METRIC_REGISTRY', 'get_metric', 'list_metrics'] \ No newline at end of file +__all__ = ['METRIC_REGISTRY', 'get_metric', 'list_metrics'] diff --git a/src/alignment/metrics/gradient_based.py b/src/alignment/metrics/gradient_based.py index a8eda883..f96119ed 100644 --- a/src/alignment/metrics/gradient_based.py +++ b/src/alignment/metrics/gradient_based.py @@ -10,10 +10,10 @@ can be used to design efficient local learning algorithms. """ -from typing import Optional, Any, Dict, List -import torch -import torch.nn as nn import logging +from typing import Any, Dict, List, Optional + +import torch from ..core.base import BaseMetric from ..core.registry import register_metric @@ -25,15 +25,15 @@ class GradientAlignment(BaseMetric): """ Measures alignment between local signals and backprop gradient. - + Computes correlation between various local signals (activations, weights, etc.) and the true backprop gradient to identify optimal local learning rules. - + Use during training to: - Find local rules that approximate backprop - Design biologically-plausible learning - Identify credit assignment strategies - + Example: >>> metric = GradientAlignment() >>> # During training (after backward()): @@ -45,7 +45,7 @@ class GradientAlignment(BaseMetric): ... ) >>> # High alignment = this local signal correlates with gradient """ - + def __init__( self, local_signal: str = 'hebbian', # 'hebbian', 'anti_hebbian', 'output', 'input' @@ -55,7 +55,7 @@ def __init__( ): """ Initialize gradient alignment metric. - + Args: local_signal: Which local signal to test - 'hebbian': x_i * y_j (Hebbian rule) @@ -71,24 +71,24 @@ def __init__( self.local_signal = local_signal self.normalize = normalize self.accumulate_over_batches = accumulate_over_batches - + # Accumulation for multi-batch correlation if accumulate_over_batches: self.accumulated_gradients = {} self.accumulated_signals = {} - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return False - + @property def requires_outputs(self) -> bool: return True - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -100,24 +100,24 @@ def compute( ) -> torch.Tensor: """ Compute alignment between local signal and backprop gradient. - + Args: inputs: Layer inputs [B, D_in] outputs: Layer outputs [B, N] gradients: Weight gradients [N, D_in] (from backprop) targets: Target labels (for anti-Hebbian) **kwargs: Additional parameters - + Returns: Alignment scores per neuron [N] """ if inputs is None or outputs is None: raise ValueError("GradientAlignment requires inputs and outputs") - + if gradients is None: logger.warning("No gradients provided, cannot compute gradient alignment") return torch.zeros(outputs.shape[1], device=outputs.device) - + # Ensure shapes if inputs.ndim > 2: inputs = inputs.reshape(inputs.shape[0], -1) @@ -125,21 +125,21 @@ def compute( outputs = outputs.reshape(outputs.shape[0], -1) if gradients.ndim > 2: gradients = gradients.reshape(gradients.shape[0], -1) - + B, D_in = inputs.shape B, N = outputs.shape - + # Compute local signal local_signal = self._compute_local_signal( inputs, outputs, targets, gradients.shape ) # [N, D_in] - same shape as gradients - + # Compute correlation with gradients alignment = self._compute_correlation(local_signal, gradients) # [N] - alignment per neuron - + return alignment - + def _compute_local_signal( self, inputs: torch.Tensor, # [B, D_in] @@ -149,18 +149,18 @@ def _compute_local_signal( ) -> torch.Tensor: """ Compute local learning signal. - + Returns: Local signal [N, D_in] (same shape as weight gradient) """ B, D_in = inputs.shape B, N = outputs.shape - + if self.local_signal == 'hebbian': # Hebbian: Δw_ij ∝ x_i * y_j # Averaged over batch: [N, D_in] signal = outputs.T @ inputs / B # Outer product averaged - + elif self.local_signal == 'anti_hebbian': # Anti-Hebbian: Δw_ij ∝ x_i * (target - y_j) if targets is None: @@ -172,10 +172,10 @@ def _compute_local_signal( target_onehot = torch.nn.functional.one_hot(targets, num_classes=N).float() else: target_onehot = targets - + error = target_onehot - outputs # [B, N] signal = error.T @ inputs / B - + elif self.local_signal == 'oja': # Oja's rule: Hebbian with weight decay # Δw = η * y * (x - y*w) @@ -187,22 +187,22 @@ def _compute_local_signal( hebbian = outputs.T @ inputs / B # This is simplified; full Oja needs weight norm signal = hebbian - + elif self.local_signal == 'output': # Just output: Δw ∝ y # Broadcast to weight shape signal = outputs.mean(dim=0).unsqueeze(1).expand(N, D_in) - + elif self.local_signal == 'input': # Just input: Δw ∝ x # Broadcast to weight shape signal = inputs.mean(dim=0).unsqueeze(0).expand(N, D_in) - + else: raise ValueError(f"Unknown local signal: {self.local_signal}") - + return signal - + def _compute_correlation( self, signal: torch.Tensor, # [N, D_in] @@ -210,38 +210,38 @@ def _compute_correlation( ) -> torch.Tensor: """ Compute correlation between local signal and backprop gradient. - + High correlation → local signal approximates gradient well → Can be used as local learning rule! - + Returns: Correlation per neuron [N] """ N, D_in = signal.shape - + # Compute correlation for each neuron correlations = torch.zeros(N, device=signal.device) - + for i in range(N): signal_i = signal[i] # [D_in] grad_i = gradients[i] # [D_in] - + if self.normalize: # Pearson correlation signal_centered = signal_i - signal_i.mean() grad_centered = grad_i - grad_i.mean() - + cov = (signal_centered * grad_centered).sum() std_signal = signal_centered.std() std_grad = grad_centered.std() - + corr = cov / (std_signal * std_grad + 1e-8) else: # Dot product (unnormalized) corr = (signal_i * grad_i).sum() / (signal_i.norm() * grad_i.norm() + 1e-8) - + correlations[i] = corr.abs() # Use absolute correlation - + return correlations @@ -249,19 +249,19 @@ def _compute_correlation( class LocalLearningRuleSearch(BaseMetric): """ Search for optimal local learning rules that approximate backprop. - + Tests multiple candidate local rules and identifies which best correlate with backprop gradients. - + Candidate rules: - Hebbian: Δw ∝ x * y - Anti-Hebbian: Δw ∝ x * (target - y) - Oja: Δw ∝ y * (x - y*w) - Contrastive: Δw ∝ x_pos * y_pos - x_neg * y_neg - Random feedback: Δw ∝ x * (B @ error) where B is random - + Returns the best rule per neuron. - + Example: >>> searcher = LocalLearningRuleSearch() >>> best_rules = searcher.compute( @@ -270,7 +270,7 @@ class LocalLearningRuleSearch(BaseMetric): >>> # best_rules[i] = index of best rule for neuron i >>> # Can then use this to train with local rules! """ - + def __init__( self, candidate_rules: Optional[List[str]] = None, @@ -278,29 +278,29 @@ def __init__( ): """ Initialize local learning rule search. - + Args: candidate_rules: List of rules to test (None = all) **config: Additional configuration """ super().__init__(**config) - + self.candidate_rules = candidate_rules or [ 'hebbian', 'anti_hebbian', 'oja', 'output', 'input' ] - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return False - + @property def requires_outputs(self) -> bool: return True - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -313,23 +313,23 @@ def compute( ) -> torch.Tensor: """ Find best local learning rule per neuron. - + Args: inputs, outputs: Layer activations gradients: Backprop gradients targets: For anti-Hebbian rules return_correlations: If True, return correlation matrix [N, num_rules] **kwargs: Additional args - + Returns: Best rule index per neuron [N] or correlations [N, num_rules] """ if gradients is None: raise ValueError("LocalLearningRuleSearch requires gradients") - + # Test all candidate rules correlations = {} - + for rule in self.candidate_rules: metric = GradientAlignment(local_signal=rule) corr = metric.compute( @@ -340,18 +340,18 @@ def compute( targets=targets ) correlations[rule] = corr - + # Stack into matrix [N, num_rules] corr_matrix = torch.stack([correlations[rule] for rule in self.candidate_rules], dim=1) - + if return_correlations: return corr_matrix - + # Find best rule per neuron best_rule_indices = corr_matrix.argmax(dim=1) # [N] - + return best_rule_indices - + def get_learning_rule_for_neuron( self, neuron_idx: int, @@ -364,27 +364,27 @@ def get_learning_rule_for_neuron( class GradientStatisticsTracker: """ Track gradient statistics during training for analysis. - + Collects: - Gradient magnitude evolution - Gradient direction consistency - Correlation with various local signals - + Use to design local learning rules. """ - + def __init__(self): """Initialize tracker.""" self.gradient_history: Dict[str, List[torch.Tensor]] = {} self.signal_history: Dict[str, List[torch.Tensor]] = {} self.correlation_history: Dict[str, List[float]] = {} - + def register_layer(self, layer_name: str): """Register a layer for tracking.""" self.gradient_history[layer_name] = [] self.signal_history[layer_name] = [] self.correlation_history[layer_name] = [] - + def update( self, layer_name: str, @@ -393,9 +393,9 @@ def update( ): """ Update statistics after a training step. - + Call this after loss.backward() but before optimizer.step() - + Args: layer_name: Name of layer gradient: Backprop gradient (layer.weight.grad) @@ -403,25 +403,25 @@ def update( """ if layer_name not in self.gradient_history: self.register_layer(layer_name) - + # Store self.gradient_history[layer_name].append(gradient.detach().cpu().clone()) self.signal_history[layer_name].append(local_signal.detach().cpu().clone()) - + # Compute correlation grad_flat = gradient.flatten() signal_flat = local_signal.flatten() - + corr = torch.corrcoef(torch.stack([grad_flat, signal_flat]))[0, 1] self.correlation_history[layer_name].append(corr.item()) - + def get_average_correlation(self, layer_name: str) -> float: """Get average correlation over training.""" if layer_name not in self.correlation_history: return 0.0 - + return sum(self.correlation_history[layer_name]) / len(self.correlation_history[layer_name]) - + def get_best_local_rule( self, layer_name: str, @@ -429,14 +429,14 @@ def get_best_local_rule( ) -> Tuple[str, float]: """ Determine which local rule best approximates backprop for this layer. - + Returns: (best_rule_name, correlation) """ # This would require testing multiple rules # For now, return based on accumulated correlation avg_corr = self.get_average_correlation(layer_name) - + return (self.local_signal if hasattr(self, 'local_signal') else 'hebbian', avg_corr) @@ -446,40 +446,40 @@ def design_local_learning_rule( ) -> Dict[str, Any]: """ Design optimal local learning rule for a layer based on gradient analysis. - + Args: gradient_tracker: Tracker that monitored training layer_name: Layer to design rule for - + Returns: Local learning rule specification """ # Analyze gradient statistics grad_history = gradient_tracker.gradient_history[layer_name] - + if not grad_history: return {'rule': 'hebbian', 'params': {}} - + # Compute gradient statistics avg_grad_magnitude = torch.stack([g.abs().mean() for g in grad_history]).mean() grad_direction_consistency = compute_direction_consistency(grad_history) - + # Design rule based on statistics if grad_direction_consistency > 0.8: # Consistent gradient → simple Hebbian works rule = 'hebbian' learning_rate = avg_grad_magnitude.item() * 0.1 - + elif grad_direction_consistency < 0.3: # Inconsistent → need more sophisticated rule rule = 'oja' learning_rate = avg_grad_magnitude.item() * 0.05 - + else: # Moderate → anti-Hebbian rule = 'anti_hebbian' learning_rate = avg_grad_magnitude.item() * 0.08 - + return { 'rule': rule, 'params': { @@ -492,27 +492,27 @@ def design_local_learning_rule( def compute_direction_consistency(gradient_history: List[torch.Tensor]) -> float: """ Compute how consistent gradient direction is across training. - + High consistency → gradient always points similar direction Low consistency → gradient changes direction frequently - + Returns: Consistency score [0, 1] """ if len(gradient_history) < 2: return 1.0 - + # Normalize gradients grads_normalized = [g / (g.norm() + 1e-8) for g in gradient_history] - + # Compute pairwise cosine similarities similarities = [] for i in range(len(grads_normalized) - 1): cos_sim = (grads_normalized[i] * grads_normalized[i+1]).sum() similarities.append(cos_sim.abs().item()) - + # Average similarity consistency = sum(similarities) / len(similarities) - + return consistency diff --git a/src/alignment/metrics/information/__init__.py b/src/alignment/metrics/information/__init__.py index 001b3d6f..d79acbd1 100644 --- a/src/alignment/metrics/information/__init__.py +++ b/src/alignment/metrics/information/__init__.py @@ -2,30 +2,23 @@ Information-theoretic metrics for neural network alignment. """ -from .mutual_information import ( - MutualInformationGaussian, - MutualInformationBinning, -) -from .redundancy import AverageRedundancy -from .pid import ( - SharedInformation, - UniqueInformationX, - UniqueInformationY, - SynergisticInformation as PIDSynergisticInformation, -) from .conditional_mutual_information import ConditionalMutualInformation -from .mi_projection import MIProjectionVsMeanInput from .gaussian_mi import GaussianMIAnalytic +from .mi_projection import MIProjectionVsMeanInput +from .mutual_information import MutualInformationBinning, MutualInformationGaussian from .pairwise_gaussian import PairwiseRedundancyGaussian +from .pid import SharedInformation, UniqueInformationX, UniqueInformationY +from .pid import SynergisticInformation as PIDSynergisticInformation +from .redundancy import AverageRedundancy from .synergy_mmi import SynergyGaussianMMI # Import higher-order metrics if available try: from .higher_order import ( - TotalCorrelation, - InteractionInformation, ConnectedInformation, + InteractionInformation, SynergisticInformation, + TotalCorrelation, ) _has_higher_order = True except ImportError: @@ -58,4 +51,4 @@ 'InteractionInformation', 'ConnectedInformation', 'SynergisticInformation', - ]) \ No newline at end of file + ]) diff --git a/src/alignment/metrics/information/conditional_mutual_information.py b/src/alignment/metrics/information/conditional_mutual_information.py index 28ca3a30..4b099261 100644 --- a/src/alignment/metrics/information/conditional_mutual_information.py +++ b/src/alignment/metrics/information/conditional_mutual_information.py @@ -2,10 +2,12 @@ Conditional mutual information metric for neural network analysis. """ -import torch -import numpy as np import logging from typing import Optional + +import numpy as np +import torch + from ...core.base import BaseMetric logger = logging.getLogger(__name__) @@ -17,26 +19,26 @@ class ConditionalMutualInformation(BaseMetric): - Y is the output of a neuron - Z is a reference signal (e.g., target outputs or mean of other neurons) - X is the input - + This measures how much information Y provides about Z beyond what X already provides. """ - + name = "conditional_mutual_information" requires_weights = False requires_inputs = True requires_outputs = True - + def __init__(self, bins: int = 10, use_gaussian: bool = False): """ Initialize the conditional MI metric. - + Args: bins: Number of bins for discretization (if not using Gaussian approximation) use_gaussian: Whether to use Gaussian approximation instead of binning """ self.bins = bins self.use_gaussian = use_gaussian - + @torch.no_grad() def compute( self, @@ -48,20 +50,20 @@ def compute( ) -> torch.Tensor: """ Compute conditional MI scores for each neuron. - + Args: inputs: Input activations [batch_size, num_input_features] weights: Not used outputs: Output activations [batch_size, num_neurons] target_outputs: Reference signal [batch_size, num_targets] (optional) **kwargs: Additional arguments - + Returns: CMI scores per neuron [num_neurons] """ if inputs is None or outputs is None: raise ValueError("Conditional MI requires both inputs and outputs") - + # Handle dimensions if inputs.ndim != 2: if inputs.ndim > 2: @@ -69,17 +71,17 @@ def compute( else: logger.warning(f"Inputs have unexpected shape: {inputs.shape}") return torch.zeros(1, device=outputs.device) - + if outputs.ndim != 2: logger.warning(f"Outputs have unexpected shape: {outputs.shape}") return torch.zeros(1, device=outputs.device) - + batch_size, num_neurons = outputs.shape - + if batch_size < 10: # Need enough samples for estimation logger.warning(f"Too few samples for CMI estimation: {batch_size}") return torch.zeros(num_neurons, device=outputs.device) - + # Use first principal component of inputs as conditioning variable if inputs.shape[1] > 1: # Simple PCA approximation using SVD @@ -88,7 +90,7 @@ def compute( X = torch.matmul(inputs_centered, V[0:1, :].T) # First PC else: X = inputs - + # Determine reference signal Z if target_outputs is not None: Z = target_outputs @@ -97,12 +99,12 @@ def compute( else: # Use mean of other neurons as reference Z = None - + if self.use_gaussian: return self._compute_gaussian(X, outputs, Z) else: return self._compute_binning(X, outputs, Z) - + def _compute_gaussian( self, X: torch.Tensor, @@ -112,11 +114,11 @@ def _compute_gaussian( """Compute CMI using Gaussian approximation.""" num_neurons = Y.shape[1] cmi_scores = torch.zeros(num_neurons, device=Y.device) - + for i in range(num_neurons): # Current neuron output y_i = Y[:, i:i+1] - + # Reference signal if Z is not None: z = Z @@ -128,50 +130,50 @@ def _compute_gaussian( z = Y[:, mask].mean(dim=1, keepdim=True) else: continue - + try: # Compute I(Y;Z|X) = H(Y|X) + H(Z|X) - H(Y,Z|X) # Under Gaussian assumption, we can compute this from covariances - + # Stack variables x_flat = X.reshape(X.shape[0], -1) xyz = torch.cat([x_flat, y_i, z], dim=1) - + # Compute covariance matrix cov = self._covariance(xyz) - + # Indices n_x = x_flat.shape[1] idx_x = slice(0, n_x) idx_y = n_x - idx_z = slice(n_x + 1, cov.shape[0]) - + slice(n_x + 1, cov.shape[0]) + # Compute conditional entropies using Schur complement # H(Y|X) cov_x = cov[idx_x, idx_x] cov_y = cov[idx_y, idx_y] cov_xy = cov[idx_x, idx_y] - + if torch.linalg.matrix_rank(cov_x) == cov_x.shape[0]: cov_y_given_x = cov_y - cov_xy.T @ torch.linalg.inv(cov_x) @ cov_xy - h_y_given_x = 0.5 * torch.log(2 * np.pi * np.e * torch.clamp(cov_y_given_x, min=1e-10)) + 0.5 * torch.log(2 * np.pi * np.e * torch.clamp(cov_y_given_x, min=1e-10)) else: - h_y_given_x = 0.5 * torch.log(2 * np.pi * np.e * torch.clamp(cov_y, min=1e-10)) - + 0.5 * torch.log(2 * np.pi * np.e * torch.clamp(cov_y, min=1e-10)) + # Similar for H(Z|X) and H(Y,Z|X) # Simplified: just use correlation-based approximation corr_yz_given_x = self._partial_correlation(y_i, z, x_flat) - + # Approximate CMI if torch.abs(corr_yz_given_x) < 0.999: cmi_scores[i] = -0.5 * torch.log(1 - corr_yz_given_x**2) - + except Exception as e: logger.debug(f"Error computing Gaussian CMI for neuron {i}: {e}") continue - + return torch.nan_to_num(cmi_scores, nan=0.0) - + def _compute_binning( self, X: torch.Tensor, @@ -181,18 +183,18 @@ def _compute_binning( """Compute CMI using binning/discretization.""" num_neurons = Y.shape[1] cmi_scores = torch.zeros(num_neurons, device=Y.device) - + # Convert to numpy for binning X_np = X.cpu().numpy() Y_np = Y.cpu().numpy() - + # Discretize X X_discrete = self._discretize(X_np) - + for i in range(num_neurons): # Current neuron output y_i_np = Y_np[:, i] - + # Reference signal if Z is not None: z_np = Z.cpu().numpy() @@ -206,31 +208,31 @@ def _compute_binning( z_np = Y_np[:, mask].mean(axis=1) else: continue - + # Discretize Y and Z y_discrete = self._discretize(y_i_np) z_discrete = self._discretize(z_np) - + try: # Compute CMI using chain rule: # I(Y;Z|X) = I(Y;Z,X) - I(Y;X) mi_yzx = self._mutual_information_3way(y_discrete, z_discrete, X_discrete) mi_yx = self._mutual_information(y_discrete, X_discrete) - + cmi = mi_yzx - mi_yx cmi_scores[i] = max(0, cmi) # CMI should be non-negative - + except Exception as e: logger.debug(f"Error computing binned CMI for neuron {i}: {e}") continue - + return cmi_scores.to(Y.device) - + def _covariance(self, X: torch.Tensor) -> torch.Tensor: """Compute covariance matrix.""" X_centered = X - X.mean(dim=0, keepdim=True) return torch.matmul(X_centered.T, X_centered) / (X.shape[0] - 1) - + def _partial_correlation( self, Y: torch.Tensor, @@ -240,17 +242,17 @@ def _partial_correlation( """Compute partial correlation between Y and Z given X.""" # Stack all variables all_vars = torch.cat([Y, Z, X], dim=1) - + # Compute correlation matrix corr = self._correlation_matrix(all_vars) - + # Extract submatrices # corr = [[r_yy, r_yz, r_yx], # [r_zy, r_zz, r_zx], # [r_xy, r_xz, r_xx]] - + n_x = X.shape[1] - + # Partial correlation formula try: # Get relevant correlations @@ -258,16 +260,16 @@ def _partial_correlation( r_yx = corr[0, 2:2+n_x] r_zx = corr[1, 2:2+n_x] R_xx = corr[2:2+n_x, 2:2+n_x] - + # Compute partial correlation if torch.linalg.matrix_rank(R_xx) == R_xx.shape[0]: R_xx_inv = torch.linalg.inv(R_xx) partial_corr = r_yz - r_yx @ R_xx_inv @ r_zx - + # Normalize var_y_given_x = 1 - r_yx @ R_xx_inv @ r_yx var_z_given_x = 1 - r_zx @ R_xx_inv @ r_zx - + if var_y_given_x > 1e-10 and var_z_given_x > 1e-10: partial_corr = partial_corr / torch.sqrt(var_y_given_x * var_z_given_x) else: @@ -275,53 +277,53 @@ def _partial_correlation( else: # Fallback to simple correlation partial_corr = r_yz - + except Exception: # Fallback to simple correlation partial_corr = corr[0, 1] - + return torch.clamp(partial_corr, -1.0, 1.0) - + def _correlation_matrix(self, X: torch.Tensor) -> torch.Tensor: """Compute correlation matrix.""" cov = self._covariance(X) std = torch.sqrt(torch.diag(cov) + 1e-10) outer_std = torch.outer(std, std) return torch.where(outer_std > 1e-10, cov / outer_std, torch.zeros_like(cov)) - + def _discretize(self, x: np.ndarray) -> np.ndarray: """Discretize continuous values into bins.""" min_val, max_val = np.min(x), np.max(x) - + if max_val - min_val < 1e-10: return np.zeros_like(x, dtype=int) - + bins = np.linspace(min_val, max_val, self.bins + 1) digitized = np.digitize(x, bins[:-1]) - 1 return np.clip(digitized, 0, self.bins - 1) - + def _mutual_information(self, x: np.ndarray, y: np.ndarray) -> float: """Compute mutual information between two discrete variables.""" # Joint probability joint_hist = np.zeros((self.bins, self.bins)) for i in range(len(x)): joint_hist[x[i], y[i]] += 1 - + joint_prob = joint_hist / len(x) - + # Marginal probabilities p_x = joint_prob.sum(axis=1) p_y = joint_prob.sum(axis=0) - + # Compute MI mi = 0.0 for i in range(self.bins): for j in range(self.bins): if joint_prob[i, j] > 1e-10 and p_x[i] > 1e-10 and p_y[j] > 1e-10: mi += joint_prob[i, j] * np.log(joint_prob[i, j] / (p_x[i] * p_y[j])) - + return mi / np.log(2) # Convert to bits - + def _mutual_information_3way( self, y: np.ndarray, @@ -331,19 +333,19 @@ def _mutual_information_3way( """Compute I(Y;Z,X) = I(Y;(Z,X)) treating (Z,X) as a joint variable.""" # Create joint variable for (Z,X) zx_joint = z * self.bins + x # Simple encoding - + # Ensure we don't exceed the range max_val = self.bins * self.bins - 1 zx_joint = np.clip(zx_joint, 0, max_val) - + # Compute MI between Y and the joint variable # Need to adjust bins for the joint variable original_bins = self.bins self.bins = self.bins * self.bins - + mi = self._mutual_information(y, zx_joint) - + # Restore original bins self.bins = original_bins - - return mi \ No newline at end of file + + return mi diff --git a/src/alignment/metrics/information/gaussian_mi.py b/src/alignment/metrics/information/gaussian_mi.py index fab1538d..1b2e812f 100644 --- a/src/alignment/metrics/information/gaussian_mi.py +++ b/src/alignment/metrics/information/gaussian_mi.py @@ -5,32 +5,32 @@ Gaussian distributions, with Edgeworth expansions for higher-order corrections. """ +from typing import Dict, Optional + import torch -import torch.nn as nn -from typing import Optional, Dict, Any, Tuple -import numpy as np -from ...core.registry import register_metric + from ...core.base import BaseMetric +from ...core.registry import register_metric @register_metric("gaussian_mi_analytic") class GaussianMIAnalytic(BaseMetric): """ Compute mutual information between input and output assuming Gaussian distributions. - + For linear transformations Y = WX + ε where X and ε are Gaussian: - I(X;Y) = 1/2 * log(det(Σ_Y) / det(Σ_Y|X)) - + With Edgeworth expansion for non-Gaussian corrections up to specified order. """ - + name = "gaussian_mi_analytic" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( - self, + self, expansion_order: int = 2, noise_std: float = 0.1, regularization: float = 1e-6, @@ -39,7 +39,7 @@ def __init__( ): """ Initialize Gaussian MI metric with expansion. - + Args: expansion_order: Order of Edgeworth expansion (0=pure Gaussian, 1-3 for corrections) noise_std: Assumed noise standard deviation @@ -52,11 +52,11 @@ def __init__( self.regularization = regularization self.per_neuron = per_neuron self.use_entropy_edgeworth = use_entropy_edgeworth - + def _compute_cumulants(self, data: torch.Tensor, max_order: int = 4) -> Dict[int, torch.Tensor]: """ Compute cumulants up to specified order. - + For centered data: - κ₁ = 0 (mean) - κ₂ = variance @@ -65,22 +65,22 @@ def _compute_cumulants(self, data: torch.Tensor, max_order: int = 4) -> Dict[int """ # Center the data data_centered = data - data.mean(dim=0, keepdim=True) - n_samples = data.shape[0] - + data.shape[0] + cumulants = {} - + # Second cumulant (variance) cumulants[2] = torch.var(data_centered, dim=0, unbiased=True) - + if max_order >= 3: # Third cumulant (related to skewness) cumulants[3] = torch.mean(data_centered ** 3, dim=0) - + if max_order >= 4: # Fourth cumulant (related to kurtosis) fourth_moment = torch.mean(data_centered ** 4, dim=0) cumulants[4] = fourth_moment - 3 * cumulants[2] ** 2 - + return cumulants def _univariate_entropy_edgeworth(self, data: torch.Tensor, eps: float = 1e-12) -> torch.Tensor: @@ -103,48 +103,48 @@ def _univariate_entropy_edgeworth(self, data: torch.Tensor, eps: float = 1e-12) h_gauss = 0.5 * torch.log(2 * torch.pi * torch.e * var) corr = - (gamma1 ** 2) / 12.0 - (gamma2 ** 2) / 48.0 return h_gauss + corr - + def _gaussian_mi( - self, - cov_x: torch.Tensor, - cov_y: torch.Tensor, + self, + cov_x: torch.Tensor, + cov_y: torch.Tensor, cov_xy: torch.Tensor ) -> torch.Tensor: """ Compute Gaussian mutual information. - + I(X;Y) = 1/2 * log(det(Σ_X) * det(Σ_Y) / det(Σ)) where Σ is the joint covariance matrix. """ # Construct joint covariance matrix n_x = cov_x.shape[0] n_y = cov_y.shape[0] - + joint_cov = torch.zeros(n_x + n_y, n_x + n_y, device=cov_x.device) joint_cov[:n_x, :n_x] = cov_x joint_cov[n_x:, n_x:] = cov_y joint_cov[:n_x, n_x:] = cov_xy joint_cov[n_x:, :n_x] = cov_xy.T - + # Add regularization for numerical stability reg_eye_x = self.regularization * torch.eye(n_x, device=cov_x.device) reg_eye_y = self.regularization * torch.eye(n_y, device=cov_y.device) reg_eye_joint = self.regularization * torch.eye(n_x + n_y, device=joint_cov.device) - + cov_x = cov_x + reg_eye_x cov_y = cov_y + reg_eye_y joint_cov = joint_cov + reg_eye_joint - + # Compute determinants det_x = torch.linalg.det(cov_x) det_y = torch.linalg.det(cov_y) det_joint = torch.linalg.det(joint_cov) - + # Mutual information mi = 0.5 * torch.log(det_x * det_y / det_joint) - + return mi - + def _edgeworth_correction( self, cumulants_x: Dict[int, torch.Tensor], @@ -154,12 +154,12 @@ def _edgeworth_correction( ) -> torch.Tensor: """ Compute Edgeworth expansion corrections to mutual information. - + The corrections involve higher-order cumulants and capture deviations from Gaussianity. """ correction = 0.0 - + if order >= 1 and 3 in cumulants_x and 3 in cumulants_y: # First-order correction involves third cumulants (skewness) # This is a simplified approximation @@ -167,34 +167,34 @@ def _edgeworth_correction( kappa3_y = cumulants_y[3].item() if hasattr(cumulants_y[3], 'item') else cumulants_y[3] var_x = cumulants_x[2].mean() if cumulants_x[2].numel() > 1 else cumulants_x[2].item() var_y = cumulants_y[2].item() if hasattr(cumulants_y[2], 'item') else cumulants_y[2] - + # Normalized third cumulants gamma1_x = kappa3_x / (var_x ** 1.5 + 1e-8) gamma1_y = kappa3_y / (var_y ** 1.5 + 1e-8) - + # Correlation coefficient (cov_xy is already scalar) corr = cov_xy / (torch.sqrt(torch.tensor(var_x * var_y)) + 1e-8) - + # First-order correction (simplified form) correction += (1/6) * corr * gamma1_x * gamma1_y - + if order >= 2 and 4 in cumulants_x and 4 in cumulants_y: # Second-order correction involves fourth cumulants (kurtosis) kappa4_x = cumulants_x[4].mean() if cumulants_x[4].numel() > 1 else cumulants_x[4].item() kappa4_y = cumulants_y[4].item() if hasattr(cumulants_y[4], 'item') else cumulants_y[4] var_x = cumulants_x[2].mean() if cumulants_x[2].numel() > 1 else cumulants_x[2].item() var_y = cumulants_y[2].item() if hasattr(cumulants_y[2], 'item') else cumulants_y[2] - + # Normalized fourth cumulants (excess kurtosis) gamma2_x = kappa4_x / (var_x ** 2 + 1e-8) gamma2_y = kappa4_y / (var_y ** 2 + 1e-8) - + # Correlation coefficient corr = cov_xy / (torch.sqrt(torch.tensor(var_x * var_y)) + 1e-8) - + # Second-order correction (simplified form) correction += (1/24) * (corr ** 2) * (gamma2_x + gamma2_y) - + if 3 in cumulants_x and 3 in cumulants_y: # Mixed term involving third cumulants kappa3_x = cumulants_x[3].mean() if cumulants_x[3].numel() > 1 else cumulants_x[3].item() @@ -202,7 +202,7 @@ def _edgeworth_correction( gamma1_x = kappa3_x / (var_x ** 1.5 + 1e-8) gamma1_y = kappa3_y / (var_y ** 1.5 + 1e-8) correction += (1/72) * (gamma1_x ** 2 + gamma1_y ** 2) - + if order >= 3: # Third-order corrections become quite complex # Here we provide a simplified version @@ -210,26 +210,26 @@ def _edgeworth_correction( var_x = cumulants_x[2].mean() if cumulants_x[2].numel() > 1 else cumulants_x[2].item() var_y = cumulants_y[2].item() if hasattr(cumulants_y[2], 'item') else cumulants_y[2] corr = cov_xy / (torch.sqrt(torch.tensor(var_x * var_y)) + 1e-8) - + kappa3_x = cumulants_x[3].mean() if cumulants_x[3].numel() > 1 else cumulants_x[3].item() kappa3_y = cumulants_y[3].item() if hasattr(cumulants_y[3], 'item') else cumulants_y[3] kappa4_x = cumulants_x[4].mean() if cumulants_x[4].numel() > 1 else cumulants_x[4].item() kappa4_y = cumulants_y[4].item() if hasattr(cumulants_y[4], 'item') else cumulants_y[4] - + gamma1_x = kappa3_x / (var_x ** 1.5 + 1e-8) gamma1_y = kappa3_y / (var_y ** 1.5 + 1e-8) gamma2_x = kappa4_x / (var_x ** 2 + 1e-8) gamma2_y = kappa4_y / (var_y ** 2 + 1e-8) - + # Third-order correction (highly simplified) correction += (1/144) * corr * (gamma1_x * gamma2_y + gamma1_y * gamma2_x) - + # Convert to scalar if needed if hasattr(correction, 'item'): correction = correction.item() - + return correction - + def compute( self, inputs: torch.Tensor, @@ -239,18 +239,18 @@ def compute( ) -> torch.Tensor: """ Compute Gaussian MI with non-Gaussian corrections. - + Args: inputs: Input activations [batch_size, input_dim] weights: Weight matrix [output_dim, input_dim] outputs: Output activations (computed if not provided) - + Returns: MI scores for each neuron [output_dim] or single score """ batch_size, input_dim = inputs.shape output_dim = weights.shape[0] - + # Compute outputs if not provided if outputs is None: outputs = inputs @ weights.T @@ -258,25 +258,25 @@ def compute( if self.noise_std > 0: noise = torch.randn_like(outputs) * self.noise_std outputs = outputs + noise - + if self.per_neuron: # Compute MI for each output neuron separately mi_scores = torch.zeros(output_dim, device=inputs.device) - + # Compute input statistics once inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) cov_x = (inputs_centered.T @ inputs_centered) / (batch_size - 1) cumulants_x = self._compute_cumulants(inputs, self.expansion_order + 2) - + for i in range(output_dim): # Get single output y = outputs[:, i].unsqueeze(1) y_centered = y - y.mean(dim=0, keepdim=True) - + # Compute covariances cov_y = (y_centered.T @ y_centered) / (batch_size - 1) cov_xy = (inputs_centered.T @ y_centered) / (batch_size - 1) - + # Gaussian MI baseline mi_gaussian = self._gaussian_mi(cov_x, cov_y, cov_xy) @@ -299,27 +299,27 @@ def compute( mi_scores[i] = mi_edge else: mi_scores[i] = mi_gaussian - + return mi_scores - + else: # Compute joint MI between all inputs and all outputs inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) outputs_centered = outputs - outputs.mean(dim=0, keepdim=True) - + # Compute covariances cov_x = (inputs_centered.T @ inputs_centered) / (batch_size - 1) cov_y = (outputs_centered.T @ outputs_centered) / (batch_size - 1) cov_xy = (inputs_centered.T @ outputs_centered) / (batch_size - 1) - + # Gaussian MI mi_gaussian = self._gaussian_mi(cov_x, cov_y, cov_xy) - + # Add corrections if requested if self.expansion_order > 0: cumulants_x = self._compute_cumulants(inputs, self.expansion_order + 2) - cumulants_y = self._compute_cumulants(outputs, self.expansion_order + 2) - + self._compute_cumulants(outputs, self.expansion_order + 2) + # For joint MI, we need to handle the corrections differently # Here we provide a simplified scalar correction avg_correction = 0.0 @@ -327,16 +327,16 @@ def compute( y_single = outputs[:, i].unsqueeze(1) cumulants_y_single = self._compute_cumulants(y_single, self.expansion_order + 2) cov_xy_single = cov_xy[:, i].unsqueeze(1) - + correction = self._edgeworth_correction( cumulants_x, cumulants_y_single, cov_xy_single.squeeze(), self.expansion_order ) avg_correction += correction - + avg_correction = avg_correction / output_dim total_mi = mi_gaussian + avg_correction else: total_mi = mi_gaussian - + # Return same value for all neurons - return torch.full((output_dim,), total_mi.item(), device=inputs.device) \ No newline at end of file + return torch.full((output_dim,), total_mi.item(), device=inputs.device) diff --git a/src/alignment/metrics/information/gaussian_pid.py b/src/alignment/metrics/information/gaussian_pid.py index 7a262f92..0b8bd89e 100644 --- a/src/alignment/metrics/information/gaussian_pid.py +++ b/src/alignment/metrics/information/gaussian_pid.py @@ -8,9 +8,10 @@ Returns average synergy per neuron over sampled partners j. """ -from typing import Optional, Any -import torch import logging +from typing import Any, Optional + +import torch from ...core.base import BaseMetric from ...core.registry import register_metric @@ -139,7 +140,6 @@ def partner_indices(i: int) -> torch.Tensor: # Compute synergy per i averaged over j in partners synergy = torch.zeros(N, device=device) - eps = 1e-12 if d == 1: z = Z.view(B) for i in range(N): diff --git a/src/alignment/metrics/information/gpu_binning.py b/src/alignment/metrics/information/gpu_binning.py index 48aea878..9c28a356 100644 --- a/src/alignment/metrics/information/gpu_binning.py +++ b/src/alignment/metrics/information/gpu_binning.py @@ -1,27 +1,26 @@ +from typing import Optional, Tuple, Union + import torch -import torch.nn as nn -from typing import Tuple, Optional, Union, List -import math class GPUBinning: """GPU-accelerated binning operations using PyTorch operations.""" - + def __init__(self, device: str = 'cuda'): """ Initialize GPU binning. - + Args: device: Device to use ('cuda' or 'cpu') """ self.device = torch.device(device if torch.cuda.is_available() else 'cpu') - + @staticmethod @torch.jit.script - def _compute_bin_indices_1d(data: torch.Tensor, - min_val: float, - max_val: float, + def _compute_bin_indices_1d(data: torch.Tensor, + min_val: float, + max_val: float, n_bins: int) -> torch.Tensor: """JIT-compiled function to compute 1D bin indices.""" # Normalize to [0, 1] @@ -31,112 +30,111 @@ def _compute_bin_indices_1d(data: torch.Tensor, # Convert to bin indices indices = (normalized * n_bins).long() return indices - - def fast_histogram_1d(self, - data: torch.Tensor, + + def fast_histogram_1d(self, + data: torch.Tensor, n_bins: int = 256, range_min: Optional[float] = None, range_max: Optional[float] = None) -> torch.Tensor: """ Compute 1D histogram on GPU. - + Args: data: Input tensor (will be flattened) n_bins: Number of bins range_min: Minimum value (computed from data if None) range_max: Maximum value (computed from data if None) - + Returns: Histogram counts """ data = data.to(self.device).flatten() - + # Determine range if range_min is None: range_min = data.min().item() if range_max is None: range_max = data.max().item() - + # Compute bin indices indices = self._compute_bin_indices_1d(data, range_min, range_max, n_bins) - + # Count using scatter_add hist = torch.zeros(n_bins, dtype=torch.float32, device=self.device) ones = torch.ones_like(indices, dtype=torch.float32) hist.scatter_add_(0, indices, ones) - + return hist - + def fast_histogram_2d(self, data_x: torch.Tensor, data_y: torch.Tensor, n_bins: Union[int, Tuple[int, int]] = 64) -> torch.Tensor: """ Compute 2D histogram on GPU. - + Args: data_x: First dimension data data_y: Second dimension data n_bins: Number of bins (single value or tuple for each dimension) - + Returns: 2D histogram """ data_x = data_x.to(self.device).flatten() data_y = data_y.to(self.device).flatten() - + if isinstance(n_bins, int): n_bins_x = n_bins_y = n_bins else: n_bins_x, n_bins_y = n_bins - + # Compute ranges x_min, x_max = data_x.min().item(), data_x.max().item() y_min, y_max = data_y.min().item(), data_y.max().item() - + # Compute bin indices x_indices = self._compute_bin_indices_1d(data_x, x_min, x_max, n_bins_x) y_indices = self._compute_bin_indices_1d(data_y, y_min, y_max, n_bins_y) - + # Convert to linear indices linear_indices = y_indices * n_bins_x + x_indices - + # Count hist = torch.zeros(n_bins_x * n_bins_y, dtype=torch.float32, device=self.device) ones = torch.ones_like(linear_indices, dtype=torch.float32) hist.scatter_add_(0, linear_indices, ones) - + return hist.view(n_bins_y, n_bins_x) - + def mutual_information_gpu(self, x: torch.Tensor, y: torch.Tensor, n_bins: int = 64) -> float: """ Compute mutual information using GPU-accelerated binning. - + Args: x: First variable y: Second variable n_bins: Number of bins - + Returns: Mutual information estimate """ # Compute 2D histogram joint_hist = self.fast_histogram_2d(x, y, n_bins) joint_hist = joint_hist + 1e-10 # Avoid log(0) - + # Normalize to get joint probability joint_prob = joint_hist / joint_hist.sum() - + # Marginal probabilities p_x = joint_prob.sum(dim=0) p_y = joint_prob.sum(dim=1) - + # Compute MI log_ratio = torch.log(joint_prob / (p_x.unsqueeze(0) * p_y.unsqueeze(1))) mi = (joint_prob * log_ratio).sum().item() - + return float(mi) - \ No newline at end of file diff --git a/src/alignment/metrics/information/higher_order.py b/src/alignment/metrics/information/higher_order.py index 4b0ed818..ac2038a0 100644 --- a/src/alignment/metrics/information/higher_order.py +++ b/src/alignment/metrics/information/higher_order.py @@ -4,28 +4,29 @@ These metrics capture complex multivariate dependencies beyond pairwise relationships. """ -import torch -import torch.nn as nn -from typing import Optional, Dict, Any, List, Tuple +from typing import List, Optional + import numpy as np -from ...core.registry import register_metric +import torch + from ...core.base import BaseMetric +from ...core.registry import register_metric @register_metric("total_correlation") class TotalCorrelation(BaseMetric): """ Measures total correlation (multi-information) among variables. - + Total correlation quantifies the amount of dependency among a set of random variables. It's the KL divergence between the joint distribution and the product of marginal distributions. - + TC(X1, ..., Xn) = sum(H(Xi)) - H(X1, ..., Xn) """ - + name = "total_correlation" - + def __init__(self, n_bins: int = 30, normalize: bool = True): """ Args: @@ -35,12 +36,12 @@ def __init__(self, n_bins: int = 30, normalize: bool = True): super().__init__() self.n_bins = n_bins self.normalize = normalize - + def _estimate_entropy(self, data: torch.Tensor) -> float: """Estimate entropy using histogram method.""" # Discretize continuous data data_np = data.detach().cpu().numpy() - + if data.dim() == 1: # Single variable entropy hist, _ = np.histogram(data_np, bins=self.n_bins) @@ -50,13 +51,13 @@ def _estimate_entropy(self, data: torch.Tensor) -> float: else: # Joint entropy for multiple variables # Use multi-dimensional histogram - ranges = [(data_np[:, i].min(), data_np[:, i].max()) + ranges = [(data_np[:, i].min(), data_np[:, i].max()) for i in range(data_np.shape[1])] hist, _ = np.histogramdd(data_np, bins=self.n_bins, range=ranges) hist = hist.flatten() + 1e-10 hist = hist / hist.sum() return -np.sum(hist * np.log(hist)) - + def compute(self, inputs: Optional[torch.Tensor] = None, weights: Optional[torch.Tensor] = None, @@ -64,29 +65,29 @@ def compute(self, """Compute total correlation.""" if outputs is None: raise ValueError("Outputs required for total correlation") - + if outputs.dim() == 1: outputs = outputs.unsqueeze(1) - + n_vars = outputs.size(1) if n_vars < 2: return 0.0 # No correlation for single variable - + # Compute marginal entropies marginal_entropies = [] for i in range(n_vars): H_i = self._estimate_entropy(outputs[:, i]) marginal_entropies.append(H_i) - + # Compute joint entropy joint_entropy = self._estimate_entropy(outputs) - + # Total correlation tc = sum(marginal_entropies) - joint_entropy - + if self.normalize: tc = tc / n_vars - + return float(tc) @@ -94,17 +95,17 @@ def compute(self, class InteractionInformation(BaseMetric): """ Compute interaction information (co-information) among triplets of variables. - + This measures the amount of information that is present only when all three variables are considered together. """ - + name = "interaction_information" - + def __init__(self, n_samples: int = 100, bins: int = 10): """ Initialize interaction information metric. - + Args: n_samples: Number of triplet samples to evaluate bins: Number of bins for discretization @@ -112,7 +113,7 @@ def __init__(self, n_samples: int = 100, bins: int = 10): super().__init__() self.n_samples = n_samples self.bins = bins - + def compute( self, inputs: torch.Tensor, @@ -122,34 +123,34 @@ def compute( ) -> torch.Tensor: """ Compute interaction information scores. - + Args: inputs: Input activations weights: Weight matrix outputs: Output activations - + Returns: Interaction information scores for each neuron """ if outputs is None: outputs = inputs @ weights.T - + n_neurons = outputs.shape[1] interaction_scores = torch.zeros(n_neurons, device=outputs.device) - + # Sample triplets of neurons n_triplets = min(self.n_samples, n_neurons * (n_neurons - 1) * (n_neurons - 2) // 6) - + for _ in range(n_triplets): # Randomly select 3 different neurons idx = torch.randperm(n_neurons)[:3] i, j, k = idx[0], idx[1], idx[2] - + # Get activations for these neurons X = outputs[:, i] Y = outputs[:, j] Z = outputs[:, k] - + # Compute pairwise mutual information using histogram method MI_XY = self._estimate_mi_binning( X.unsqueeze(1), Y.unsqueeze(1) @@ -160,60 +161,60 @@ def compute( MI_YZ = self._estimate_mi_binning( Y.unsqueeze(1), Z.unsqueeze(1) ) - + # Compute conditional mutual information I(X;Y|Z) # Using approximation: I(X;Y|Z) ≈ I(X;Y) - I(X;Y;Z) # Where I(X;Y;Z) is the interaction information - + # For simplicity, we'll use the difference of mutual informations # as an approximation of interaction information interaction = MI_XY + MI_XZ + MI_YZ - + # Distribute score to participating neurons interaction_scores[i] += interaction / 3 interaction_scores[j] += interaction / 3 interaction_scores[k] += interaction / 3 - + # Normalize by number of samples interaction_scores = interaction_scores / n_triplets - + return interaction_scores - + def _estimate_mi_binning(self, x: torch.Tensor, y: torch.Tensor) -> float: """Estimate mutual information using binning method.""" x_np = x.detach().cpu().numpy() y_np = y.detach().cpu().numpy() - + # Create 2D histogram hist, _, _ = np.histogram2d(x_np.flatten(), y_np.flatten(), bins=self.bins) hist = hist + 1e-10 # Avoid log(0) - + # Normalize to get probabilities pxy = hist / hist.sum() px = pxy.sum(axis=1) py = pxy.sum(axis=0) - + # Compute MI mi = 0.0 for i in range(len(px)): for j in range(len(py)): if pxy[i, j] > 0: mi += pxy[i, j] * np.log(pxy[i, j] / (px[i] * py[j])) - + return mi -@register_metric("connected_information") +@register_metric("connected_information") class ConnectedInformation(BaseMetric): """ Measures connected information (interaction information of order n). - + This captures pure n-way interactions that cannot be reduced to lower-order interactions, useful for understanding complex dependencies in neural networks. """ - + name = "connected_information" - + def __init__(self, n_bins: int = 20, max_order: int = 4): """ Args: @@ -224,19 +225,19 @@ def __init__(self, n_bins: int = 20, max_order: int = 4): self.n_bins = n_bins self.max_order = max_order self._entropy_est = TotalCorrelation(n_bins=n_bins) - + def _compute_interaction_info(self, data: torch.Tensor, indices: List[int]) -> float: """Compute interaction information for a subset of variables.""" if len(indices) < 2: return 0.0 - + # Use inclusion-exclusion principle total = 0.0 n = len(indices) - + # Generate all non-empty subsets from itertools import combinations - + for k in range(1, n + 1): sign = (-1) ** (n - k) for subset in combinations(indices, k): @@ -246,9 +247,9 @@ def _compute_interaction_info(self, data: torch.Tensor, indices: List[int]) -> f else: entropy = self._entropy_est._estimate_entropy(subset_data) total += sign * entropy - + return total - + def compute(self, inputs: Optional[torch.Tensor] = None, weights: Optional[torch.Tensor] = None, @@ -256,30 +257,30 @@ def compute(self, """Compute connected information up to max_order.""" if outputs is None: raise ValueError("Outputs required for connected information") - + if outputs.dim() == 1: outputs = outputs.unsqueeze(1) - + n_vars = outputs.size(1) if n_vars < 2: return 0.0 - + # Compute interaction information for different orders total_connected = 0.0 - + from itertools import combinations for order in range(2, min(n_vars + 1, self.max_order + 1)): for var_subset in combinations(range(n_vars), order): interaction = self._compute_interaction_info(outputs, list(var_subset)) total_connected += abs(interaction) # Use absolute value - + # Normalize by number of possible interactions n_interactions = sum(1 for order in range(2, min(n_vars + 1, self.max_order + 1)) for _ in combinations(range(n_vars), order)) - + if n_interactions > 0: total_connected /= n_interactions - + return float(total_connected) @@ -289,13 +290,13 @@ class SynergisticInformation(BaseMetric): Compute synergistic information - information that can only be obtained from the joint state of multiple neurons. """ - + name = "synergistic_information" - + def __init__(self, group_size: int = 3, n_groups: int = 50): """ Initialize synergistic information metric. - + Args: group_size: Size of neuron groups to analyze n_groups: Number of random groups to sample @@ -303,7 +304,7 @@ def __init__(self, group_size: int = 3, n_groups: int = 50): super().__init__() self.group_size = group_size self.n_groups = n_groups - + def compute( self, inputs: torch.Tensor, @@ -313,55 +314,55 @@ def compute( ) -> torch.Tensor: """ Compute synergistic information scores. - + Args: inputs: Input activations weights: Weight matrix outputs: Output activations - + Returns: Synergistic information scores for each neuron """ if outputs is None: outputs = inputs @ weights.T - + batch_size, n_neurons = outputs.shape synergy_scores = torch.zeros(n_neurons, device=outputs.device) - + # Sample groups of neurons n_groups_actual = min(self.n_groups, n_neurons // self.group_size) - + for _ in range(n_groups_actual): # Select a random group idx = torch.randperm(n_neurons)[:self.group_size] group_outputs = outputs[:, idx] - + # Compute joint entropy of the group # Using Gaussian assumption for efficiency group_centered = group_outputs - group_outputs.mean(dim=0, keepdim=True) cov_group = (group_centered.T @ group_centered) / (batch_size - 1) cov_group = cov_group + 1e-8 * torch.eye(self.group_size, device=outputs.device) - + # Joint entropy under Gaussian assumption # H = 0.5 * log(det(2πe * Σ)) det_cov = torch.linalg.det(cov_group) joint_entropy = 0.5 * torch.log(2 * np.pi * np.e * det_cov) - + # Compute sum of individual entropies individual_entropies = 0 for i in range(self.group_size): var = cov_group[i, i] individual_entropies += 0.5 * torch.log(2 * np.pi * np.e * var) - + # Synergy approximation: joint entropy - sum of individual entropies # (negative of this gives redundancy, positive gives synergy) synergy = joint_entropy - individual_entropies - + # Distribute score to participating neurons for i in idx: synergy_scores[i] += synergy / self.group_size - + # Normalize by number of groups synergy_scores = synergy_scores / n_groups_actual - - return synergy_scores \ No newline at end of file + + return synergy_scores diff --git a/src/alignment/metrics/information/mi_projection.py b/src/alignment/metrics/information/mi_projection.py index 4b08a281..bea997c5 100644 --- a/src/alignment/metrics/information/mi_projection.py +++ b/src/alignment/metrics/information/mi_projection.py @@ -2,10 +2,12 @@ Mutual information between neuron projections and mean input. """ -import torch -import numpy as np import logging from typing import Optional + +import numpy as np +import torch + from ...core.base import BaseMetric logger = logging.getLogger(__name__) @@ -14,21 +16,21 @@ class MIProjectionVsMeanInput(BaseMetric): """ Compute mutual information between each neuron's projection and the mean input. - + For each neuron with weights w, computes MI(w^T x, mean(x)) where x is the input. This measures how much information the neuron's projection preserves about the average input pattern. """ - + name = "mi_projection_vs_mean_input" requires_weights = True requires_inputs = True requires_outputs = False - + def __init__(self, bins: int = 30, eps: float = 1e-9, force_cpu: bool = False): """ Initialize the MI projection metric. - + Args: bins: Number of bins for discretization eps: Small value for numerical stability @@ -37,7 +39,7 @@ def __init__(self, bins: int = 30, eps: float = 1e-9, force_cpu: bool = False): self.bins = bins self.eps = eps self.force_cpu = force_cpu - + @torch.no_grad() def compute( self, @@ -48,19 +50,19 @@ def compute( ) -> torch.Tensor: """ Compute MI scores for each neuron. - + Args: inputs: Input activations [batch_size, num_features] weights: Weight matrix [num_neurons, num_features] outputs: Not used **kwargs: Additional arguments - + Returns: MI scores per neuron [num_neurons] """ if inputs is None or weights is None: raise ValueError("MI projection requires both inputs and weights") - + # Handle dimensions if inputs.ndim != 2: if inputs.ndim > 2: @@ -68,35 +70,35 @@ def compute( else: logger.warning(f"Inputs have unexpected shape: {inputs.shape}") return torch.zeros(1, device=weights.device) - + if weights.ndim != 2: if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) else: logger.warning(f"Weights have unexpected shape: {weights.shape}") return torch.zeros(1, device=weights.device) - + batch_size, num_features = inputs.shape num_neurons = weights.shape[0] - + # Check dimension compatibility if weights.shape[1] != num_features: min_dim = min(weights.shape[1], num_features) weights = weights[:, :min_dim] inputs = inputs[:, :min_dim] logger.warning(f"Dimension mismatch, truncating to {min_dim} features") - + # Need enough samples for MI estimation if batch_size < 10: logger.warning(f"Too few samples for MI estimation: {batch_size}") return torch.zeros(num_neurons, device=weights.device) - + # Compute mean input across features mean_input = inputs.mean(dim=1) # [batch_size] - + # Initialize scores mi_scores = torch.zeros(num_neurons, device=weights.device) - + # Move to CPU if requested and tensors are large if self.force_cpu and inputs.is_cuda and inputs.numel() > 1e6: inputs_cpu = inputs.cpu() @@ -106,67 +108,67 @@ def compute( inputs_cpu = inputs weights_cpu = weights mean_input_cpu = mean_input - + # Convert to numpy for binning mean_input_np = mean_input_cpu.numpy() - + # Discretize mean input mean_min, mean_max = np.min(mean_input_np), np.max(mean_input_np) if mean_max - mean_min < self.eps: logger.warning("Mean input has no variation") return torch.zeros(num_neurons, device=weights.device) - + mean_bins = np.linspace(mean_min, mean_max, self.bins + 1) mean_digitized = np.digitize(mean_input_np, mean_bins[:-1]) - 1 mean_digitized = np.clip(mean_digitized, 0, self.bins - 1) - + # Compute MI for each neuron for i in range(num_neurons): try: # Compute projection: w^T x projection = torch.matmul(inputs_cpu, weights_cpu[i]) # [batch_size] projection_np = projection.numpy() - + # Discretize projection proj_min, proj_max = np.min(projection_np), np.max(projection_np) if proj_max - proj_min < self.eps: continue - + proj_bins = np.linspace(proj_min, proj_max, self.bins + 1) proj_digitized = np.digitize(projection_np, proj_bins[:-1]) - 1 proj_digitized = np.clip(proj_digitized, 0, self.bins - 1) - + # Compute joint histogram joint_hist = np.zeros((self.bins, self.bins)) for j in range(batch_size): joint_hist[proj_digitized[j], mean_digitized[j]] += 1 - + # Convert to probabilities joint_prob = joint_hist / batch_size - + # Marginal probabilities p_proj = joint_prob.sum(axis=1) p_mean = joint_prob.sum(axis=0) - + # Compute MI mi = 0.0 for pi in range(self.bins): for mi_idx in range(self.bins): - if (joint_prob[pi, mi_idx] > self.eps and - p_proj[pi] > self.eps and + if (joint_prob[pi, mi_idx] > self.eps and + p_proj[pi] > self.eps and p_mean[mi_idx] > self.eps): mi += joint_prob[pi, mi_idx] * np.log2( joint_prob[pi, mi_idx] / (p_proj[pi] * p_mean[mi_idx]) ) - + mi_scores[i] = mi - + except Exception as e: logger.debug(f"Error computing MI for neuron {i}: {e}") continue - + # Convert back to torch tensor on original device if mi_scores.device != weights.device: mi_scores = mi_scores.to(weights.device) - - return torch.nan_to_num(mi_scores, nan=0.0) \ No newline at end of file + + return torch.nan_to_num(mi_scores, nan=0.0) diff --git a/src/alignment/metrics/information/mutual_information.py b/src/alignment/metrics/information/mutual_information.py index b50b06ce..bbee30ad 100644 --- a/src/alignment/metrics/information/mutual_information.py +++ b/src/alignment/metrics/information/mutual_information.py @@ -2,12 +2,12 @@ Mutual Information metrics for measuring dependencies between neural representations. """ -import torch -import torch.nn.functional as F -from typing import Optional, Dict, Any, Tuple -import numpy as np import logging -import warnings +from typing import Any, Optional + +import numpy as np +import torch + from ...core.base import BaseMetric from ...core.registry import register_metric @@ -18,14 +18,14 @@ class MutualInformationGaussian(BaseMetric): """ Mutual Information metric assuming Gaussian distributions. - + Computes MI between layer outputs and a reference signal (target outputs or first principal component of inputs) using the Gaussian approximation: MI = -0.5 * log(1 - ρ²) - + where ρ is the correlation coefficient. """ - + def __init__( self, use_pc_reference: bool = True, @@ -34,7 +34,7 @@ def __init__( ): """ Initialize the Gaussian MI metric. - + Args: use_pc_reference: If True and no target provided, use PC1 of inputs as reference min_samples: Minimum samples required for computation @@ -43,19 +43,19 @@ def __init__( super().__init__(**config) self.use_pc_reference = use_pc_reference self.min_samples = min_samples - + @property def requires_inputs(self) -> bool: return self.use_pc_reference - + @property def requires_weights(self) -> bool: return False - + @property def requires_outputs(self) -> bool: return True - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -66,35 +66,35 @@ def compute( ) -> torch.Tensor: """ Compute Gaussian MI for each output neuron. - + Args: inputs: Input activations (used for PC reference if needed) weights: Not used outputs: Layer output activations [batch_size, num_neurons] target_outputs: Target reference signal [batch_size, num_targets] - + Returns: MI values for each neuron [num_neurons] """ if outputs is None: raise ValueError("MutualInformationGaussian requires outputs") - + if outputs.ndim != 2: outputs = outputs.reshape(outputs.shape[0], -1) - + batch_size, num_neurons = outputs.shape - + if batch_size < self.min_samples: logger.warning(f"MI_gaussian: Only {batch_size} samples, returning zeros") return torch.zeros(num_neurons, device=outputs.device, dtype=outputs.dtype) - + # Determine reference signal if target_outputs is None: if self.use_pc_reference and inputs is not None: # Use first PC of inputs as reference if inputs.ndim != 2: inputs = inputs.reshape(inputs.shape[0], -1) - + try: # Compute covariance and get first PC inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) @@ -111,12 +111,12 @@ def compute( ref_data = target_outputs if ref_data.ndim == 1: ref_data = ref_data.unsqueeze(1) - + # Ensure ref_data has correct batch size if ref_data.shape[0] != batch_size: - logger.warning(f"MI_gaussian: Reference batch size mismatch") + logger.warning("MI_gaussian: Reference batch size mismatch") return torch.zeros(num_neurons, device=outputs.device, dtype=outputs.dtype) - + # Vectorized MI computation across neurons and reference dims # Standardize outputs and references along batch dimension eps = 1e-12 @@ -145,7 +145,7 @@ def compute( # Average over reference dims mi_scores = mi_per_ref.mean(dim=1) mi_scores = mi_scores.to(outputs.device) - + return torch.nan_to_num(mi_scores) @@ -153,11 +153,11 @@ def compute( class MutualInformationBinning(BaseMetric): """ Mutual Information using histogram binning method. - + Computes MI using discrete probability distributions estimated through histogram binning of continuous values. """ - + def __init__( self, bins: int = 10, @@ -166,7 +166,7 @@ def __init__( ): """ Initialize the binning MI metric. - + Args: bins: Number of bins for histogram min_samples: Minimum samples (need more for binning) @@ -175,19 +175,19 @@ def __init__( super().__init__(**config) self.bins = bins self.min_samples = min_samples - + @property def requires_inputs(self) -> bool: return False - + @property def requires_weights(self) -> bool: return False - + @property def requires_outputs(self) -> bool: return True - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -198,31 +198,31 @@ def compute( ) -> torch.Tensor: """ Compute binning-based MI for each output neuron. - + Args: inputs: Not used weights: Not used outputs: Layer output activations [batch_size, num_neurons] target_outputs: Target reference signal [batch_size, num_targets] - + Returns: MI values for each neuron [num_neurons] """ if outputs is None: raise ValueError("MutualInformationBinning requires outputs") - + if outputs.ndim != 2: outputs = outputs.reshape(outputs.shape[0], -1) - + batch_size, num_neurons = outputs.shape - + if batch_size < self.min_samples: logger.warning(f"MI_binning: Only {batch_size} samples, need {self.min_samples}") return torch.zeros(num_neurons, device=outputs.device, dtype=outputs.dtype) - + # Convert to numpy for binning operations outputs_np = outputs.cpu().numpy() - + # Determine reference if target_outputs is not None: ref_np = target_outputs.cpu().numpy() @@ -231,12 +231,12 @@ def compute( else: # Use mean of other neurons as reference ref_np = None - + mi_scores = np.zeros(num_neurons) - + for i in range(num_neurons): neuron_i_np = outputs_np[:, i] - + # Get reference for this neuron if ref_np is None and num_neurons > 1: # Use mean of other neurons @@ -247,27 +247,27 @@ def compute( continue else: current_ref_np = ref_np - + if current_ref_np is None: continue - + # Compute MI with each reference dimension mi_sum = 0.0 valid_refs = 0 - + for k in range(current_ref_np.shape[1]): ref_k_np = current_ref_np[:, k] - + # Bin the data hist_2d, x_edges, y_edges = np.histogram2d( neuron_i_np, ref_k_np, bins=self.bins ) - + # Convert to probabilities joint_p = hist_2d / batch_size p_x = np.sum(joint_p, axis=1) p_y = np.sum(joint_p, axis=0) - + # Compute MI (in nats; use natural logarithm) mi_val = 0.0 for xi in range(self.bins): @@ -276,13 +276,13 @@ def compute( mi_val += joint_p[xi, yi] * np.log( joint_p[xi, yi] / (p_x[xi] * p_y[yi]) ) - + mi_sum += mi_val valid_refs += 1 - + if valid_refs > 0: mi_scores[i] = mi_sum / valid_refs - + return torch.tensor(mi_scores, device=outputs.device, dtype=outputs.dtype) @@ -290,11 +290,11 @@ def compute( class ConditionalMutualInformation(MutualInformationGaussian): """ Conditional Mutual Information I(X;Y|Z). - + Measures information between X and Y that is not explained by Z. Uses Gaussian approximation. """ - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -306,45 +306,45 @@ def compute( ) -> torch.Tensor: """ Compute conditional MI: I(outputs; target | condition). - + Args: outputs: X variable [batch_size, num_neurons] target_outputs: Y variable [batch_size, num_targets] condition_on: Z variable to condition on [batch_size, num_conditions] - + Returns: Conditional MI values [num_neurons] """ if outputs is None or target_outputs is None or condition_on is None: raise ValueError("ConditionalMI requires outputs, target_outputs, and condition_on") - + # Compute I(X;Y) - I(X;Y;Z) using Gaussian approximation # This is a simplified implementation - + # Regular MI between outputs and targets mi_xy = super().compute( outputs=outputs, target_outputs=target_outputs, **kwargs ) - + # MI between outputs and condition mi_xz = super().compute( outputs=outputs, target_outputs=condition_on, **kwargs ) - - # MI between targets and condition + + # MI between targets and condition mi_yz = super().compute( outputs=target_outputs, target_outputs=condition_on, **kwargs ) - + # Approximate CMI (this is simplified - proper implementation would use # partial correlations or multivariate Gaussian formulas) cmi = mi_xy - torch.min(mi_xz, mi_yz.mean() * torch.ones_like(mi_xz)) cmi = torch.clamp(cmi, min=0.0) # CMI is non-negative - - return cmi \ No newline at end of file + + return cmi diff --git a/src/alignment/metrics/information/pairwise_gaussian.py b/src/alignment/metrics/information/pairwise_gaussian.py index ea808094..97e1f35a 100644 --- a/src/alignment/metrics/information/pairwise_gaussian.py +++ b/src/alignment/metrics/information/pairwise_gaussian.py @@ -7,9 +7,10 @@ where ρ is correlation in the Σ_X space. """ -from typing import Optional, Any -import torch import logging +from typing import Any, Optional + +import torch from ...core.base import BaseMetric from ...core.registry import register_metric @@ -21,19 +22,19 @@ class PairwiseRedundancyGaussian(BaseMetric): """ Compute per-neuron redundancy via pairwise Gaussian mutual information. - + For each neuron, computes average redundancy with K sampled partner neurons. Redundancy proxy: I(Y_i; Y_j) = -0.5 * log(1 - ρ²) - + This is a target-free redundancy measure based on correlation in the input covariance space. - + Example: >>> redundancy_metric = PairwiseRedundancyGaussian(num_pairs=10) >>> redundancy = redundancy_metric.compute(inputs, weights) >>> print(redundancy.shape) # [num_neurons] """ - + def __init__( self, num_pairs: int = 10, @@ -44,7 +45,7 @@ def __init__( ): """ Initialize pairwise redundancy metric. - + Args: num_pairs: Number of partner neurons to sample per neuron sampling_strategy: How to sample pairs ('random', 'nearest', 'all') @@ -59,19 +60,19 @@ def __init__( self.sampling_strategy = sampling_strategy self.mode = mode self.regularization = regularization - + @property def requires_inputs(self) -> bool: return self.mode == 'covariance_based' - + @property def requires_weights(self) -> bool: return self.mode == 'covariance_based' - + @property def requires_outputs(self) -> bool: return self.mode == 'output_based' - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -81,13 +82,13 @@ def compute( ) -> torch.Tensor: """ Compute per-neuron redundancy scores. - + Args: inputs: Input activations [batch_size, input_features] (for covariance_based) weights: Layer weights [num_neurons, input_features] (for covariance_based) outputs: Layer outputs [batch_size, num_neurons] (for output_based) **kwargs: Additional parameters - + Returns: Per-neuron redundancy scores [num_neurons] """ @@ -103,67 +104,67 @@ def compute( if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) outputs = inputs @ weights.T - + return self._compute_output_based(outputs) - + elif self.mode == 'covariance_based': if inputs is None or weights is None: raise ValueError("covariance_based mode requires inputs and weights") return self._compute_covariance_based(inputs, weights) - + else: raise ValueError(f"Unknown mode: {self.mode}") - + def _compute_output_based(self, outputs: torch.Tensor) -> torch.Tensor: """ Compute redundancy from outputs directly (FAST!). - + This is 3-30x faster than covariance-based, especially for high-dimensional inputs. - + Complexity: O(B·N² + N·K) instead of O(B·D²·N·K) For LLMs with D=4096, N=4096: ~30x speedup! - + Args: outputs: Layer outputs [batch_size, num_neurons] - + Returns: Per-neuron redundancy [num_neurons] """ if outputs.ndim > 2: outputs = outputs.reshape(outputs.shape[0], -1) - + B, N = outputs.shape - + if B < 2: logger.warning("Output-based redundancy needs B >= 2, returning zeros") return torch.zeros(N, device=outputs.device) - + # Center outputs Y_centered = outputs - outputs.mean(dim=0, keepdim=True) - + # Compute all pairwise covariances at once: [N, N] cov_Y = (Y_centered.T @ Y_centered) / max(1, B - 1) - + # Variances (diagonal) var_Y = torch.diag(cov_Y) # [N] - + # Correlation matrix std_matrix = torch.sqrt(var_Y.unsqueeze(1) * var_Y.unsqueeze(0) + 1e-12) corr_matrix = cov_Y / (std_matrix + 1e-12) - + # Clip to valid range corr_matrix = torch.clamp(corr_matrix, -0.9999, 0.9999) - + # Redundancy: I(Yi; Yj) = -0.5·log(1 - ρ²) rho_sq = corr_matrix ** 2 R_matrix = -0.5 * torch.log(1 - rho_sq + 1e-8) # [N, N] - + # Zero out diagonal (neuron with itself) R_matrix.fill_diagonal_(0) - + # Per-neuron redundancy: average over sampled partners redundancy = torch.zeros(N, device=outputs.device) - + if self.sampling_strategy == 'all': # Average over all partners row_sums = R_matrix.sum(dim=1) @@ -174,9 +175,9 @@ def _compute_output_based(self, outputs: torch.Tensor) -> torch.Tensor: partners = self._sample_partners_fast(i, N) if len(partners) > 0: redundancy[i] = R_matrix[i, partners].mean() - + return redundancy - + def _compute_covariance_based( self, inputs: torch.Tensor, @@ -184,53 +185,53 @@ def _compute_covariance_based( ) -> torch.Tensor: """ Compute redundancy via input covariance (SLOWER but works without outputs). - + Use when outputs not available. For large D, this is expensive. - + Args: inputs: Input activations [batch_size, input_features] weights: Layer weights [num_neurons, input_features] - + Returns: Per-neuron redundancy [num_neurons] """ - + # Flatten if needed if inputs.ndim > 2: inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) - + batch_size, input_features = inputs.shape num_neurons, weight_features = weights.shape - + # Check compatibility if input_features != weight_features: min_dim = min(input_features, weight_features) inputs = inputs[:, :min_dim] weights = weights[:, :min_dim] input_features = min_dim - + # Compute input covariance inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) cov = (inputs_centered.T @ inputs_centered) / max(1, batch_size - 1) - + # Add regularization if self.regularization > 0: cov = cov + self.regularization * torch.eye( input_features, device=cov.device, dtype=cov.dtype ) - + # Compute redundancy for each neuron redundancy = torch.zeros(num_neurons, device=weights.device, dtype=weights.dtype) - + for i in range(num_neurons): # Sample partner neurons partner_indices = self._sample_partners_fast(i, num_neurons) - + if len(partner_indices) == 0: continue - + # Compute correlation with each partner correlations = [] for j in partner_indices: @@ -238,7 +239,7 @@ def _compute_covariance_based( weights[i], weights[j], cov ) correlations.append(rho_sq) - + # Average redundancy with partners if correlations: # I(Y_i; Y_j) = -0.5 * log(1 - ρ²) @@ -247,9 +248,9 @@ def _compute_covariance_based( correlations_tensor = torch.clamp(correlations_tensor, max=0.9999) redundancy_values = -0.5 * torch.log(1 - correlations_tensor + 1e-8) redundancy[i] = redundancy_values.mean() - + return redundancy - + def _sample_partners_fast( self, neuron_idx: int, @@ -257,45 +258,45 @@ def _sample_partners_fast( ) -> torch.Tensor: """ Sample partner neurons for redundancy computation. - + Args: neuron_idx: Index of current neuron num_neurons: Total number of neurons - + Returns: Indices of partner neurons """ # Exclude self available = list(range(num_neurons)) available.remove(neuron_idx) - + if self.sampling_strategy == 'all': # Use all other neurons return torch.tensor(available, dtype=torch.long) - + elif self.sampling_strategy == 'random': # Random sample num_to_sample = min(self.num_pairs, len(available)) if num_to_sample == 0: return torch.tensor([], dtype=torch.long) - + indices = torch.randperm(len(available))[:num_to_sample] return torch.tensor([available[i] for i in indices], dtype=torch.long) - + elif self.sampling_strategy == 'nearest': # Sample nearby indices (assumes some ordering) num_to_sample = min(self.num_pairs, len(available)) if num_to_sample == 0: return torch.tensor([], dtype=torch.long) - + # Get closest indices distances = torch.abs(torch.tensor(available) - neuron_idx) _, nearest_indices = torch.topk(distances, num_to_sample, largest=False) return torch.tensor([available[i] for i in nearest_indices], dtype=torch.long) - + else: raise ValueError(f"Unknown sampling strategy: {self.sampling_strategy}") - + def _compute_correlation_squared( self, w_i: torch.Tensor, @@ -304,35 +305,35 @@ def _compute_correlation_squared( ) -> torch.Tensor: """ Compute squared correlation between two neurons in covariance space. - + Args: w_i: Weight vector of neuron i [features] w_j: Weight vector of neuron j [features] cov: Input covariance matrix [features, features] - + Returns: ρ² = (w_i^T Σ w_j)² / [(w_i^T Σ w_i)(w_j^T Σ w_j)] """ # Compute variances var_i = w_i @ cov @ w_i # scalar var_j = w_j @ cov @ w_j # scalar - + # Compute covariance cov_ij = w_i @ cov @ w_j # scalar - + # Compute squared correlation denominator = var_i * var_j - + if denominator < 1e-12: return torch.tensor(0.0, device=w_i.device, dtype=w_i.dtype) - + rho_sq = (cov_ij ** 2) / denominator - + # Clip to [0, 1] rho_sq = torch.clamp(rho_sq, min=0.0, max=1.0) - + return rho_sq - + def compute_pairwise_matrix( self, inputs: torch.Tensor, @@ -340,11 +341,11 @@ def compute_pairwise_matrix( ) -> torch.Tensor: """ Compute full pairwise redundancy matrix. - + Args: inputs: Input activations [batch_size, features] weights: Layer weights [num_neurons, features] - + Returns: Redundancy matrix [num_neurons, num_neurons] where R[i,j] = redundancy between neurons i and j @@ -353,25 +354,25 @@ def compute_pairwise_matrix( inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) - + batch_size = inputs.shape[0] num_neurons = weights.shape[0] - + # Compute covariance inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) cov = (inputs_centered.T @ inputs_centered) / max(1, batch_size - 1) - + if self.regularization > 0: cov = cov + self.regularization * torch.eye( cov.shape[0], device=cov.device, dtype=cov.dtype ) - + # Compute pairwise redundancy redundancy_matrix = torch.zeros( num_neurons, num_neurons, device=weights.device, dtype=weights.dtype ) - + for i in range(num_neurons): for j in range(i + 1, num_neurons): rho_sq = self._compute_correlation_squared( @@ -380,9 +381,9 @@ def compute_pairwise_matrix( # Clip correlation rho_sq = torch.clamp(rho_sq, max=0.9999) redundancy = -0.5 * torch.log(1 - rho_sq + 1e-8) - + # Symmetric redundancy_matrix[i, j] = redundancy redundancy_matrix[j, i] = redundancy - + return redundancy_matrix diff --git a/src/alignment/metrics/information/pid.py b/src/alignment/metrics/information/pid.py index 1c84d4a9..a9f6a794 100644 --- a/src/alignment/metrics/information/pid.py +++ b/src/alignment/metrics/information/pid.py @@ -5,11 +5,11 @@ about the output into unique, redundant, and synergistic components. """ -from typing import Optional, Dict, Any, Tuple -import torch -import numpy as np import logging -from pathlib import Path +from typing import Dict, Optional, Tuple + +import numpy as np +import torch from ...core.base import BaseMetric from ...core.registry import register_metric @@ -18,8 +18,9 @@ # Try to import the BROJA 2PID module try: - import sys import os + import sys + # Add the external module to path if needed from ...external.BROJA_2PID import BROJA_2PID HAS_BROJA = True @@ -33,12 +34,12 @@ class BasePIDMetric(BaseMetric): """Base class for PID-based metrics.""" - + # PID metrics require inputs and outputs requires_inputs = True requires_weights = False requires_outputs = True - + def __init__( self, bins: int = 10, @@ -48,7 +49,7 @@ def __init__( ): """ Initialize PID metric. - + Args: bins: Number of bins for discretization normalize: Whether to normalize the result @@ -59,7 +60,7 @@ def __init__( self.bins = bins self.normalize = normalize self.use_pca_inputs = use_pca_inputs - + def _prepare_pid_input( self, x1: torch.Tensor, @@ -68,65 +69,65 @@ def _prepare_pid_input( ) -> Optional[Dict[Tuple[int, int, int], float]]: """ Prepare input for BROJA PID computation. - + Args: x1: First input variable - x2: Second input variable + x2: Second input variable y: Output variable - + Returns: Probability distribution dictionary or None if error """ if not HAS_BROJA: return None - + try: # Convert to numpy and discretize x1_discrete = np.digitize(x1.cpu().numpy(), bins=np.linspace(x1.min(), x1.max(), self.bins)) x2_discrete = np.digitize(x2.cpu().numpy(), bins=np.linspace(x2.min(), x2.max(), self.bins)) y_discrete = np.digitize(y.cpu().numpy(), bins=np.linspace(y.min(), y.max(), self.bins)) - + # Create joint probability distribution joint_prob = {} n_samples = len(x1_discrete) - + for i in range(n_samples): key = (int(x1_discrete[i]), int(x2_discrete[i]), int(y_discrete[i])) joint_prob[key] = joint_prob.get(key, 0) + 1.0 / n_samples - + return joint_prob - + except Exception as e: logger.error(f"Error preparing PID input: {e}") return None - + def _compute_pca_features(self, inputs: torch.Tensor, n_components: int = 2) -> torch.Tensor: """ Compute PCA features from inputs. - + Args: inputs: Input tensor [batch, features] n_components: Number of components to keep - + Returns: PCA transformed features """ # Center the data inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) - + # Compute covariance cov = torch.mm(inputs_centered.t(), inputs_centered) / (inputs.size(0) - 1) - + # Eigendecomposition eigenvalues, eigenvectors = torch.linalg.eigh(cov) - + # Sort by eigenvalue (descending) idx = eigenvalues.argsort(descending=True) eigenvectors = eigenvectors[:, idx[:n_components]] - + # Project data pca_features = torch.mm(inputs_centered, eigenvectors) - + return pca_features @@ -134,13 +135,13 @@ def _compute_pca_features(self, inputs: torch.Tensor, n_components: int = 2) -> class SharedInformation(BasePIDMetric): """ Computes shared information (redundancy) between two inputs about output. - + This measures the information that both inputs provide redundantly about the output. """ - + name = "pid_shared" - + def compute( self, inputs: torch.Tensor, @@ -150,57 +151,57 @@ def compute( ) -> torch.Tensor: """ Compute shared information for each output neuron. - + Args: inputs: Input activations [batch, input_features] outputs: Output activations [batch, output_features] weights: Not used for this metric **kwargs: Additional arguments - + Returns: Shared information scores [output_features] """ if not HAS_BROJA: logger.warning("BROJA not available, returning zeros") return torch.zeros(outputs.shape[1], device=outputs.device) - + batch_size, n_outputs = outputs.shape scores = torch.zeros(n_outputs, device=outputs.device) - + # Get input features (use PCA if enabled) if self.use_pca_inputs and inputs.shape[1] > 2: input_features = self._compute_pca_features(inputs, n_components=2) else: # Use first two features input_features = inputs[:, :2] - + # Compute PID for each output neuron for i in range(n_outputs): if input_features.shape[1] < 2: continue - + # Prepare PID input pid_input = self._prepare_pid_input( input_features[:, 0], input_features[:, 1], outputs[:, i] ) - + if pid_input is None: continue - + try: # Compute PID pid_result = BROJA_2PID.pid(pid_input) scores[i] = pid_result.get("SI", 0.0) # Shared Information - + except Exception as e: logger.debug(f"PID computation failed for neuron {i}: {e}") continue - + if self.normalize and scores.max() > 0: scores = scores / scores.max() - + return scores @@ -208,13 +209,13 @@ def compute( class UniqueInformationX(BasePIDMetric): """ Computes unique information from first input variable about output. - + This measures information that only the first input provides about the output (not available from the second input). """ - + name = "pid_unique_x" - + def compute( self, inputs: torch.Tensor, @@ -226,54 +227,54 @@ def compute( if not HAS_BROJA: logger.warning("BROJA not available, returning zeros") return torch.zeros(outputs.shape[1], device=outputs.device) - + batch_size, n_outputs = outputs.shape scores = torch.zeros(n_outputs, device=outputs.device) - + # Get input features if self.use_pca_inputs and inputs.shape[1] > 2: input_features = self._compute_pca_features(inputs, n_components=2) else: input_features = inputs[:, :2] - + for i in range(n_outputs): if input_features.shape[1] < 2: continue - + pid_input = self._prepare_pid_input( input_features[:, 0], input_features[:, 1], outputs[:, i] ) - + if pid_input is None: continue - + try: pid_result = BROJA_2PID.pid(pid_input) scores[i] = pid_result.get("UIY", 0.0) # Unique info from Y (first var) - + except Exception as e: logger.debug(f"PID computation failed for neuron {i}: {e}") continue - + if self.normalize and scores.max() > 0: scores = scores / scores.max() - + return scores -@register_metric("pid_unique_y") +@register_metric("pid_unique_y") class UniqueInformationY(BasePIDMetric): """ Computes unique information from second input variable about output. - + This measures information that only the second input provides about the output (not available from the first input). """ - + name = "pid_unique_y" - + def compute( self, inputs: torch.Tensor, @@ -285,40 +286,40 @@ def compute( if not HAS_BROJA: logger.warning("BROJA not available, returning zeros") return torch.zeros(outputs.shape[1], device=outputs.device) - + batch_size, n_outputs = outputs.shape scores = torch.zeros(n_outputs, device=outputs.device) - + # Get input features if self.use_pca_inputs and inputs.shape[1] > 2: input_features = self._compute_pca_features(inputs, n_components=2) else: input_features = inputs[:, :2] - + for i in range(n_outputs): if input_features.shape[1] < 2: continue - + pid_input = self._prepare_pid_input( input_features[:, 0], input_features[:, 1], outputs[:, i] ) - + if pid_input is None: continue - + try: pid_result = BROJA_2PID.pid(pid_input) scores[i] = pid_result.get("UIZ", 0.0) # Unique info from Z (second var) - + except Exception as e: logger.debug(f"PID computation failed for neuron {i}: {e}") continue - + if self.normalize and scores.max() > 0: scores = scores / scores.max() - + return scores @@ -326,13 +327,13 @@ def compute( class SynergisticInformation(BasePIDMetric): """ Computes synergistic information between inputs about output. - + This measures information about the output that is only available when both inputs are considered together (emergent information). """ - + name = "pid_synergy" - + def compute( self, inputs: torch.Tensor, @@ -344,42 +345,42 @@ def compute( if not HAS_BROJA: logger.warning("BROJA not available, returning zeros") return torch.zeros(outputs.shape[1], device=outputs.device) - + batch_size, n_outputs = outputs.shape scores = torch.zeros(n_outputs, device=outputs.device) - + # Get input features if self.use_pca_inputs and inputs.shape[1] > 2: input_features = self._compute_pca_features(inputs, n_components=2) else: input_features = inputs[:, :2] - + for i in range(n_outputs): if input_features.shape[1] < 2: continue - + pid_input = self._prepare_pid_input( input_features[:, 0], input_features[:, 1], outputs[:, i] ) - + if pid_input is None: continue - + try: pid_result = BROJA_2PID.pid(pid_input) scores[i] = pid_result.get("CI", 0.0) # Synergistic/Complementary info - + except Exception as e: logger.debug(f"PID computation failed for neuron {i}: {e}") continue - + if self.normalize and scores.max() > 0: scores = scores / scores.max() - + return scores # Aliases for backward compatibility -PartialInformationDecomposition = SharedInformation \ No newline at end of file +PartialInformationDecomposition = SharedInformation diff --git a/src/alignment/metrics/information/redundancy.py b/src/alignment/metrics/information/redundancy.py index be37c44f..d2afb39d 100644 --- a/src/alignment/metrics/information/redundancy.py +++ b/src/alignment/metrics/information/redundancy.py @@ -5,9 +5,10 @@ indicating how much overlapping information they capture. """ -from typing import Optional, Any -import torch import logging +from typing import Any, Optional + +import torch from ...core.base import BaseMetric from ...core.registry import register_metric @@ -19,12 +20,12 @@ class AverageRedundancy(BaseMetric): """ Average redundancy between neurons using Gaussian approximation. - + For each neuron, computes the average mutual information with all other neurons, indicating how much its information is redundant with the rest of the layer. """ - + def __init__( self, min_samples: int = 2, @@ -33,7 +34,7 @@ def __init__( ): """ Initialize the redundancy metric. - + Args: min_samples: Minimum samples for computation use_correlation: If True, use correlation; if False, use covariance @@ -42,19 +43,19 @@ def __init__( super().__init__(**config) self.min_samples = min_samples self.use_correlation = use_correlation - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return True - + @property def requires_outputs(self) -> bool: return False - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -64,43 +65,43 @@ def compute( ) -> torch.Tensor: """ Compute average redundancy for each neuron. - + Args: inputs: Input activations [batch_size, input_features] weights: Layer weights [num_neurons, input_features] outputs: Not used (computed from inputs and weights) - + Returns: Average redundancy scores [num_neurons] """ if inputs is None or weights is None: raise ValueError("AverageRedundancy requires inputs and weights") - + # Flatten if needed if inputs.ndim != 2: inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim != 2: weights = weights.reshape(weights.shape[0], -1) - + batch_size = inputs.shape[0] num_neurons = weights.shape[0] - + if batch_size < self.min_samples: logger.warning(f"Redundancy: Only {batch_size} samples, returning zeros") return torch.zeros(num_neurons, device=weights.device, dtype=weights.dtype) - + if num_neurons <= 1: logger.warning("Redundancy: Need at least 2 neurons") return torch.zeros(num_neurons, device=weights.device, dtype=weights.dtype) - + # Compute projected outputs projected = torch.matmul(inputs, weights.T) # [batch_size, num_neurons] - + # Move to CPU for large correlation computations if needed compute_device = projected.device if self._should_use_cpu(projected): projected = projected.cpu() - + # Compute correlation or covariance matrix if self.use_correlation: # Normalize each neuron's output @@ -112,19 +113,19 @@ def compute( else: # Use covariance corr_matrix = torch.cov(projected.T) - + # Compute average redundancy for each neuron redundancy_scores = torch.zeros(num_neurons, device=weights.device) - + for i in range(num_neurons): # Average MI with other neurons using Gaussian approximation sum_redundancy = 0.0 num_pairs = 0 - + for j in range(num_neurons): if i == j: continue - + # Get correlation/covariance value if self.use_correlation: rho_sq = corr_matrix[i, j] ** 2 @@ -136,21 +137,21 @@ def compute( rho_sq = (corr_matrix[i, j] ** 2) / (var_i * var_j) else: rho_sq = 0.0 - + # MI approximation rho_sq = torch.clamp(rho_sq, 0, 0.999999) mi_ij = -0.5 * torch.log(1.0 - rho_sq) - + sum_redundancy += mi_ij num_pairs += 1 - + if num_pairs > 0: redundancy_scores[i] = sum_redundancy / num_pairs - + # Move back to original device if needed if compute_device != weights.device: redundancy_scores = redundancy_scores.to(weights.device) - + return torch.nan_to_num(redundancy_scores) @@ -158,12 +159,12 @@ def compute( class NodeRedundancy(BaseMetric): """ Redundancy between input features based on correlation. - + This metric measures how redundant each input feature is with respect to other input features, useful for identifying correlated inputs. """ - + def __init__( self, min_samples: int = 2, @@ -172,7 +173,7 @@ def __init__( ): """ Initialize node redundancy metric. - + Args: min_samples: Minimum samples for correlation exclude_self: Whether to exclude self-correlation @@ -181,19 +182,19 @@ def __init__( super().__init__(**config) self.min_samples = min_samples self.exclude_self = exclude_self - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return False - + @property def requires_outputs(self) -> bool: return False - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -203,47 +204,47 @@ def compute( ) -> torch.Tensor: """ Compute redundancy for each input feature. - + Args: inputs: Input activations [batch_size, num_features] weights: Not used outputs: Not used - + Returns: Redundancy scores for each input feature [num_features] """ if inputs is None: raise ValueError("NodeRedundancy requires inputs") - + if inputs.ndim != 2: inputs = inputs.reshape(inputs.shape[0], -1) - + batch_size, num_features = inputs.shape - + if batch_size < self.min_samples: logger.warning(f"NodeRedundancy: Only {batch_size} samples") return torch.zeros(num_features, device=inputs.device, dtype=inputs.dtype) - + # Compute correlation matrix compute_device = inputs.device if self._should_use_cpu(inputs): inputs = inputs.cpu() - + # Standardize features inputs_mean = inputs.mean(dim=0, keepdim=True) inputs_std = inputs.std(dim=0, keepdim=True) inputs_std = torch.where(inputs_std > 1e-10, inputs_std, torch.ones_like(inputs_std)) inputs_norm = (inputs - inputs_mean) / inputs_std - + # Correlation matrix corr_matrix = torch.matmul(inputs_norm.T, inputs_norm) / (batch_size - 1) - + # Take absolute correlations (strength matters, not direction) abs_corr = torch.abs(corr_matrix) - + # Compute average correlation with other features redundancy_scores = torch.zeros(num_features, device=inputs.device) - + for i in range(num_features): if self.exclude_self: # Average correlation with other features @@ -254,11 +255,11 @@ def compute( else: # Include self-correlation redundancy_scores[i] = abs_corr[i].mean() - + # Move back to original device if compute_device != inputs.device: redundancy_scores = redundancy_scores.to(compute_device) - + return torch.nan_to_num(redundancy_scores) @@ -266,11 +267,11 @@ def compute( class LayerRedundancy(BaseMetric): """ Overall redundancy measure for an entire layer. - + Computes a single redundancy score for the layer based on the average pairwise mutual information between neurons. """ - + def __init__( self, return_matrix: bool = False, @@ -278,7 +279,7 @@ def __init__( ): """ Initialize layer redundancy metric. - + Args: return_matrix: If True, return full redundancy matrix **config: Additional configuration @@ -286,19 +287,19 @@ def __init__( super().__init__(**config) self.return_matrix = return_matrix self._avg_redundancy = AverageRedundancy(**config) - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return True - + @property def requires_outputs(self) -> bool: return False - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -308,7 +309,7 @@ def compute( ) -> torch.Tensor: """ Compute overall layer redundancy. - + Returns: If return_matrix=False: scalar redundancy score If return_matrix=True: redundancy matrix [num_neurons, num_neurons] @@ -320,10 +321,10 @@ def compute( outputs=outputs, **kwargs ) - + if self.return_matrix: # Return full redundancy matrix (would need to modify avg_redundancy) logger.warning("Full matrix return not yet implemented, returning average") - + # Return average redundancy across all neurons - return neuron_redundancies.mean().unsqueeze(0) \ No newline at end of file + return neuron_redundancies.mean().unsqueeze(0) diff --git a/src/alignment/metrics/information/synergy_mmi.py b/src/alignment/metrics/information/synergy_mmi.py index 9933d328..337e8c87 100644 --- a/src/alignment/metrics/information/synergy_mmi.py +++ b/src/alignment/metrics/information/synergy_mmi.py @@ -7,9 +7,10 @@ where Z is a discrete target and Y_i, Y_j are continuous neuron outputs. """ -from typing import Optional, Any -import torch import logging +from typing import Any, Optional + +import torch from ...core.base import BaseMetric from ...core.registry import register_metric @@ -21,14 +22,14 @@ class SynergyGaussianMMI(BaseMetric): """ Compute per-neuron synergy using Gaussian MI and MMI redundancy. - + For each neuron, computes average synergy with K sampled partner neurons relative to a discrete target Z. - + Synergy measures information that emerges only from the joint outputs, using the MMI (Minimum Mutual Information) redundancy axiom: S_MMI = I(Z; Y_i, Y_j) - I(Z; Y_i) - I(Z; Y_j) + min(I(Z; Y_i), I(Z; Y_j)) - + Example: >>> synergy_metric = SynergyGaussianMMI(num_pairs=10) >>> synergy = synergy_metric.compute( @@ -38,7 +39,7 @@ class SynergyGaussianMMI(BaseMetric): ... ) >>> print(synergy.shape) # [num_neurons] """ - + def __init__( self, num_pairs: int = 10, @@ -47,7 +48,7 @@ def __init__( ): """ Initialize synergy metric. - + Args: num_pairs: Number of partner neurons to sample per neuron sampling_strategy: How to sample pairs ('random', 'nearest', 'all') @@ -56,19 +57,19 @@ def __init__( super().__init__(**config) self.num_pairs = num_pairs self.sampling_strategy = sampling_strategy - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return True - + @property def requires_outputs(self) -> bool: return True # We'll use outputs if provided, else compute from inputs @ weights.T - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -79,49 +80,49 @@ def compute( ) -> torch.Tensor: """ Compute per-neuron synergy scores. - + Args: inputs: Input activations [batch_size, input_features] weights: Layer weights [num_neurons, input_features] outputs: Layer outputs [batch_size, num_neurons] (optional) targets: Target labels [batch_size] (required) **kwargs: Additional parameters - + Returns: Per-neuron synergy scores [num_neurons] """ if inputs is None or weights is None: raise ValueError("SynergyGaussianMMI requires inputs and weights") - + if targets is None: raise ValueError("SynergyGaussianMMI requires targets") - + # Flatten if needed if inputs.ndim > 2: inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) - + # Ensure targets are 1D if targets.ndim > 1: targets = targets.squeeze() - + # Compute outputs if not provided if outputs is None: outputs = inputs @ weights.T # [batch_size, num_neurons] - + num_neurons = weights.shape[0] - + # Compute synergy for each neuron synergy = torch.zeros(num_neurons, device=weights.device, dtype=weights.dtype) - + for i in range(num_neurons): # Sample partner neurons partner_indices = self._sample_partners(i, num_neurons) - + if len(partner_indices) == 0: continue - + # Compute synergy with each partner synergy_values = [] for j in partner_indices: @@ -131,13 +132,13 @@ def compute( targets ) synergy_values.append(s) - + # Average synergy with partners if synergy_values: synergy[i] = torch.stack(synergy_values).mean() - + return synergy - + def _compute_pairwise_synergy( self, y_i: torch.Tensor, @@ -146,31 +147,31 @@ def _compute_pairwise_synergy( ) -> torch.Tensor: """ Compute synergy between two neurons relative to target. - + Args: y_i: Output of neuron i [batch_size] y_j: Output of neuron j [batch_size] targets: Target labels [batch_size] - + Returns: Synergy S_MMI(Z; Y_i, Y_j) """ # Compute individual MIs mi_i = self._gaussian_mi_categorical(y_i, targets) mi_j = self._gaussian_mi_categorical(y_j, targets) - + # Compute joint MI y_joint = torch.stack([y_i, y_j], dim=1) # [batch_size, 2] mi_joint = self._gaussian_mi_categorical_multivariate(y_joint, targets) - + # MMI redundancy redundancy_mmi = torch.min(mi_i, mi_j) - + # Synergy synergy = mi_joint - mi_i - mi_j + redundancy_mmi - + return synergy - + def _gaussian_mi_categorical( self, y: torch.Tensor, @@ -179,48 +180,48 @@ def _gaussian_mi_categorical( ) -> torch.Tensor: """ Compute MI between continuous y and categorical z using Gaussian approximation. - + I(Z; Y) = H(Y) - H(Y|Z) = 0.5 * log(2πe σ²_Y) - Σ p(z) * 0.5 * log(2πe σ²_{Y|z}) - + Args: y: Continuous variable [batch_size] z: Categorical variable [batch_size] eps: Small value for numerical stability - + Returns: Mutual information (scalar) """ # Overall variance var_y = torch.var(y, unbiased=True) + eps - + # Conditional variances classes = torch.unique(z) conditional_entropy = 0.0 - + for c in classes: mask = (z == c) n_c = mask.sum() - + if n_c < 2: continue - + y_c = y[mask] var_y_c = torch.var(y_c, unbiased=True) + eps - + # Weight by class probability p_c = n_c.float() / len(z) conditional_entropy += p_c * 0.5 * torch.log(2 * torch.pi * torch.e * var_y_c) - + # MI = H(Y) - H(Y|Z) marginal_entropy = 0.5 * torch.log(2 * torch.pi * torch.e * var_y) mi = marginal_entropy - conditional_entropy - + # MI should be non-negative mi = torch.clamp(mi, min=0.0) - + return mi - + def _gaussian_mi_categorical_multivariate( self, y: torch.Tensor, @@ -229,66 +230,66 @@ def _gaussian_mi_categorical_multivariate( ) -> torch.Tensor: """ Compute MI between multivariate continuous y and categorical z. - + I(Z; Y) = 0.5 * log(det(Σ_Y) / det(Σ_{Y|Z})) - + Args: y: Continuous variables [batch_size, dim] z: Categorical variable [batch_size] eps: Regularization for covariance - + Returns: Mutual information (scalar) """ batch_size, dim = y.shape - + # Overall covariance y_centered = y - y.mean(dim=0, keepdim=True) cov_y = (y_centered.T @ y_centered) / max(1, batch_size - 1) cov_y = cov_y + eps * torch.eye(dim, device=y.device, dtype=y.dtype) - + # Conditional covariances (weighted average) classes = torch.unique(z) cov_y_given_z = torch.zeros_like(cov_y) total_weight = 0.0 - + for c in classes: mask = (z == c) n_c = mask.sum() - + if n_c < dim + 1: # Need enough samples continue - + y_c = y[mask] y_c_centered = y_c - y_c.mean(dim=0, keepdim=True) cov_c = (y_c_centered.T @ y_c_centered) / max(1, n_c - 1) cov_c = cov_c + eps * torch.eye(dim, device=y.device, dtype=y.dtype) - + # Weight by class probability weight = n_c.float() cov_y_given_z += cov_c * weight total_weight += weight - + if total_weight > 0: cov_y_given_z = cov_y_given_z / total_weight else: # Fallback: return zero MI return torch.tensor(0.0, device=y.device, dtype=y.dtype) - + # MI via determinants # Add small regularization for numerical stability det_y = torch.det(cov_y) det_y_given_z = torch.det(cov_y_given_z) - + if det_y <= 0 or det_y_given_z <= 0: # Numerical issues, return zero return torch.tensor(0.0, device=y.device, dtype=y.dtype) - + mi = 0.5 * torch.log(det_y / (det_y_given_z + eps)) mi = torch.clamp(mi, min=0.0) - + return mi - + def _sample_partners( self, neuron_idx: int, @@ -296,38 +297,38 @@ def _sample_partners( ) -> torch.Tensor: """ Sample partner neurons for synergy computation. - + Args: neuron_idx: Index of current neuron num_neurons: Total number of neurons - + Returns: Indices of partner neurons """ # Exclude self available = list(range(num_neurons)) available.remove(neuron_idx) - + if self.sampling_strategy == 'all': return torch.tensor(available, dtype=torch.long) - + elif self.sampling_strategy == 'random': num_to_sample = min(self.num_pairs, len(available)) if num_to_sample == 0: return torch.tensor([], dtype=torch.long) - + indices = torch.randperm(len(available))[:num_to_sample] return torch.tensor([available[i] for i in indices], dtype=torch.long) - + elif self.sampling_strategy == 'nearest': num_to_sample = min(self.num_pairs, len(available)) if num_to_sample == 0: return torch.tensor([], dtype=torch.long) - + distances = torch.abs(torch.tensor(available) - neuron_idx) _, nearest_indices = torch.topk(distances, num_to_sample, largest=False) return torch.tensor([available[i] for i in nearest_indices], dtype=torch.long) - + else: raise ValueError(f"Unknown sampling strategy: {self.sampling_strategy}") diff --git a/src/alignment/metrics/pairwise_base.py b/src/alignment/metrics/pairwise_base.py index fef544d3..597f5c39 100644 --- a/src/alignment/metrics/pairwise_base.py +++ b/src/alignment/metrics/pairwise_base.py @@ -10,10 +10,11 @@ - Correlation: How correlated is neuron i with others? """ +import logging from abc import abstractmethod -from typing import Optional, Any, Literal +from typing import Any, Literal, Optional + import torch -import logging from ..core.base import BaseMetric @@ -23,18 +24,18 @@ class PairwiseMetric(BaseMetric): """ Base class for pairwise metrics. - + Pairwise metrics compute relationships between neuron pairs (i, j), then aggregate to per-neuron scores. - + Workflow: 1. Compute pairwise values: R[i,j] for all pairs 2. Aggregate to single-neuron: score[i] = aggregate(R[i, :]) - + Subclasses implement: - compute_pairwise(): Returns [N, N] matrix - aggregate_pairwise(): Converts matrix to [N] scores - + Example: >>> class MyPairwiseMetric(PairwiseMetric): ... def compute_pairwise(self, outputs): @@ -45,7 +46,7 @@ class PairwiseMetric(BaseMetric): >>> scores = metric.compute(outputs=outputs) >>> # Returns [N] - one score per neuron """ - + def __init__( self, aggregation: Literal['mean', 'median', 'max', 'sum'] = 'mean', @@ -56,7 +57,7 @@ def __init__( ): """ Initialize pairwise metric. - + Args: aggregation: How to aggregate pairwise values to single-neuron scores - 'mean': Average over partners (default) @@ -76,7 +77,7 @@ def __init__( self.exclude_diagonal = exclude_diagonal self.sampling_strategy = sampling_strategy self.num_pairs = num_pairs - + @abstractmethod def compute_pairwise( self, @@ -87,62 +88,62 @@ def compute_pairwise( ) -> torch.Tensor: """ Compute pairwise relationships between all neurons. - + Args: inputs: Input activations weights: Layer weights outputs: Layer outputs **kwargs: Additional arguments - + Returns: Pairwise matrix [N, N] where entry [i,j] is relationship between neurons i and j """ pass - + def aggregate_pairwise( self, pairwise_matrix: torch.Tensor ) -> torch.Tensor: """ Aggregate pairwise matrix to per-neuron scores. - + Args: pairwise_matrix: [N, N] pairwise relationships - + Returns: Per-neuron scores [N] """ N = pairwise_matrix.shape[0] - + # Exclude diagonal if requested if self.exclude_diagonal: # Create mask mask = ~torch.eye(N, dtype=torch.bool, device=pairwise_matrix.device) - + if self.aggregation == 'mean': # Average over non-diagonal row_sums = (pairwise_matrix * mask.float()).sum(dim=1) scores = row_sums / max(1, N - 1) - + elif self.aggregation == 'median': scores = torch.zeros(N, device=pairwise_matrix.device) for i in range(N): # Get non-diagonal values for row i values = pairwise_matrix[i, mask[i]] scores[i] = values.median() - + elif self.aggregation == 'max': scores = torch.zeros(N, device=pairwise_matrix.device) for i in range(N): values = pairwise_matrix[i, mask[i]] scores[i] = values.max() - + elif self.aggregation == 'sum': scores = (pairwise_matrix * mask.float()).sum(dim=1) - + else: raise ValueError(f"Unknown aggregation: {self.aggregation}") - + else: # Include diagonal if self.aggregation == 'mean': @@ -155,9 +156,9 @@ def aggregate_pairwise( scores = pairwise_matrix.sum(dim=1) else: raise ValueError(f"Unknown aggregation: {self.aggregation}") - + return scores - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -168,28 +169,28 @@ def compute( ) -> torch.Tensor: """ Compute per-neuron scores from pairwise relationships. - + Args: inputs: Input activations weights: Layer weights outputs: Layer outputs return_matrix: If True, return full [N, N] matrix instead of aggregated [N] **kwargs: Additional arguments - + Returns: Per-neuron scores [N] or pairwise matrix [N, N] if return_matrix=True """ # Compute pairwise matrix pairwise_matrix = self.compute_pairwise(inputs, weights, outputs, **kwargs) - + if return_matrix: return pairwise_matrix - + # Aggregate to per-neuron scores scores = self.aggregate_pairwise(pairwise_matrix) - + return scores - + def compute_for_subset( self, neuron_indices: torch.Tensor, @@ -200,12 +201,12 @@ def compute_for_subset( ) -> torch.Tensor: """ Compute scores for a subset of neurons (efficient for large layers). - + Args: neuron_indices: Indices of neurons to score inputs, weights, outputs: As usual **kwargs: Additional arguments - + Returns: Scores for specified neurons only """ diff --git a/src/alignment/metrics/rayleigh/__init__.py b/src/alignment/metrics/rayleigh/__init__.py index fe0c0b88..c143a7f9 100644 --- a/src/alignment/metrics/rayleigh/__init__.py +++ b/src/alignment/metrics/rayleigh/__init__.py @@ -8,4 +8,4 @@ __all__ = [ 'RayleighQuotient', 'RayleighQuotientAlternative', -] \ No newline at end of file +] diff --git a/src/alignment/metrics/rayleigh/delta_alignment.py b/src/alignment/metrics/rayleigh/delta_alignment.py index 2cfa8d5e..412befe2 100644 --- a/src/alignment/metrics/rayleigh/delta_alignment.py +++ b/src/alignment/metrics/rayleigh/delta_alignment.py @@ -5,11 +5,11 @@ with the input covariance structure. """ -from typing import Optional, Any, Dict -import torch import logging +from typing import Any, Dict, Optional + +import torch -from ...core.base import BaseMetric from ...core.registry import register_metric from .rayleigh_quotient import RayleighQuotient @@ -20,14 +20,14 @@ class DeltaAlignment(RayleighQuotient): """ Delta Alignment metric. - + Computes the Rayleigh Quotient of the weight changes (W_current - W_initial) with respect to the input covariance. This measures how the learned weight changes align with the input data structure. - + This metric requires the model to store initial weights for comparison. """ - + def __init__( self, relative: bool = True, @@ -37,7 +37,7 @@ def __init__( ): """ Initialize the Delta Alignment metric. - + Args: relative: Whether to normalize by trace(C) for relative alignment min_samples: Minimum samples required for covariance computation @@ -46,17 +46,17 @@ def __init__( """ super().__init__(relative, min_samples, scale_by_norm, **config) self._initial_weights: Dict[str, torch.Tensor] = {} - + def set_initial_weights(self, layer_name: str, weights: torch.Tensor) -> None: """ Store initial weights for a layer. - + Args: layer_name: Name of the layer weights: Initial weight tensor """ self._initial_weights[layer_name] = weights.clone().detach() - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -68,7 +68,7 @@ def compute( ) -> torch.Tensor: """ Compute Delta Alignment values for each neuron. - + Args: inputs: Input activations [batch_size, input_features] weights: Current layer weights [output_features, input_features] @@ -76,13 +76,13 @@ def compute( layer_name: Name of the layer (for retrieving stored initial weights) initial_weights: Initial weights (if provided directly) **kwargs: Additional parameters - + Returns: Delta alignment values for each output neuron [output_features] """ if inputs is None or weights is None: raise ValueError("DeltaAlignment requires both inputs and weights") - + # Get initial weights if initial_weights is None: if layer_name is None or layer_name not in self._initial_weights: @@ -93,7 +93,7 @@ def compute( initial_weights = torch.zeros_like(weights) else: initial_weights = self._initial_weights[layer_name] - + # Ensure shapes match if initial_weights.shape != weights.shape: logger.warning( @@ -106,10 +106,10 @@ def compute( # If sizes don't match, use zeros logger.warning("DeltaAlignment: Cannot reshape initial weights. Using zeros.") initial_weights = torch.zeros_like(weights) - + # Compute weight difference weight_diff = weights - initial_weights - + # Use parent class RQ computation on the weight differences return super().compute(inputs=inputs, weights=weight_diff, outputs=None, **kwargs) @@ -118,11 +118,11 @@ def compute( class NormalizedDeltaAlignment(DeltaAlignment): """ Normalized Delta Alignment metric. - + Normalizes the delta alignment by the magnitude of weight changes, providing a scale-invariant measure of alignment. """ - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -134,7 +134,7 @@ def compute( ) -> torch.Tensor: """ Compute normalized delta alignment values. - + Returns: Normalized delta alignment values [output_features] """ @@ -147,22 +147,22 @@ def compute( initial_weights=initial_weights, **kwargs ) - + # Get initial weights for normalization if initial_weights is None: if layer_name is None or layer_name not in self._initial_weights: initial_weights = torch.zeros_like(weights) else: initial_weights = self._initial_weights[layer_name] - + # Compute weight change magnitudes weight_diff = weights - initial_weights weight_change_norm = torch.norm(weight_diff, dim=1) - + # Normalize by weight change magnitude eps = 1e-12 normalized_delta = torch.zeros_like(delta_rq) valid_mask = weight_change_norm > eps normalized_delta[valid_mask] = delta_rq[valid_mask] / weight_change_norm[valid_mask] - - return normalized_delta \ No newline at end of file + + return normalized_delta diff --git a/src/alignment/metrics/rayleigh/rayleigh_quotient.py b/src/alignment/metrics/rayleigh/rayleigh_quotient.py index a522ee5c..9a0767b8 100644 --- a/src/alignment/metrics/rayleigh/rayleigh_quotient.py +++ b/src/alignment/metrics/rayleigh/rayleigh_quotient.py @@ -1,13 +1,14 @@ """ Rayleigh Quotient alignment metric implementation. -This metric measures how well neural network weights align with the +This metric measures how well neural network weights align with the principal components of their input activations. """ -from typing import Optional, Any, Union, Dict -import torch import logging +from typing import Any, Dict, Optional, Union + +import torch from ...core.base import BaseMetric from ...core.registry import register_metric @@ -19,18 +20,18 @@ class RayleighQuotient(BaseMetric): """ Rayleigh Quotient alignment metric. - + Computes the proportion of variance in input activations that is captured by each neuron's weight vector. This measures how well the weights align with the covariance structure of the inputs. - + For weight vector w and input covariance C: RQ(w) = (w^T C w) / (w^T w) - + When relative=True (default), normalizes by trace(C) to get a proportion of total variance. """ - + def __init__( self, relative: bool = True, @@ -42,7 +43,7 @@ def __init__( ): """ Initialize the Rayleigh Quotient metric. - + Args: relative: Whether to normalize by trace(C) for relative alignment min_samples: Minimum samples required for covariance computation @@ -57,19 +58,19 @@ def __init__( self.regularization = regularization # Optional: class-conditioned covariance support (targets provided at compute time preferred) self._cc_targets = class_conditioned_targets - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return True - + @property def requires_outputs(self) -> bool: return False - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -80,33 +81,33 @@ def compute( ) -> torch.Tensor: """ Compute Rayleigh Quotient values for each neuron. - + Args: inputs: Input activations [batch_size, input_features] or [batch_size, features, patches] weights: Layer weights [output_features, input_features] outputs: Not used for this metric **kwargs: Additional parameters - + Returns: RQ values for each output neuron [output_features] """ if inputs is None or weights is None: raise ValueError("RayleighQuotient requires both inputs and weights") - + # Handle patchwise inputs (3D tensors) if inputs.ndim == 3: # Compute patchwise RQ return self._compute_patchwise(inputs, weights, **kwargs) - + # Validate shapes for standard 2D computation if inputs.ndim != 2: inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim != 2: weights = weights.reshape(weights.shape[0], -1) - + batch_size, input_features = inputs.shape output_features, weight_features = weights.shape - + # Check sample size if batch_size < self.min_samples: logger.warning( @@ -114,7 +115,7 @@ def compute( "Returning zeros." ) return torch.zeros(output_features, device=weights.device, dtype=weights.dtype) - + # Check dimension compatibility if input_features != weight_features: logger.warning( @@ -124,7 +125,7 @@ def compute( min_dim = min(input_features, weight_features) inputs = inputs[:, :min_dim] weights = weights[:, :min_dim] - + # Move to appropriate device for computation compute_device = weights.device if self._should_use_cpu(inputs, weights): @@ -132,7 +133,7 @@ def compute( compute_device = torch.device('cpu') inputs = inputs.cpu() weights = weights.cpu() - + # Optionally compute class-conditioned covariance and average use_class_cond = targets is not None or self._cc_targets is not None if use_class_cond: @@ -161,33 +162,33 @@ def compute( # Compute covariance matrix inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) cov = torch.matmul(inputs_centered.T, inputs_centered) / (batch_size - 1) - + # Add regularization to diagonal for numerical stability if self.regularization > 0: cov = cov + self.regularization * torch.eye( input_features, device=cov.device, dtype=cov.dtype ) - + # Scale covariance by norm if requested if self.scale_by_norm: cov_norm = torch.norm(cov, p='fro') if cov_norm > 0: cov = cov / cov_norm - + # Compute RQ for each neuron efficiently # w^T C w = sum((w @ C) * w, dim=1) wc = torch.matmul(weights, cov) # [output_features, input_features] numerator = torch.sum(wc * weights, dim=1) # [output_features] - + # w^T w denominator = torch.sum(weights * weights, dim=1) # [output_features] - + # Compute RQ with numerical stability eps = 1e-12 rq_values = torch.zeros_like(numerator) valid_mask = denominator > eps rq_values[valid_mask] = numerator[valid_mask] / denominator[valid_mask] - + # Normalize by trace if relative if self.relative: trace_cov = torch.trace(cov) @@ -195,16 +196,16 @@ def compute( rq_values = rq_values / trace_cov else: logger.warning("RQ: Covariance trace near zero, cannot compute relative RQ") - + # Move back to original device if compute_device != weights.device: rq_values = rq_values.to(weights.device) - + # Handle any numerical issues rq_values = torch.nan_to_num(rq_values, nan=0.0, posinf=0.0, neginf=0.0) - + return rq_values - + def compute_class_conditioned( self, inputs: torch.Tensor, @@ -215,20 +216,20 @@ def compute_class_conditioned( ) -> Union[torch.Tensor, Dict[str, torch.Tensor]]: """ Compute class-conditioned Rayleigh Quotient. - + For each class c, computes RQ using class-specific covariance Σ_{X|y=c}, then returns the average across classes weighted by class frequency. - + Optionally also computes ΔRQ = RQ(unconditional) - E[RQ(class-conditioned)], which measures how much alignment varies across classes. - + Args: inputs: Input activations [batch_size, input_features] weights: Layer weights [output_features, input_features] targets: Class labels [batch_size] return_delta_rq: If True, also return ΔRQ **kwargs: Additional parameters - + Returns: If return_delta_rq=False: class-conditioned RQ [output_features] If return_delta_rq=True: dict with keys 'rq_uncond', 'rq_cond', 'delta_rq' @@ -238,97 +239,97 @@ def compute_class_conditioned( inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) - + # Ensure targets are 1D if targets.ndim > 1: targets = targets.squeeze() - + batch_size, input_features = inputs.shape output_features, weight_features = weights.shape - + # Check compatibility if input_features != weight_features: min_dim = min(input_features, weight_features) inputs = inputs[:, :min_dim] weights = weights[:, :min_dim] input_features = min_dim - + device = weights.device - + # Get unique classes classes = torch.unique(targets) - + # Compute class-conditioned RQ (weighted average) rq_cond_sum = torch.zeros(output_features, device=device) total_weight = 0.0 - + for c in classes: mask = (targets == c) n_c = mask.sum() - + if n_c < self.min_samples: logger.warning(f"Class {c}: only {n_c} samples, skipping") continue - + # Extract class data inputs_c = inputs[mask] - + # Compute class-specific covariance inputs_c_centered = inputs_c - inputs_c.mean(dim=0, keepdim=True) cov_c = (inputs_c_centered.T @ inputs_c_centered) / max(1, n_c - 1) - + # Add regularization if self.regularization > 0: cov_c = cov_c + self.regularization * torch.eye( input_features, device=device, dtype=cov_c.dtype ) - + # Compute RQ for this class wc = torch.matmul(weights, cov_c) numerator_c = torch.sum(wc * weights, dim=1) denominator_c = torch.sum(weights * weights, dim=1) - + eps = 1e-12 rq_c = torch.zeros_like(numerator_c) valid_mask = denominator_c > eps rq_c[valid_mask] = numerator_c[valid_mask] / denominator_c[valid_mask] - + # Normalize by trace if relative if self.relative: trace_c = torch.trace(cov_c) if trace_c > eps: rq_c = rq_c / trace_c - + # Weighted sum weight_c = n_c.float() rq_cond_sum += rq_c * weight_c total_weight += weight_c - + # Average across classes if total_weight > 0: rq_cond = rq_cond_sum / total_weight else: logger.warning("No valid classes found, returning zeros") rq_cond = torch.zeros(output_features, device=device) - + # Clean up numerical issues rq_cond = torch.nan_to_num(rq_cond, nan=0.0, posinf=0.0, neginf=0.0) - + if not return_delta_rq: return rq_cond - + # Also compute unconditional RQ rq_uncond = self.compute(inputs=inputs, weights=weights, **kwargs) - + # Compute ΔRQ delta_rq = rq_uncond - rq_cond - + return { 'rq_uncond': rq_uncond, 'rq_cond': rq_cond, 'delta_rq': delta_rq } - + def _compute_patchwise( self, inputs: torch.Tensor, @@ -338,50 +339,50 @@ def _compute_patchwise( ) -> torch.Tensor: """ Compute patch-wise RQ for CNN layers. - + Args: inputs: Input patches [batch_size, features, num_patches] weights: Flattened weights [output_features, features] weight_by_variance: Whether to weight patches by their variance - + Returns: RQ values [output_features] """ batch_size, features, num_patches = inputs.shape output_features = weights.shape[0] - + if batch_size < self.min_samples: logger.warning(f"Only {batch_size} samples, minimum {self.min_samples} recommended") return torch.zeros(output_features, device=weights.device, dtype=weights.dtype) - + # Ensure weight dimensions match if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) - + # Compute variance for each patch patch_var = torch.var(inputs, dim=0, keepdim=False) # [features, num_patches] patch_total_var = patch_var.sum(dim=0) # [num_patches] - + # Initialize accumulators weighted_rq_sum = torch.zeros(output_features, device=weights.device) total_weight = 0.0 - + # Compute RQ for each patch for p in range(num_patches): patch_data = inputs[:, :, p] # [batch_size, features] - + # Center the data patch_data_centered = patch_data - patch_data.mean(dim=0, keepdim=True) - + # Compute covariance for this patch patch_cov = torch.matmul(patch_data_centered.T, patch_data_centered) / (batch_size - 1) - + # Scale by norm if requested if self.scale_by_norm: cov_norm = torch.norm(patch_cov, p='fro') if cov_norm > 0: patch_cov = patch_cov / cov_norm - + # Handle dimension mismatch min_dim = min(features, weights.shape[1]) if features != weights.shape[1]: @@ -389,38 +390,38 @@ def _compute_patchwise( weights_adj = weights[:, :min_dim] else: weights_adj = weights - + # Compute RQ for this patch wc = torch.matmul(weights_adj, patch_cov) numerator = torch.sum(wc * weights_adj, dim=1) denominator = torch.sum(weights_adj * weights_adj, dim=1) - + eps = 1e-12 patch_rq = torch.zeros_like(numerator) valid_mask = denominator > eps patch_rq[valid_mask] = numerator[valid_mask] / denominator[valid_mask] - + # Normalize by trace if relative if self.relative: trace = torch.trace(patch_cov) if trace > eps: patch_rq = patch_rq / trace - + # Weight by patch variance if requested if weight_by_variance: patch_weight = patch_total_var[p].item() else: patch_weight = 1.0 - + weighted_rq_sum += patch_rq * patch_weight total_weight += patch_weight - + # Average across patches if total_weight > 0: final_rq = weighted_rq_sum / total_weight else: final_rq = weighted_rq_sum - + return torch.nan_to_num(final_rq, nan=0.0, posinf=0.0, neginf=0.0) @@ -428,11 +429,11 @@ def _compute_patchwise( class PatchWiseRayleighQuotient(RayleighQuotient): """ Patch-wise variant of Rayleigh Quotient for convolutional layers. - + This variant computes RQ separately for each spatial location (patch) and then aggregates the results, weighted by patch variance. """ - + def __init__( self, relative: bool = True, @@ -443,7 +444,7 @@ def __init__( ): """ Initialize patch-wise RQ metric. - + Args: relative: Whether to normalize by trace(C) min_samples: Minimum samples for covariance @@ -453,7 +454,7 @@ def __init__( """ super().__init__(relative, min_samples, scale_by_norm, **config) self.weight_by_variance = weight_by_variance - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -463,61 +464,61 @@ def compute( ) -> torch.Tensor: """ Compute patch-wise RQ for convolutional inputs. - + Args: inputs: Input patches [batch_size, features, num_patches] weights: Convolutional weights [output_channels, input_features] outputs: Not used - + Returns: RQ values for each output channel [output_channels] """ if inputs is None or weights is None: raise ValueError("PatchWiseRQ requires both inputs and weights") - + # Handle different input formats if inputs.ndim == 2: # Regular RQ if not patch format return super().compute(inputs, weights, outputs, **kwargs) - + if inputs.ndim != 3: raise ValueError(f"PatchWiseRQ expects 3D input, got {inputs.ndim}D") - + batch_size, features, num_patches = inputs.shape output_channels = weights.shape[0] - + if batch_size < self.min_samples: return torch.zeros(output_channels, device=weights.device, dtype=weights.dtype) - + # Compute variance for each patch patch_var = torch.var(inputs, dim=0, keepdim=False) # [features, num_patches] patch_total_var = patch_var.sum(dim=0) # [num_patches] - + # Initialize results all_patch_rq = [] all_patch_weights = [] - + # Compute RQ for each patch for p in range(num_patches): patch_data = inputs[:, :, p] # [batch_size, features] - + # Compute RQ for this patch patch_rq = super().compute(patch_data, weights, None) - + # Weight by patch variance if requested if self.weight_by_variance: patch_weight = patch_total_var[p] else: patch_weight = 1.0 - + all_patch_rq.append(patch_rq * patch_weight) all_patch_weights.append(patch_weight) - + # Aggregate across patches total_weight = sum(all_patch_weights) if total_weight > 0: final_rq = torch.stack(all_patch_rq).sum(dim=0) / total_weight else: final_rq = torch.zeros(output_channels, device=weights.device, dtype=weights.dtype) - - return final_rq \ No newline at end of file + + return final_rq diff --git a/src/alignment/metrics/rayleigh/rq_alternative.py b/src/alignment/metrics/rayleigh/rq_alternative.py index 42e922c2..994bbcd0 100644 --- a/src/alignment/metrics/rayleigh/rq_alternative.py +++ b/src/alignment/metrics/rayleigh/rq_alternative.py @@ -2,9 +2,11 @@ Alternative Rayleigh Quotient metric with different normalization. """ -import torch import logging from typing import Optional + +import torch + from ...core.base import BaseMetric logger = logging.getLogger(__name__) @@ -13,28 +15,28 @@ class RayleighQuotientAlternative(BaseMetric): """ Compute Rayleigh Quotient with alternative denominator. - + Instead of using w^T w in the denominator, this uses trace(C) where C is the covariance matrix of inputs. This provides a different normalization that can be more stable in some cases. """ - + name = "rayleigh_quotient_alternative" requires_weights = True requires_inputs = True requires_outputs = False - + def __init__(self, relative: bool = True, epsilon: float = 1e-8): """ Initialize the alternative RQ metric. - + Args: relative: Whether to normalize by trace(C) epsilon: Small value for numerical stability """ self.relative = relative self.epsilon = epsilon - + @torch.no_grad() def compute( self, @@ -45,19 +47,19 @@ def compute( ) -> torch.Tensor: """ Compute alternative RQ scores for each neuron. - + Args: inputs: Input activations [batch_size, num_features] weights: Weight matrix [num_neurons, num_features] outputs: Not used **kwargs: Additional arguments - + Returns: RQ scores per neuron [num_neurons] """ if inputs is None or weights is None: raise ValueError("Alternative RQ requires both inputs and weights") - + # Handle dimensions if inputs.ndim != 2: if inputs.ndim > 2: @@ -65,63 +67,63 @@ def compute( else: logger.warning(f"Inputs have unexpected shape: {inputs.shape}") return torch.zeros(1, device=weights.device) - + if weights.ndim != 2: if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) else: logger.warning(f"Weights have unexpected shape: {weights.shape}") return torch.zeros(1, device=weights.device) - + batch_size, num_features = inputs.shape num_neurons = weights.shape[0] - + # Check dimension compatibility if weights.shape[1] != num_features: min_dim = min(weights.shape[1], num_features) weights = weights[:, :min_dim] inputs = inputs[:, :min_dim] logger.warning(f"Dimension mismatch, truncating to {min_dim} features") - + # Need at least 2 samples for covariance if batch_size < 2: logger.warning(f"Need at least 2 samples, got {batch_size}") return torch.zeros(num_neurons, device=weights.device) - + try: # Compute input covariance inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) C = torch.matmul(inputs_centered.T, inputs_centered) / (batch_size - 1) - + # Check for numerical issues if torch.isnan(C).any() or torch.isinf(C).any(): logger.warning("NaN or Inf in covariance matrix") return torch.zeros(num_neurons, device=weights.device) - + # Compute w^T C w for each neuron # Efficient computation: WC = W @ C, then sum(WC * W, dim=1) WC = torch.matmul(weights, C) numerators = torch.sum(WC * weights, dim=1) - + # Alternative denominator: trace(C) trace_C = torch.trace(C) - + # Initialize scores rq_scores = torch.zeros(num_neurons, device=weights.device) - + if self.relative and trace_C > self.epsilon: # Normalize by trace(C) rq_scores = numerators / trace_C else: # Just use raw numerators rq_scores = numerators - + # Additional normalization by number of features # This helps make scores comparable across layers rq_scores = rq_scores / num_features - + except Exception as e: logger.error(f"Error computing alternative RQ: {e}") return torch.zeros(num_neurons, device=weights.device) - - return torch.nan_to_num(rq_scores, nan=0.0, posinf=0.0, neginf=0.0) \ No newline at end of file + + return torch.nan_to_num(rq_scores, nan=0.0, posinf=0.0, neginf=0.0) diff --git a/src/alignment/metrics/similarity/__init__.py b/src/alignment/metrics/similarity/__init__.py index ff62c92e..8e713c9b 100644 --- a/src/alignment/metrics/similarity/__init__.py +++ b/src/alignment/metrics/similarity/__init__.py @@ -2,10 +2,15 @@ Similarity-based alignment metrics. """ -from .cosine_similarity import WeightCosineSimilarity as CosineSimilarityFromFile, ActivationCosineSimilarity -from .node_redundancy import NodeRedundancy -from .weight_similarity import WeightCosineSimilarity, WeightDotSimilarity, WeightEuclideanDistance +from .cosine_similarity import ActivationCosineSimilarity +from .cosine_similarity import WeightCosineSimilarity as CosineSimilarityFromFile from .node_correlation import NodeCorrelation +from .node_redundancy import NodeRedundancy +from .weight_similarity import ( + WeightCosineSimilarity, + WeightDotSimilarity, + WeightEuclideanDistance, +) __all__ = [ 'ActivationCosineSimilarity', @@ -14,4 +19,4 @@ 'WeightDotSimilarity', 'WeightEuclideanDistance', 'NodeCorrelation', -] \ No newline at end of file +] diff --git a/src/alignment/metrics/similarity/cosine_similarity.py b/src/alignment/metrics/similarity/cosine_similarity.py index e67f94a9..2729bc74 100644 --- a/src/alignment/metrics/similarity/cosine_similarity.py +++ b/src/alignment/metrics/similarity/cosine_similarity.py @@ -5,10 +5,11 @@ weight vectors or activation patterns. """ -from typing import Optional, Any +import logging +from typing import Any, Optional + import torch import torch.nn.functional as F -import logging from ...core.base import BaseMetric from ...core.registry import register_metric @@ -20,11 +21,11 @@ class WeightCosineSimilarity(BaseMetric): """ Cosine similarity between weight vectors. - + For each neuron, computes the average cosine similarity with other neurons in the same layer, measuring weight alignment. """ - + def __init__( self, normalize: bool = True, @@ -33,7 +34,7 @@ def __init__( ): """ Initialize weight cosine similarity metric. - + Args: normalize: Whether to normalize weights before computing similarity exclude_self: Whether to exclude self-similarity (always 1.0) @@ -42,19 +43,19 @@ def __init__( super().__init__(**config) self.normalize = normalize self.exclude_self = exclude_self - + @property def requires_inputs(self) -> bool: return False - + @property def requires_weights(self) -> bool: return True - + @property def requires_outputs(self) -> bool: return False - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -64,27 +65,27 @@ def compute( ) -> torch.Tensor: """ Compute average cosine similarity for each weight vector. - + Args: inputs: Not used weights: Layer weights [num_neurons, input_features] outputs: Not used - + Returns: Average cosine similarity scores [num_neurons] """ if weights is None: raise ValueError("WeightCosineSimilarity requires weights") - + if weights.ndim != 2: weights = weights.reshape(weights.shape[0], -1) - + num_neurons = weights.shape[0] - + if num_neurons <= 1: logger.warning("WeightCosineSimilarity: Need at least 2 neurons") return torch.ones(num_neurons, device=weights.device, dtype=weights.dtype) - + # Normalize weight vectors if self.normalize: weights_norm = F.normalize(weights, p=2, dim=1) @@ -93,13 +94,13 @@ def compute( weight_norms = torch.norm(weights, p=2, dim=1, keepdim=True) weight_norms = torch.where(weight_norms > 1e-12, weight_norms, torch.ones_like(weight_norms)) weights_norm = weights / weight_norms - + # Compute pairwise cosine similarities similarity_matrix = torch.matmul(weights_norm, weights_norm.T) - + # Compute average similarity for each neuron similarity_scores = torch.zeros(num_neurons, device=weights.device) - + for i in range(num_neurons): if self.exclude_self: # Average similarity with other neurons @@ -110,7 +111,7 @@ def compute( else: # Include self-similarity similarity_scores[i] = similarity_matrix[i].mean() - + return similarity_scores @@ -118,11 +119,11 @@ def compute( class ActivationCosineSimilarity(BaseMetric): """ Cosine similarity between activation patterns. - + Measures how similar the activation patterns of neurons are across the batch dimension. """ - + def __init__( self, min_samples: int = 2, @@ -132,7 +133,7 @@ def __init__( ): """ Initialize activation cosine similarity metric. - + Args: min_samples: Minimum samples for meaningful patterns exclude_self: Whether to exclude self-similarity @@ -143,19 +144,19 @@ def __init__( self.min_samples = min_samples self.exclude_self = exclude_self self.use_outputs = use_outputs - + @property def requires_inputs(self) -> bool: return not self.use_outputs - + @property def requires_weights(self) -> bool: return not self.use_outputs - + @property def requires_outputs(self) -> bool: return self.use_outputs - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -165,12 +166,12 @@ def compute( ) -> torch.Tensor: """ Compute average cosine similarity between activation patterns. - + Args: inputs: Input activations (if use_outputs=False) weights: Layer weights (if use_outputs=False) outputs: Output activations (if use_outputs=True) - + Returns: Average activation similarity scores [num_neurons] """ @@ -182,36 +183,36 @@ def compute( else: if inputs is None or weights is None: raise ValueError("ActivationCosineSimilarity requires inputs and weights when use_outputs=False") - + # Compute activations if inputs.ndim != 2: inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim != 2: weights = weights.reshape(weights.shape[0], -1) - + activations = torch.matmul(inputs, weights.T) - + if activations.ndim != 2: activations = activations.reshape(activations.shape[0], -1) - + batch_size, num_neurons = activations.shape - + if batch_size < self.min_samples: logger.warning(f"ActivationCosineSimilarity: Only {batch_size} samples") return torch.zeros(num_neurons, device=activations.device, dtype=activations.dtype) - + if num_neurons <= 1: return torch.ones(num_neurons, device=activations.device, dtype=activations.dtype) - + # Normalize activation patterns (across batch dimension) activations_norm = F.normalize(activations, p=2, dim=0) # Normalize each neuron's pattern - + # Compute pairwise similarities similarity_matrix = torch.matmul(activations_norm.T, activations_norm) / batch_size - + # Average similarity for each neuron similarity_scores = torch.zeros(num_neurons, device=activations.device) - + for i in range(num_neurons): if self.exclude_self: mask = torch.ones(num_neurons, dtype=torch.bool, device=activations.device) @@ -220,7 +221,7 @@ def compute( similarity_scores[i] = similarity_matrix[i, mask].mean() else: similarity_scores[i] = similarity_matrix[i].mean() - + return similarity_scores @@ -228,11 +229,11 @@ def compute( class WeightActivationAlignment(BaseMetric): """ Alignment between weight vectors and activation covariance. - + Measures how well each weight vector aligns with the principal components of the activation covariance, using cosine similarity. """ - + def __init__( self, n_components: int = 5, @@ -241,7 +242,7 @@ def __init__( ): """ Initialize weight-activation alignment metric. - + Args: n_components: Number of principal components to consider min_samples: Minimum samples for PCA @@ -250,19 +251,19 @@ def __init__( super().__init__(**config) self.n_components = n_components self.min_samples = min_samples - + @property def requires_inputs(self) -> bool: return True - + @property def requires_weights(self) -> bool: return True - + @property def requires_outputs(self) -> bool: return False - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -272,29 +273,29 @@ def compute( ) -> torch.Tensor: """ Compute alignment between weights and activation PCs. - + Returns: Maximum cosine similarity with top PCs [num_neurons] """ if inputs is None or weights is None: raise ValueError("WeightActivationAlignment requires inputs and weights") - + if inputs.ndim != 2: inputs = inputs.reshape(inputs.shape[0], -1) if weights.ndim != 2: weights = weights.reshape(weights.shape[0], -1) - + batch_size, input_features = inputs.shape num_neurons = weights.shape[0] - + if batch_size < self.min_samples: logger.warning(f"WeightActivationAlignment: Only {batch_size} samples") return torch.zeros(num_neurons, device=weights.device, dtype=weights.dtype) - + # Compute input covariance and eigendecomposition inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) cov = torch.matmul(inputs_centered.T, inputs_centered) / (batch_size - 1) - + # Get top eigenvectors try: eigenvalues, eigenvectors = torch.linalg.eigh(cov) @@ -304,15 +305,15 @@ def compute( except Exception as e: logger.warning(f"WeightActivationAlignment: Eigendecomposition failed: {e}") return torch.zeros(num_neurons, device=weights.device, dtype=weights.dtype) - + # Normalize weights and eigenvectors weights_norm = F.normalize(weights, p=2, dim=1) eigenvecs_norm = F.normalize(top_eigenvecs, p=2, dim=0) - + # Compute cosine similarities with each PC similarities = torch.matmul(weights_norm, eigenvecs_norm) # [num_neurons, n_components] - + # Take maximum similarity across PCs for each neuron max_similarities, _ = torch.max(torch.abs(similarities), dim=1) - - return max_similarities \ No newline at end of file + + return max_similarities diff --git a/src/alignment/metrics/similarity/node_correlation.py b/src/alignment/metrics/similarity/node_correlation.py index d2672eb0..00458f04 100644 --- a/src/alignment/metrics/similarity/node_correlation.py +++ b/src/alignment/metrics/similarity/node_correlation.py @@ -2,9 +2,11 @@ Node correlation metric for measuring output correlations. """ -import torch import logging from typing import Optional + +import torch + from ...core.base import BaseMetric logger = logging.getLogger(__name__) @@ -13,27 +15,27 @@ class NodeCorrelation(BaseMetric): """ Compute average correlation between each neuron's output and all other neurons. - + This measures how correlated a neuron's activations are with other neurons in the same layer, which can indicate redundancy or specialization. """ - + name = "node_correlation" requires_weights = False requires_inputs = False requires_outputs = True - + def __init__(self, absolute: bool = True, force_cpu: bool = False): """ Initialize the node correlation metric. - + Args: absolute: Whether to use absolute correlation values force_cpu: Whether to force CPU computation for large operations """ self.absolute = absolute self.force_cpu = force_cpu - + @torch.no_grad() def compute( self, @@ -44,19 +46,19 @@ def compute( ) -> torch.Tensor: """ Compute node correlation scores. - + Args: inputs: Not used weights: Not used outputs: Output activations [batch_size, num_neurons] **kwargs: Additional arguments - + Returns: Average correlation per neuron [num_neurons] """ if outputs is None: raise ValueError("Node correlation requires outputs") - + # Handle different output dimensions if outputs.ndim != 2: if outputs.ndim > 2: @@ -65,63 +67,63 @@ def compute( else: logger.warning(f"Outputs have unexpected shape: {outputs.shape}") return torch.zeros(1, device=outputs.device) - + batch_size, num_neurons = outputs.shape - + # Need at least 2 samples to compute correlation if batch_size < 2: logger.warning(f"Need at least 2 samples for correlation, got {batch_size}") return torch.zeros(num_neurons, device=outputs.device) - + # Single neuron case if num_neurons == 1: return torch.zeros(1, device=outputs.device) - + correlation_scores = torch.zeros(num_neurons, device=outputs.device) - + try: # Compute correlation matrix corr_matrix = self._compute_correlation(outputs) - + # Use absolute values if requested if self.absolute: corr_matrix = torch.abs(corr_matrix) - + # For each neuron, compute average correlation with others for i in range(num_neurons): # Exclude self-correlation mask = torch.ones(num_neurons, dtype=torch.bool, device=outputs.device) mask[i] = False - + if mask.sum() > 0: correlation_scores[i] = corr_matrix[i, mask].mean() - + except Exception as e: logger.error(f"Error computing node correlation: {e}") return torch.zeros(num_neurons, device=outputs.device) - + return torch.nan_to_num(correlation_scores, nan=0.0) - + def _compute_correlation(self, X: torch.Tensor) -> torch.Tensor: """Compute correlation matrix.""" device = X.device - + # Move to CPU if requested and tensor is large if self.force_cpu and X.is_cuda and X.numel() > 1e6: X = X.cpu() - + # Center the data X_centered = X - X.mean(dim=0, keepdim=True) - + # Compute covariance cov = torch.matmul(X_centered.T, X_centered) / (X.size(0) - 1) - + # Compute standard deviations std = torch.sqrt(torch.diag(cov) + 1e-10) - + # Handle zero variance neurons valid_mask = std > 1e-10 - + # Compute correlation corr = torch.zeros_like(cov) if valid_mask.any(): @@ -131,12 +133,12 @@ def _compute_correlation(self, X: torch.Tensor) -> torch.Tensor: corr[valid_indices[:, None], valid_indices] = ( cov[valid_indices[:, None], valid_indices] / outer_std ) - + # Set diagonal to 1 for valid neurons corr.diagonal().copy_(valid_mask.float()) - + # Move back to original device if corr.device != device: corr = corr.to(device) - - return corr \ No newline at end of file + + return corr diff --git a/src/alignment/metrics/similarity/node_redundancy.py b/src/alignment/metrics/similarity/node_redundancy.py index 54d41e2f..c0d5f1b0 100644 --- a/src/alignment/metrics/similarity/node_redundancy.py +++ b/src/alignment/metrics/similarity/node_redundancy.py @@ -2,9 +2,11 @@ Node redundancy metric for measuring input feature correlations. """ -import torch import logging -from typing import Optional, Dict, Any +from typing import Optional + +import torch + from ...core.base import BaseMetric logger = logging.getLogger(__name__) @@ -13,25 +15,25 @@ class NodeRedundancy(BaseMetric): """ Compute redundancy between input features based on correlation of activations. - + This represents feature redundancy rather than per-output-node scores. For each feature, it computes the average absolute correlation with other features. """ - + name = "node_redundancy" requires_weights = False requires_inputs = True requires_outputs = False - + def __init__(self, force_cpu: bool = False): """ Initialize the node redundancy metric. - + Args: force_cpu: Whether to force CPU computation for large operations """ self.force_cpu = force_cpu - + @torch.no_grad() def compute( self, @@ -42,19 +44,19 @@ def compute( ) -> torch.Tensor: """ Compute node redundancy scores. - + Args: inputs: Input activations [batch_size, num_features] weights: Not used outputs: Not used **kwargs: Additional arguments - + Returns: Redundancy scores per input feature [num_features] """ if inputs is None: raise ValueError("Node redundancy requires inputs") - + # Handle different input dimensions if inputs.ndim != 2: if inputs.ndim > 2: @@ -63,65 +65,65 @@ def compute( else: logger.warning(f"Input has unexpected shape: {inputs.shape}") return torch.zeros(1, device=inputs.device) - + batch_size, num_features = inputs.shape - + # Need at least 2 samples to compute correlation if batch_size < 2: logger.warning(f"Need at least 2 samples for correlation, got {batch_size}") return torch.zeros(num_features, device=inputs.device) - + # Single feature case if num_features == 1: return torch.zeros(1, device=inputs.device) - + redundancy_scores = torch.zeros(num_features, device=inputs.device) - + try: # Compute correlation matrix corr_matrix = self._compute_correlation(inputs) - + # Take absolute values (we care about strength, not direction) abs_corr = torch.abs(corr_matrix) - + # For each feature, compute average correlation with other features for i in range(num_features): # Exclude self-correlation (always 1.0) mask = torch.ones(num_features, dtype=torch.bool, device=inputs.device) mask[i] = False - + if mask.sum() > 0: redundancy_scores[i] = abs_corr[i, mask].mean() - + except Exception as e: logger.error(f"Error computing node redundancy: {e}") return torch.zeros(num_features, device=inputs.device) - + return torch.nan_to_num(redundancy_scores, nan=0.0) - + def _compute_correlation(self, X: torch.Tensor) -> torch.Tensor: """Compute correlation matrix.""" device = X.device - + # Move to CPU if requested and tensor is large if self.force_cpu and X.is_cuda and X.numel() > 1e6: X = X.cpu() - + # Center the data X_centered = X - X.mean(dim=0, keepdim=True) - + # Compute covariance cov = torch.matmul(X_centered.T, X_centered) / (X.size(0) - 1) - + # Compute standard deviations std = torch.sqrt(torch.diag(cov) + 1e-10) - + # Compute correlation outer_std = torch.outer(std, std) corr = torch.where(outer_std > 1e-10, cov / outer_std, torch.zeros_like(cov)) - + # Move back to original device if corr.device != device: corr = corr.to(device) - - return corr \ No newline at end of file + + return corr diff --git a/src/alignment/metrics/similarity/weight_similarity.py b/src/alignment/metrics/similarity/weight_similarity.py index bc2804de..209896e2 100644 --- a/src/alignment/metrics/similarity/weight_similarity.py +++ b/src/alignment/metrics/similarity/weight_similarity.py @@ -2,9 +2,11 @@ Weight similarity metrics for measuring relationships between neuron weight vectors. """ -import torch import logging from typing import Optional + +import torch + from ...core.base import BaseMetric logger = logging.getLogger(__name__) @@ -12,11 +14,11 @@ class WeightSimilarityBase(BaseMetric): """Base class for weight similarity metrics.""" - + requires_weights = True requires_inputs = False requires_outputs = False - + @torch.no_grad() def compute( self, @@ -27,19 +29,19 @@ def compute( ) -> torch.Tensor: """ Compute weight similarity scores. - + Args: inputs: Not used weights: Weight matrix [num_neurons, num_features] outputs: Not used **kwargs: Additional arguments - + Returns: Similarity scores per neuron [num_neurons] """ if weights is None: raise ValueError(f"{self.name} requires weights") - + # Handle different weight dimensions if weights.ndim != 2: if weights.ndim > 2: @@ -48,14 +50,14 @@ def compute( else: logger.warning(f"Weights have unexpected shape: {weights.shape}") return torch.zeros(1, device=weights.device) - + num_neurons = weights.shape[0] - + if num_neurons <= 1: return torch.zeros(num_neurons, device=weights.device) - + return self._compute_similarity(weights) - + def _compute_similarity(self, weights: torch.Tensor) -> torch.Tensor: """Compute similarity scores. To be implemented by subclasses.""" raise NotImplementedError @@ -65,14 +67,14 @@ class WeightCosineSimilarity(WeightSimilarityBase): """ Compute average cosine similarity between each neuron's weights and all others. """ - + name = "weight_cosine_similarity" - + def _compute_similarity(self, weights: torch.Tensor) -> torch.Tensor: """Compute average cosine similarity for each neuron.""" num_neurons = weights.shape[0] similarity_scores = torch.zeros(num_neurons, device=weights.device) - + # Normalize weight vectors weight_norms = torch.norm(weights, dim=1, keepdim=True) normalized_weights = torch.where( @@ -80,17 +82,17 @@ def _compute_similarity(self, weights: torch.Tensor) -> torch.Tensor: weights / weight_norms, torch.zeros_like(weights) ) - + # Compute pairwise cosine similarities cosine_sim_matrix = torch.matmul(normalized_weights, normalized_weights.T) - + # Average similarity with other neurons (excluding self) for i in range(num_neurons): mask = torch.ones(num_neurons, dtype=torch.bool, device=weights.device) mask[i] = False if mask.sum() > 0: similarity_scores[i] = cosine_sim_matrix[i, mask].mean() - + return similarity_scores @@ -98,24 +100,24 @@ class WeightDotSimilarity(WeightSimilarityBase): """ Compute average dot product between each neuron's weights and all others. """ - + name = "weight_dot_similarity" - + def _compute_similarity(self, weights: torch.Tensor) -> torch.Tensor: """Compute average dot product for each neuron.""" num_neurons = weights.shape[0] similarity_scores = torch.zeros(num_neurons, device=weights.device) - + # Compute pairwise dot products dot_product_matrix = torch.matmul(weights, weights.T) - + # Average dot product with other neurons (excluding self) for i in range(num_neurons): mask = torch.ones(num_neurons, dtype=torch.bool, device=weights.device) mask[i] = False if mask.sum() > 0: similarity_scores[i] = dot_product_matrix[i, mask].mean() - + return similarity_scores @@ -124,28 +126,28 @@ class WeightEuclideanDistance(WeightSimilarityBase): Compute average Euclidean distance between each neuron's weights and all others. Note: Lower values indicate higher similarity. """ - + name = "weight_euclidean_distance" - + def _compute_similarity(self, weights: torch.Tensor) -> torch.Tensor: """Compute average Euclidean distance for each neuron.""" num_neurons = weights.shape[0] distance_scores = torch.zeros(num_neurons, device=weights.device) - + # Compute pairwise Euclidean distances efficiently # ||w_i - w_j||^2 = ||w_i||^2 + ||w_j||^2 - 2 * w_i @ w_j weight_norms_sq = torch.sum(weights ** 2, dim=1, keepdim=True) dot_products = torch.matmul(weights, weights.T) - + # Distance matrix dist_matrix_sq = weight_norms_sq + weight_norms_sq.T - 2 * dot_products dist_matrix = torch.sqrt(torch.clamp(dist_matrix_sq, min=0)) - + # Average distance to other neurons (excluding self) for i in range(num_neurons): mask = torch.ones(num_neurons, dtype=torch.bool, device=weights.device) mask[i] = False if mask.sum() > 0: distance_scores[i] = dist_matrix[i, mask].mean() - - return distance_scores \ No newline at end of file + + return distance_scores diff --git a/src/alignment/metrics/spectral/__init__.py b/src/alignment/metrics/spectral/__init__.py index 44d86f5e..1719813e 100644 --- a/src/alignment/metrics/spectral/__init__.py +++ b/src/alignment/metrics/spectral/__init__.py @@ -2,24 +2,24 @@ # Phase 3 metrics from .spectral_alignment import ( - SpectralGapMetric, EigenvalueAlignmentMetric, + PowerIterationAlignment, SpectralClusteringAlignment, - PowerIterationAlignment + SpectralGapMetric, ) # Classic spectral metrics from .spectral_classic import ( + EigenvalueEntropy, SpectralAlignment, + SpectralClusteringScore, SpectralNormRatio, - EigenvalueEntropy, - SpectralClusteringScore ) __all__ = [ # Phase 3 metrics 'SpectralGapMetric', - 'EigenvalueAlignmentMetric', + 'EigenvalueAlignmentMetric', 'SpectralClusteringAlignment', 'PowerIterationAlignment', # Classic metrics @@ -27,4 +27,4 @@ 'SpectralNormRatio', 'EigenvalueEntropy', 'SpectralClusteringScore' -] \ No newline at end of file +] diff --git a/src/alignment/metrics/spectral/spectral_alignment.py b/src/alignment/metrics/spectral/spectral_alignment.py index fcb7f33b..a297c2dc 100644 --- a/src/alignment/metrics/spectral/spectral_alignment.py +++ b/src/alignment/metrics/spectral/spectral_alignment.py @@ -1,9 +1,9 @@ """Spectral alignment metrics based on eigenvalue analysis.""" +from typing import Optional + import torch -import torch.nn as nn -from typing import Optional, Dict, Any, Tuple -import numpy as np + from ...core.base import BaseMetric from ...core.registry import register_metric @@ -12,14 +12,14 @@ class SpectralGapMetric(BaseMetric): """ Measures the spectral gap of weight matrices. - + The spectral gap is the difference between the largest and second-largest eigenvalues, normalized by the largest eigenvalue. A larger spectral gap indicates a more dominant principal component. """ - + name = "spectral_gap" - + def __init__(self, normalize: bool = True): """ Args: @@ -27,8 +27,8 @@ def __init__(self, normalize: bool = True): """ super().__init__() self.normalize = normalize - - def compute(self, + + def compute(self, inputs: Optional[torch.Tensor] = None, weights: Optional[torch.Tensor] = None, outputs: Optional[torch.Tensor] = None, @@ -36,7 +36,7 @@ def compute(self, """Compute spectral gap of weight matrix.""" if weights is None: raise ValueError("Weights required for spectral gap metric") - + # Handle different weight shapes if weights.dim() == 4: # Conv weights # Reshape to 2D: (out_channels, in_channels * kernel_size) @@ -46,24 +46,24 @@ def compute(self, weights = weights.mean(dim=0) elif weights.dim() != 2: raise ValueError(f"Unsupported weight dimension: {weights.dim()}") - + # Compute SVD (more stable than eigendecomposition) try: U, S, V = torch.svd(weights) - + if S.numel() < 2: return torch.zeros(weights.size(0), device=weights.device) - + # Spectral gap is difference between top 2 singular values gap = (S[0] - S[1]).item() - + if self.normalize and S[0] > 1e-8: gap = gap / S[0].item() - + # Return as tensor with one value per neuron (repeated) return torch.full((weights.size(0),), gap, device=weights.device) - - except Exception as e: + + except Exception: # Return zeros if SVD fails return torch.zeros(weights.size(0), device=weights.device) @@ -72,13 +72,13 @@ def compute(self, class EigenvalueAlignmentMetric(BaseMetric): """ Measures alignment between eigenvalue distributions of two weight matrices. - + Uses Wasserstein distance between eigenvalue distributions as a measure of spectral similarity. """ - + name = "eigenvalue_alignment" - + def __init__(self, p: float = 2.0, top_k: Optional[int] = None): """ Args: @@ -89,11 +89,11 @@ def __init__(self, p: float = 2.0, top_k: Optional[int] = None): self.p = p self.top_k = top_k self._reference_eigenvalues = None - + def set_reference(self, weights: torch.Tensor): """Set reference weight matrix for comparison.""" self._reference_eigenvalues = self._compute_eigenvalues(weights) - + def _compute_eigenvalues(self, weights: torch.Tensor) -> torch.Tensor: """Compute eigenvalues of weight matrix.""" # Handle different weight shapes @@ -103,15 +103,15 @@ def _compute_eigenvalues(self, weights: torch.Tensor) -> torch.Tensor: weights = weights.mean(dim=0) elif weights.dim() != 2: raise ValueError(f"Unsupported weight dimension: {weights.dim()}") - + # Compute singular values (eigenvalues of W^T W) _, S, _ = torch.svd(weights) - + if self.top_k is not None and S.numel() > self.top_k: S = S[:self.top_k] - + return S.sort(descending=True)[0] - + def compute(self, inputs: Optional[torch.Tensor] = None, weights: Optional[torch.Tensor] = None, @@ -120,18 +120,18 @@ def compute(self, """Compute eigenvalue alignment.""" if weights is None: raise ValueError("Weights required for eigenvalue alignment") - + if self._reference_eigenvalues is None: # If no reference, return 0 (perfect alignment with self) return torch.zeros(weights.size(0), device=weights.device) - + current_eigenvalues = self._compute_eigenvalues(weights) - + # Compute Wasserstein distance # For 1D distributions, this simplifies to sorted L^p distance n_ref = len(self._reference_eigenvalues) n_curr = len(current_eigenvalues) - + if n_ref != n_curr: # Pad shorter sequence with zeros max_len = max(n_ref, n_curr) @@ -142,10 +142,10 @@ def compute(self, else: ref_padded = self._reference_eigenvalues curr_padded = current_eigenvalues - + # Wasserstein distance distance = torch.norm(ref_padded - curr_padded, p=self.p).item() - + # Return as tensor with one value per neuron (repeated) return torch.full((weights.size(0),), distance, device=weights.device) @@ -154,13 +154,13 @@ def compute(self, class SpectralClusteringAlignment(BaseMetric): """ Measures how well weight matrix eigenspaces align with data clustering. - + This metric computes the alignment between the top eigenvectors of the weight matrix and the cluster structure in the output space. """ - + name = "spectral_clustering" - + def __init__(self, n_components: int = 5, n_clusters: int = 10): """ Args: @@ -170,7 +170,7 @@ def __init__(self, n_components: int = 5, n_clusters: int = 10): super().__init__() self.n_components = n_components self.n_clusters = n_clusters - + def compute(self, inputs: Optional[torch.Tensor] = None, weights: Optional[torch.Tensor] = None, @@ -179,7 +179,7 @@ def compute(self, """Compute spectral clustering alignment.""" if weights is None or outputs is None: raise ValueError("Both weights and outputs required") - + # Handle different weight shapes if weights.dim() == 4: # Conv weights weights = weights.view(weights.size(0), -1) @@ -187,33 +187,33 @@ def compute(self, weights = weights.mean(dim=0) elif weights.dim() != 2: raise ValueError(f"Unsupported weight dimension: {weights.dim()}") - + # Get top eigenvectors of weight matrix try: U, S, V = torch.svd(weights) top_components = V[:, :self.n_components] # Right singular vectors - + # Project outputs using eigenvectors if outputs.size(-1) != top_components.size(0): # Dimension mismatch - return low alignment return torch.zeros(weights.size(0), device=weights.device) - + projected = outputs @ top_components - + # Compute clustering quality in projected space # Use variance ratio as a simple measure total_var = outputs.var(dim=0).sum().item() projected_var = projected.var(dim=0).sum().item() - + if total_var > 1e-8: alignment = projected_var / total_var else: alignment = 0.0 - + # Return as tensor with one value per neuron (repeated) return torch.full((weights.size(0),), alignment, device=weights.device) - - except Exception as e: + + except Exception: return torch.zeros(weights.size(0), device=weights.device) @@ -221,13 +221,13 @@ def compute(self, class PowerIterationAlignment(BaseMetric): """ Measures alignment using power iteration convergence properties. - + This metric analyzes how quickly power iteration converges to the dominant eigenvector, which indicates the spectral structure of the weight matrix. """ - + name = "power_iteration" - + def __init__(self, max_iterations: int = 100, tolerance: float = 1e-6): """ Args: @@ -237,7 +237,7 @@ def __init__(self, max_iterations: int = 100, tolerance: float = 1e-6): super().__init__() self.max_iterations = max_iterations self.tolerance = tolerance - + def compute(self, inputs: Optional[torch.Tensor] = None, weights: Optional[torch.Tensor] = None, @@ -246,7 +246,7 @@ def compute(self, """Compute power iteration convergence rate.""" if weights is None: raise ValueError("Weights required for power iteration alignment") - + # Handle different weight shapes if weights.dim() == 4: # Conv weights weights = weights.view(weights.size(0), -1) @@ -254,29 +254,29 @@ def compute(self, weights = weights.mean(dim=0) elif weights.dim() != 2: raise ValueError(f"Unsupported weight dimension: {weights.dim()}") - + # Initialize random vector n = weights.size(0) v = torch.randn(n, 1, device=weights.device) v = v / torch.norm(v) - + # Power iteration convergence_rate = 0.0 prev_v = v.clone() - + for i in range(self.max_iterations): # One power iteration step v = weights @ v v = v / torch.norm(v) - + # Check convergence diff = torch.norm(v - prev_v).item() if diff < self.tolerance: # Converged - compute convergence rate convergence_rate = 1.0 / (i + 1) break - + prev_v = v.clone() - + # Return as tensor with one value per neuron (repeated) - return torch.full((n,), convergence_rate, device=weights.device) \ No newline at end of file + return torch.full((n,), convergence_rate, device=weights.device) diff --git a/src/alignment/metrics/spectral/spectral_classic.py b/src/alignment/metrics/spectral/spectral_classic.py index 6b4d2929..2f5e773b 100644 --- a/src/alignment/metrics/spectral/spectral_classic.py +++ b/src/alignment/metrics/spectral/spectral_classic.py @@ -3,33 +3,35 @@ These metrics were originally in metrics/spectral.py """ +from typing import Optional + import torch import torch.linalg as LA -from typing import Optional, Tuple, Dict, Any -from ...core.registry import register_metric + from ...core.base import BaseMetric +from ...core.registry import register_metric @register_metric("spectral_alignment") class SpectralAlignment(BaseMetric): """ Compute spectral alignment based on eigenvalue decomposition of the covariance matrix. - + This metric analyzes how well neuron activations align with the principal components of the input distribution. """ - + name = "spectral_alignment" - + def __init__( - self, + self, n_components: Optional[int] = None, normalize: bool = True, epsilon: float = 1e-8 ): """ Initialize spectral alignment metric. - + Args: n_components: Number of top components to consider (None for all) normalize: Whether to normalize by total variance @@ -39,7 +41,7 @@ def __init__( self.n_components = n_components self.normalize = normalize self.epsilon = epsilon - + def compute( self, inputs: torch.Tensor, @@ -49,55 +51,55 @@ def compute( ) -> torch.Tensor: """ Compute spectral alignment scores. - + Args: inputs: Input activations (batch_size, input_dim) weights: Weight matrix (output_dim, input_dim) outputs: Output activations (optional) - + Returns: Alignment scores for each neuron """ batch_size, input_dim = inputs.shape output_dim, _ = weights.shape - + # Center the inputs inputs_centered = inputs - inputs.mean(dim=0, keepdim=True) - + # Compute input covariance matrix cov_matrix = (inputs_centered.T @ inputs_centered) / (batch_size - 1) cov_matrix = cov_matrix + self.epsilon * torch.eye(input_dim, device=inputs.device) - + # Compute eigenvalue decomposition eigenvalues, eigenvectors = LA.eigh(cov_matrix) - + # Sort by eigenvalues (descending) idx = eigenvalues.argsort(descending=True) eigenvalues = eigenvalues[idx] eigenvectors = eigenvectors[:, idx] - + # Select top components if specified if self.n_components is not None: n_comp = min(self.n_components, input_dim) eigenvalues = eigenvalues[:n_comp] eigenvectors = eigenvectors[:, :n_comp] - + # Compute alignment of each weight vector with principal components # Weight vectors projected onto principal components projections = weights @ eigenvectors # (output_dim, n_components) - + # Weighted by eigenvalues (importance of each component) weighted_projections = projections * eigenvalues.unsqueeze(0) - + # Compute alignment score for each neuron alignment_scores = weighted_projections.norm(dim=1) - + if self.normalize: # Normalize by weight norm and total variance weight_norms = weights.norm(dim=1) total_variance = eigenvalues.sum() alignment_scores = alignment_scores / (weight_norms * total_variance.sqrt() + self.epsilon) - + return alignment_scores @@ -105,22 +107,22 @@ def compute( class SpectralNormRatio(BaseMetric): """ Compute the ratio of spectral norm to Frobenius norm for weight matrices. - + This metric indicates how concentrated the weight matrix is along its principal direction. """ - + name = "spectral_norm_ratio" - + def __init__(self, epsilon: float = 1e-8): """ Initialize spectral norm ratio metric. - + Args: epsilon: Small constant for numerical stability """ super().__init__() self.epsilon = epsilon - + def compute( self, inputs: torch.Tensor, @@ -130,24 +132,24 @@ def compute( ) -> torch.Tensor: """ Compute spectral norm ratio for each layer. - + Args: inputs: Input activations (not used, included for interface compatibility) weights: Weight matrix (output_dim, input_dim) outputs: Output activations (not used) - + Returns: Spectral norm ratio (single value per layer) """ # Compute spectral norm (largest singular value) spectral_norm = LA.matrix_norm(weights, ord=2) - + # Compute Frobenius norm frobenius_norm = weights.norm(p='fro') - + # Compute ratio ratio = spectral_norm / (frobenius_norm + self.epsilon) - + # Return as tensor with one value per neuron (repeated) return ratio.repeat(weights.shape[0]) @@ -156,17 +158,17 @@ def compute( class EigenvalueEntropy(BaseMetric): """ Compute the entropy of eigenvalue distribution of the Gram matrix. - + This metric measures how evenly distributed the neuron activations are across different modes of variation. """ - + name = "eigenvalue_entropy" - + def __init__(self, temperature: float = 1.0, epsilon: float = 1e-8): """ Initialize eigenvalue entropy metric. - + Args: temperature: Temperature parameter for softmax epsilon: Small constant for numerical stability @@ -174,7 +176,7 @@ def __init__(self, temperature: float = 1.0, epsilon: float = 1e-8): super().__init__() self.temperature = temperature self.epsilon = epsilon - + def compute( self, inputs: torch.Tensor, @@ -184,48 +186,48 @@ def compute( ) -> torch.Tensor: """ Compute eigenvalue entropy for neuron activations. - + Args: inputs: Input activations (batch_size, input_dim) weights: Weight matrix (output_dim, input_dim) outputs: Output activations (optional) - + Returns: Entropy values for each neuron """ batch_size = inputs.shape[0] output_dim = weights.shape[0] - + # Compute neuron activations if not provided if outputs is None: outputs = inputs @ weights.T - + entropies = [] - + for i in range(output_dim): # Get activations for this neuron neuron_acts = outputs[:, i].unsqueeze(1) - + # Compute Gram matrix gram = neuron_acts @ neuron_acts.T - + # Add small diagonal for stability gram = gram + self.epsilon * torch.eye(batch_size, device=gram.device) - + # Compute eigenvalues eigenvalues = LA.eigvalsh(gram) eigenvalues = eigenvalues[eigenvalues > self.epsilon] - + # Normalize eigenvalues eigenvalues = eigenvalues / eigenvalues.sum() - + # Apply temperature scaling eigenvalues = eigenvalues / self.temperature - + # Compute entropy entropy = -(eigenvalues * eigenvalues.log()).sum() entropies.append(entropy) - + return torch.stack(entropies) @@ -234,9 +236,9 @@ class SpectralClusteringScore(BaseMetric): """ Compute how well neurons cluster based on spectral analysis of their similarity matrix. """ - + name = "spectral_clustering_score" - + def __init__( self, n_clusters: int = 5, @@ -245,7 +247,7 @@ def __init__( ): """ Initialize spectral clustering score. - + Args: n_clusters: Number of clusters to form similarity_type: Type of similarity ('correlation', 'cosine', 'rbf') @@ -255,7 +257,7 @@ def __init__( self.n_clusters = n_clusters self.similarity_type = similarity_type self.epsilon = epsilon - + def compute( self, inputs: torch.Tensor, @@ -265,17 +267,17 @@ def compute( ) -> torch.Tensor: """ Compute spectral clustering scores. - + Args: inputs: Input activations weights: Weight matrix outputs: Output activations - + Returns: Clustering quality scores for each neuron """ output_dim = weights.shape[0] - + # Compute similarity matrix between neurons if self.similarity_type == "correlation": # Normalize weights @@ -283,41 +285,41 @@ def compute( weights_std = weights_norm.std(dim=1, keepdim=True) weights_norm = weights_norm / (weights_std + self.epsilon) similarity = weights_norm @ weights_norm.T / weights.shape[1] - + elif self.similarity_type == "cosine": weights_norm = weights / (weights.norm(dim=1, keepdim=True) + self.epsilon) similarity = weights_norm @ weights_norm.T - + elif self.similarity_type == "rbf": # RBF kernel similarity dist_matrix = torch.cdist(weights, weights, p=2) sigma = dist_matrix.mean() similarity = torch.exp(-dist_matrix**2 / (2 * sigma**2)) - + else: raise ValueError(f"Unknown similarity type: {self.similarity_type}") - + # Compute degree matrix degree = similarity.sum(dim=1) - + # Compute normalized Laplacian D_sqrt_inv = torch.diag(1.0 / (degree.sqrt() + self.epsilon)) L_norm = torch.eye(output_dim, device=weights.device) - D_sqrt_inv @ similarity @ D_sqrt_inv - + # Compute eigenvalues of normalized Laplacian eigenvalues = LA.eigvalsh(L_norm) - + # Sort eigenvalues eigenvalues = eigenvalues.sort()[0] - + # Compute spectral gap (difference between k-th and (k+1)-th eigenvalue) if self.n_clusters < output_dim: spectral_gap = eigenvalues[self.n_clusters] - eigenvalues[self.n_clusters - 1] else: spectral_gap = eigenvalues[-1] - eigenvalues[-2] - + # Higher spectral gap indicates better clustering # Return as score for each neuron (can be refined to per-neuron scores) scores = torch.full((output_dim,), spectral_gap.item(), device=weights.device) - - return scores \ No newline at end of file + + return scores diff --git a/src/alignment/metrics/task_specific/__init__.py b/src/alignment/metrics/task_specific/__init__.py index 433fd02e..b9b9f256 100644 --- a/src/alignment/metrics/task_specific/__init__.py +++ b/src/alignment/metrics/task_specific/__init__.py @@ -1,26 +1,25 @@ """Task-specific alignment metrics for different domains and objectives.""" # General task alignment -from .general import ( - TaskAlignment, - FeatureImportance, - RepresentationQuality, - ClassSelectivity -) - # Activation-based importance from .activation_magnitude import ( ActivationL2Norm, ActivationMean, ActivationNorm, - ActivationVariance + ActivationVariance, ) # Domain-specific metrics from .classification import ClassificationAlignment +from .general import ( + ClassSelectivity, + FeatureImportance, + RepresentationQuality, + TaskAlignment, +) from .language_model import LanguageModelAlignment -from .vision import VisionTaskAlignment from .reinforcement_learning import ReinforcementLearningAlignment +from .vision import VisionTaskAlignment __all__ = [ # General @@ -38,4 +37,4 @@ 'LanguageModelAlignment', 'VisionTaskAlignment', 'ReinforcementLearningAlignment' -] \ No newline at end of file +] diff --git a/src/alignment/metrics/task_specific/activation_magnitude.py b/src/alignment/metrics/task_specific/activation_magnitude.py index 5d19734d..5b636b50 100644 --- a/src/alignment/metrics/task_specific/activation_magnitude.py +++ b/src/alignment/metrics/task_specific/activation_magnitude.py @@ -5,10 +5,10 @@ commonly used in pruning literature including TensorRT and NeMo. """ -from typing import Optional, Any -import torch -import torch.nn.functional as F import logging +from typing import Any, Optional + +import torch from ...core.base import BaseMetric from ...core.registry import register_metric @@ -20,25 +20,25 @@ class ActivationL2Norm(BaseMetric): """ Compute neuron importance as L2 norm of activations. - + This metric is commonly used in LLM pruning (e.g., TensorRT-LLM, NeMo). For each neuron, computes: sqrt(sum_over_batch(mean_over_seq(|activations|)^2)) - + This is equivalent to the metric used in: - NVIDIA NeMo pruning - TensorRT-LLM pruning - Various LLM compression papers - + Args: aggregate_method: How to aggregate activations ('l2', 'mean', 'max') use_absolute: Whether to take absolute value before aggregation """ - + name = "activation_l2_norm" requires_inputs = True requires_weights = False requires_outputs = True - + def __init__( self, aggregate_method: str = "l2", @@ -47,7 +47,7 @@ def __init__( super().__init__() self.aggregate_method = aggregate_method self.use_absolute = use_absolute - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -57,12 +57,12 @@ def compute( ) -> torch.Tensor: """ Compute activation-based importance scores. - + Args: inputs: Input activations [batch_size, input_dim] or [seq_len, batch_size, input_dim] weights: Weight matrix (not used, but kept for interface compatibility) outputs: Output activations [batch_size, num_neurons] or [seq_len, batch_size, num_neurons] - + Returns: Importance scores [num_neurons] """ @@ -80,43 +80,43 @@ def compute( raise ValueError(f"Unsupported input shape: {inputs.shape}") else: raise ValueError("Must provide either outputs or (inputs + weights)") - + # Handle different input shapes if activations.ndim == 3: # [seq_len, batch_size, num_neurons] - typical for transformers # This matches PruneLLM's format - + if self.use_absolute: activations = activations.abs() - + # Mean over sequence dimension activations = activations.mean(dim=0) # [batch_size, num_neurons] - + elif activations.ndim == 2: # [batch_size, num_neurons] - typical for MLPs/CNNs if self.use_absolute: activations = activations.abs() else: raise ValueError(f"Unsupported activation shape: {activations.shape}") - + # Now activations is [batch_size, num_neurons] # Compute importance based on method if self.aggregate_method == "l2": # L2 norm across batch: sqrt(sum(x^2)) # This matches PruneLLM exactly: activations.pow(2).sum(dim=0).sqrt() importance = activations.pow(2).sum(dim=0).sqrt() - + elif self.aggregate_method == "mean": # Mean activation magnitude importance = activations.mean(dim=0) - + elif self.aggregate_method == "max": # Max activation magnitude importance = activations.max(dim=0)[0] - + else: raise ValueError(f"Unknown aggregate_method: {self.aggregate_method}") - + return importance @@ -124,13 +124,13 @@ def compute( class ActivationMean(ActivationL2Norm): """ Compute neuron importance as mean absolute activation. - + For each neuron, computes the average magnitude of activations across all samples in the batch. """ - + name = "activation_mean" - + def __init__(self): super().__init__(aggregate_method="mean", use_absolute=True) @@ -139,17 +139,17 @@ def __init__(self): class ActivationNorm(ActivationL2Norm): """ Compute neuron importance as L2 norm of activations. - + For each neuron, computes: sqrt(sum_over_batch(mean_over_seq(|activations|)^2)) - + This is a standard metric used in: - LLM pruning (TensorRT-LLM, NeMo) - Neural architecture search - Channel pruning for CNNs - + Formula: importance[n] = ||activations[:, n]||_2 - + For transformers with shape [seq_len, batch, neurons]: 1. Take absolute value: |activations| 2. Mean over sequence: mean(dim=0) -> [batch, neurons] @@ -157,9 +157,9 @@ class ActivationNorm(ActivationL2Norm): 4. Sum over batch: sum(dim=0) -> [neurons] 5. Square root: sqrt() -> [neurons] """ - + name = "activation_norm" - + def __init__(self): super().__init__(aggregate_method="l2", use_absolute=True) @@ -168,15 +168,15 @@ def __init__(self): class ActivationVariance(BaseMetric): """ Compute neuron importance as variance of activations. - + High variance neurons are more selective/informative. """ - + name = "activation_variance" requires_inputs = True requires_weights = False requires_outputs = True - + def compute( self, inputs: Optional[torch.Tensor] = None, @@ -195,14 +195,14 @@ def compute( activations = torch.matmul(inputs, weights.T) else: raise ValueError("Must provide either outputs or (inputs + weights)") - + # Handle 3D activations (seq_len, batch, neurons) if activations.ndim == 3: # Combine seq and batch dimensions activations = activations.reshape(-1, activations.shape[-1]) - + # Compute variance per neuron variance = activations.var(dim=0) - + return variance diff --git a/src/alignment/metrics/task_specific/classification.py b/src/alignment/metrics/task_specific/classification.py index f90f005e..2a441830 100644 --- a/src/alignment/metrics/task_specific/classification.py +++ b/src/alignment/metrics/task_specific/classification.py @@ -2,27 +2,29 @@ Classification-specific alignment metrics. """ +from typing import Optional + import torch import torch.nn.functional as F -from typing import Optional, Dict, Any -from ...core.registry import register_metric + from ...core.base import BaseMetric +from ...core.registry import register_metric @register_metric("classification_alignment") class ClassificationAlignment(BaseMetric): """ Measures alignment specifically for classification tasks. - + This metric evaluates how well neuron activations align with class boundaries and decision surfaces in classification problems. """ - + name = "classification_alignment" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, n_classes: int, @@ -31,7 +33,7 @@ def __init__( ): """ Initialize classification alignment metric. - + Args: n_classes: Number of classes in the classification task alignment_type: Type of alignment measure @@ -44,7 +46,7 @@ def __init__( self.n_classes = n_classes self.alignment_type = alignment_type self.temperature = temperature - + def compute( self, inputs: torch.Tensor, @@ -56,40 +58,40 @@ def compute( ) -> torch.Tensor: """ Compute classification alignment scores. - + Args: inputs: Input activations [batch_size, input_dim] weights: Weight matrix [output_dim, input_dim] outputs: Output activations [batch_size, output_dim] labels: True class labels [batch_size] logits: Classification logits [batch_size, n_classes] - + Returns: Alignment scores for each neuron [output_dim] """ if outputs is None: outputs = inputs @ weights.T - + n_neurons = outputs.shape[1] alignment_scores = torch.zeros(n_neurons, device=outputs.device) - + # Generate synthetic labels if not provided if labels is None: # Use k-means clustering on outputs labels = self._generate_labels(outputs, self.n_classes) - + # Generate logits if not provided if logits is None: # Simple linear projection from outputs to classes projection = torch.randn(outputs.shape[1], self.n_classes, device=outputs.device) logits = outputs @ projection - + if self.alignment_type == "boundary_distance": # Measure how neuron activations change near decision boundaries probabilities = F.softmax(logits / self.temperature, dim=1) # Add small epsilon to avoid log(0) entropy = -(probabilities * (probabilities + 1e-8).log()).sum(dim=1) - + # High entropy indicates proximity to decision boundary # Compute correlation between neuron activation and boundary proximity for i in range(n_neurons): @@ -105,78 +107,78 @@ def compute( alignment_scores[i] = correlation.abs() else: alignment_scores[i] = 0.0 - + elif self.alignment_type == "class_separation": # Measure how well neurons separate different classes for i in range(n_neurons): neuron_activations = outputs[:, i] - + # Compute mean activation per class class_means = [] class_stds = [] - + for c in range(self.n_classes): class_mask = labels == c if class_mask.sum() > 0: class_acts = neuron_activations[class_mask] class_means.append(class_acts.mean()) class_stds.append(class_acts.std()) - + if len(class_means) > 1: class_means = torch.stack(class_means) class_stds = torch.stack(class_stds) - + # Fisher's discriminant ratio between_class_var = class_means.var() within_class_var = class_stds.mean() ** 2 - + if within_class_var > 0: separation = between_class_var / within_class_var else: separation = between_class_var - + alignment_scores[i] = separation - + elif self.alignment_type == "confidence_alignment": # Measure alignment with prediction confidence probabilities = F.softmax(logits / self.temperature, dim=1) - + # Get confidence (max probability) and correctness confidence, predictions = probabilities.max(dim=1) correct = (predictions == labels).float() - + # For each neuron, measure correlation with correct confident predictions for i in range(n_neurons): neuron_activations = outputs[:, i] - + # Confidence-weighted correctness weighted_correct = correct * confidence - + # Correlation between neuron activation and confident correct predictions if neuron_activations.std() > 0 and weighted_correct.std() > 0: correlation = torch.corrcoef( torch.stack([neuron_activations, weighted_correct]) )[0, 1] alignment_scores[i] = correlation.abs() - + else: raise ValueError(f"Unknown alignment type: {self.alignment_type}") - + return alignment_scores - + def _generate_labels(self, outputs: torch.Tensor, n_classes: int) -> torch.Tensor: """Generate synthetic labels using k-means clustering.""" # Simple k-means centroids = outputs[torch.randperm(outputs.shape[0])[:n_classes]] - + for _ in range(10): # iterations distances = torch.cdist(outputs, centroids) labels = distances.argmin(dim=1) - + # Update centroids for i in range(n_classes): mask = labels == i if mask.sum() > 0: centroids[i] = outputs[mask].mean(dim=0) - - return labels \ No newline at end of file + + return labels diff --git a/src/alignment/metrics/task_specific/general.py b/src/alignment/metrics/task_specific/general.py index ca18c688..437c9417 100644 --- a/src/alignment/metrics/task_specific/general.py +++ b/src/alignment/metrics/task_specific/general.py @@ -2,27 +2,29 @@ General task-specific alignment metrics that can be customized for different downstream tasks. """ +from typing import Callable, Optional + import torch import torch.nn.functional as F -from typing import Optional, Callable, Dict, Any, Union -from ...core.registry import register_metric + from ...core.base import BaseMetric +from ...core.registry import register_metric @register_metric("task_alignment") class TaskAlignment(BaseMetric): """ Compute alignment with respect to a specific task's target function. - + This metric measures how well neuron activations align with gradients or importance scores from a downstream task. """ - + name = "task_alignment" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, task_loss_fn: Optional[Callable] = None, @@ -31,7 +33,7 @@ def __init__( ): """ Initialize task alignment metric. - + Args: task_loss_fn: Loss function for the task (if None, uses MSE) alignment_type: Type of alignment ('gradient', 'taylor', 'integrated_gradients') @@ -41,7 +43,7 @@ def __init__( self.task_loss_fn = task_loss_fn or F.mse_loss self.alignment_type = alignment_type self.normalize = normalize - + def compute( self, inputs: torch.Tensor, @@ -52,80 +54,80 @@ def compute( ) -> torch.Tensor: """ Compute task-specific alignment scores. - + Args: inputs: Input activations weights: Weight matrix outputs: Output activations targets: Target values for the task - + Returns: Task alignment scores for each neuron """ if outputs is None: outputs = inputs @ weights.T - + # If no targets provided, create dummy targets if targets is None: # Use outputs themselves as targets (self-supervised) targets = outputs.detach() - + # Ensure inputs require gradients inputs_grad = inputs.detach().requires_grad_(True) outputs_grad = inputs_grad @ weights.T - + if self.alignment_type == "gradient": # Compute task loss loss = self.task_loss_fn(outputs_grad, targets) - + # Compute gradients with respect to inputs grads = torch.autograd.grad(loss, inputs_grad, retain_graph=True)[0] - + # Compute alignment as dot product between weights and input gradients alignment_scores = torch.abs((weights @ grads.T).mean(dim=1)) - + elif self.alignment_type == "taylor": # First-order Taylor expansion importance loss = self.task_loss_fn(outputs_grad, targets) - + # Compute gradients with respect to outputs output_grads = torch.autograd.grad(loss, outputs_grad, retain_graph=True)[0] - + # Taylor importance: |grad * activation| importance = torch.abs(output_grads * outputs_grad) alignment_scores = importance.mean(dim=0) - + elif self.alignment_type == "integrated_gradients": # Integrated gradients from baseline to current inputs baseline = torch.zeros_like(inputs) n_steps = 10 - + integrated_grads = torch.zeros_like(weights) - + for i in range(n_steps): alpha = i / n_steps interpolated = baseline + alpha * (inputs - baseline) interpolated.requires_grad_(True) - + outputs_interp = interpolated @ weights.T loss = self.task_loss_fn(outputs_interp, targets) - + grads = torch.autograd.grad(loss, interpolated)[0] integrated_grads += weights @ grads.T - + integrated_grads = integrated_grads / n_steps alignment_scores = integrated_grads.abs().mean(dim=1) - + else: raise ValueError(f"Unknown alignment type: {self.alignment_type}") - + if self.normalize: # Normalize scores to [0, 1] min_score = alignment_scores.min() max_score = alignment_scores.max() if max_score > min_score: alignment_scores = (alignment_scores - min_score) / (max_score - min_score) - + return alignment_scores @@ -134,12 +136,12 @@ class ClassSelectivity(BaseMetric): """ Measure how selectively neurons respond to different classes in classification tasks. """ - + name = "class_selectivity" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, n_classes: Optional[int] = None, @@ -148,7 +150,7 @@ def __init__( ): """ Initialize class selectivity metric. - + Args: n_classes: Number of classes (inferred if None) selectivity_type: Type of selectivity measure ('variance', 'entropy', 'gini') @@ -158,7 +160,7 @@ def __init__( self.n_classes = n_classes self.selectivity_type = selectivity_type self.temperature = temperature - + def compute( self, inputs: torch.Tensor, @@ -169,46 +171,46 @@ def compute( ) -> torch.Tensor: """ Compute class selectivity scores. - + Args: inputs: Input activations weights: Weight matrix outputs: Output activations labels: Class labels for each sample - + Returns: Selectivity scores for each neuron """ if outputs is None: outputs = inputs @ weights.T - + n_neurons = outputs.shape[1] - + # If no labels provided, cluster outputs if labels is None: # Simple k-means style clustering n_classes = self.n_classes or min(10, outputs.shape[0] // 10) - + # Use random initialization centroids = outputs[torch.randperm(outputs.shape[0])[:n_classes]] - + # Assign to nearest centroid distances = torch.cdist(outputs, centroids) labels = distances.argmin(dim=1) - + # Infer number of classes unique_labels = labels.unique() n_classes = len(unique_labels) - + selectivity_scores = torch.zeros(n_neurons, device=outputs.device) - + for neuron_idx in range(n_neurons): neuron_outputs = outputs[:, neuron_idx] - + # Compute mean activation for each class class_means = [] class_vars = [] - + for class_idx in unique_labels: class_mask = labels == class_idx if class_mask.sum() > 0: @@ -219,16 +221,16 @@ def compute( class_vars.append(class_activations.var()) else: class_vars.append(torch.tensor(0.0, device=outputs.device)) - + class_means = torch.stack(class_means) class_vars = torch.stack(class_vars) - + if self.selectivity_type == "variance": # Ratio of between-class variance to within-class variance between_var = class_means.var() within_var = class_vars.mean() selectivity = between_var / (within_var + 1e-8) - + elif self.selectivity_type == "entropy": # Entropy of class response distribution # Normalize responses to probabilities @@ -236,7 +238,7 @@ def compute( entropy = -(probs * probs.log()).sum() # Lower entropy = higher selectivity selectivity = 1.0 / (1.0 + entropy) - + elif self.selectivity_type == "gini": # Gini coefficient of class responses sorted_means = class_means.sort()[0] @@ -244,12 +246,12 @@ def compute( index = torch.arange(1, n + 1, device=outputs.device) gini = (2 * (index * sorted_means).sum()) / (n * sorted_means.sum()) - (n + 1) / n selectivity = gini - + else: raise ValueError(f"Unknown selectivity type: {self.selectivity_type}") - + selectivity_scores[neuron_idx] = selectivity - + return selectivity_scores @@ -258,12 +260,12 @@ class FeatureImportance(BaseMetric): """ Compute feature importance scores based on task-specific objectives. """ - + name = "feature_importance" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, importance_method: str = "permutation", @@ -272,7 +274,7 @@ def __init__( ): """ Initialize feature importance metric. - + Args: importance_method: Method to compute importance ('permutation', 'shap_approximation') n_permutations: Number of permutations for permutation importance @@ -282,11 +284,11 @@ def __init__( self.importance_method = importance_method self.n_permutations = n_permutations self.task_metric = task_metric or self._default_metric - + def _default_metric(self, outputs: torch.Tensor, targets: torch.Tensor) -> float: """Default metric: negative MSE (so higher is better).""" return -F.mse_loss(outputs, targets).item() - + def compute( self, inputs: torch.Tensor, @@ -297,66 +299,66 @@ def compute( ) -> torch.Tensor: """ Compute feature importance scores. - + Args: inputs: Input activations weights: Weight matrix outputs: Output activations targets: Target values - + Returns: Importance scores for each neuron """ if outputs is None: outputs = inputs @ weights.T - + if targets is None: targets = outputs.detach() - + n_neurons = weights.shape[0] importance_scores = torch.zeros(n_neurons, device=outputs.device) - + # Baseline performance baseline_score = self.task_metric(outputs, targets) - + if self.importance_method == "permutation": # Permutation importance for neuron_idx in range(n_neurons): neuron_scores = [] - + for _ in range(self.n_permutations): # Create a copy of weights with permuted neuron weights_perm = weights.clone() perm_idx = torch.randperm(weights.shape[1]) weights_perm[neuron_idx] = weights[neuron_idx, perm_idx] - + # Compute outputs with permuted weights outputs_perm = inputs @ weights_perm.T - + # Compute performance drop perm_score = self.task_metric(outputs_perm, targets) neuron_scores.append(baseline_score - perm_score) - + importance_scores[neuron_idx] = torch.tensor(neuron_scores).mean() - + elif self.importance_method == "shap_approximation": # Simplified SHAP-like approximation # Use gradient * activation as approximation inputs_grad = inputs.detach().requires_grad_(True) outputs_grad = inputs_grad @ weights.T - + # Use negative of task metric as loss loss = -self.task_metric(outputs_grad, targets) - + # Compute gradients grads = torch.autograd.grad(loss, outputs_grad)[0] - + # Importance = |gradient * activation| importance_scores = (grads * outputs_grad).abs().mean(dim=0) - + else: raise ValueError(f"Unknown importance method: {self.importance_method}") - + return importance_scores @@ -365,12 +367,12 @@ class RepresentationQuality(BaseMetric): """ Measure the quality of learned representations for downstream tasks. """ - + name = "representation_quality" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, quality_measure: str = "linear_probe", @@ -379,7 +381,7 @@ def __init__( ): """ Initialize representation quality metric. - + Args: quality_measure: Type of quality measure ('linear_probe', 'nearest_neighbor') probe_type: Type of linear probe ('ridge', 'logistic') @@ -389,7 +391,7 @@ def __init__( self.quality_measure = quality_measure self.probe_type = probe_type self.regularization = regularization - + def compute( self, inputs: torch.Tensor, @@ -400,74 +402,74 @@ def compute( ) -> torch.Tensor: """ Compute representation quality scores. - + Args: inputs: Input activations weights: Weight matrix outputs: Output activations (representations) targets: Target values/labels - + Returns: Quality scores for each neuron """ if outputs is None: outputs = inputs @ weights.T - + if targets is None: # Create synthetic targets based on clustering kmeans_labels = self._simple_kmeans(outputs, k=5) targets = kmeans_labels.float() - + n_neurons = outputs.shape[1] quality_scores = torch.zeros(n_neurons, device=outputs.device) - + if self.quality_measure == "linear_probe": # Fit a linear probe on top of each neuron's representation for neuron_idx in range(n_neurons): neuron_repr = outputs[:, neuron_idx].unsqueeze(1) - + if self.probe_type == "ridge": # Ridge regression # Solve: (X^T X + λI)w = X^T y XtX = neuron_repr.T @ neuron_repr Xty = neuron_repr.T @ targets - + # Add regularization XtX = XtX + self.regularization * torch.eye(1, device=outputs.device) - + # Solve for weights probe_weights = torch.linalg.solve(XtX, Xty) - + # Compute predictions predictions = neuron_repr @ probe_weights - + # Compute R^2 score ss_res = ((targets - predictions) ** 2).sum() ss_tot = ((targets - targets.mean()) ** 2).sum() r2 = 1 - ss_res / (ss_tot + 1e-8) - + quality_scores[neuron_idx] = r2.clamp(min=0) - + elif self.quality_measure == "nearest_neighbor": # k-NN classification quality k = min(5, outputs.shape[0] // 10) - + for neuron_idx in range(n_neurons): neuron_repr = outputs[:, neuron_idx].unsqueeze(1) - + # Compute pairwise distances distances = torch.cdist(neuron_repr, neuron_repr) - + # Get k nearest neighbors (excluding self) _, indices = distances.topk(k + 1, largest=False, dim=1) neighbor_indices = indices[:, 1:] # Exclude self - + # Get neighbor targets neighbor_targets = targets[neighbor_indices] - + # Predict as mean of neighbors (for regression) or mode (for classification) predictions = neighbor_targets.mean(dim=1) - + # Compute accuracy or MSE if targets.dtype == torch.long: # Classification: accuracy @@ -477,25 +479,25 @@ def compute( # Regression: negative MSE mse = F.mse_loss(predictions, targets) quality_scores[neuron_idx] = 1.0 / (1.0 + mse) - + return quality_scores - + def _simple_kmeans(self, data: torch.Tensor, k: int, n_iter: int = 10) -> torch.Tensor: """Simple k-means clustering implementation.""" n_samples = data.shape[0] - + # Random initialization centroids = data[torch.randperm(n_samples)[:k]] - + for _ in range(n_iter): # Assign to nearest centroid distances = torch.cdist(data, centroids) labels = distances.argmin(dim=1) - + # Update centroids for i in range(k): mask = labels == i if mask.sum() > 0: centroids[i] = data[mask].mean(dim=0) - - return labels \ No newline at end of file + + return labels diff --git a/src/alignment/metrics/task_specific/language_model.py b/src/alignment/metrics/task_specific/language_model.py index 68913fdc..841de8cb 100644 --- a/src/alignment/metrics/task_specific/language_model.py +++ b/src/alignment/metrics/task_specific/language_model.py @@ -2,27 +2,29 @@ Language model-specific alignment metrics. """ +from typing import Optional + import torch import torch.nn.functional as F -from typing import Optional, Dict, Any, List -from ...core.registry import register_metric + from ...core.base import BaseMetric +from ...core.registry import register_metric @register_metric("language_model_alignment") class LanguageModelAlignment(BaseMetric): """ Measures alignment for language modeling tasks. - + This metric evaluates how well neuron activations align with linguistic properties such as syntax, semantics, and prediction patterns. """ - + name = "language_model_alignment" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, vocab_size: int, @@ -32,7 +34,7 @@ def __init__( ): """ Initialize language model alignment metric. - + Args: vocab_size: Size of the vocabulary alignment_type: Type of alignment measure @@ -47,7 +49,7 @@ def __init__( self.alignment_type = alignment_type self.context_window = context_window self.semantic_dims = semantic_dims or 128 - + def compute( self, inputs: torch.Tensor, @@ -60,7 +62,7 @@ def compute( ) -> torch.Tensor: """ Compute language model alignment scores. - + Args: inputs: Input activations [batch_size, seq_len, input_dim] weights: Weight matrix [output_dim, input_dim] @@ -68,110 +70,110 @@ def compute( token_ids: Token IDs [batch_size, seq_len] attention_weights: Attention weights [batch_size, n_heads, seq_len, seq_len] embeddings: Token embeddings [vocab_size, embedding_dim] - + Returns: Alignment scores for each neuron [output_dim] """ # Handle 2D inputs by adding sequence dimension if inputs.dim() == 2: inputs = inputs.unsqueeze(1) - + if outputs is None: # inputs: [batch, seq, input_dim], weights: [output_dim, input_dim] outputs = torch.matmul(inputs, weights.T) - + if outputs.dim() == 2: outputs = outputs.unsqueeze(1) - + batch_size, seq_len, n_neurons = outputs.shape alignment_scores = torch.zeros(n_neurons, device=outputs.device) - + if self.alignment_type == "prediction_alignment": # Measure alignment with next-token prediction task if token_ids is None: # Generate synthetic token IDs token_ids = torch.randint(0, self.vocab_size, (batch_size, seq_len), device=outputs.device) - + # Create next-token targets if seq_len > 1: targets = token_ids[:, 1:] # Next tokens current_outputs = outputs[:, :-1] # Current outputs - + # Project outputs to vocabulary space projection = torch.randn(n_neurons, self.vocab_size, device=outputs.device) - logits = torch.matmul(current_outputs, projection) - + torch.matmul(current_outputs, projection) + # Compute cross-entropy loss per neuron for i in range(n_neurons): neuron_contribution = current_outputs[:, :, i:i+1] neuron_logits = neuron_contribution @ projection[i:i+1, :] - + # Flatten for loss computation neuron_logits_flat = neuron_logits.reshape(-1, self.vocab_size) targets_flat = targets.reshape(-1) - + # Cross-entropy loss loss = F.cross_entropy(neuron_logits_flat, targets_flat, reduction='mean') - + # Lower loss = better alignment alignment_scores[i] = 1.0 / (1.0 + loss.item()) - + elif self.alignment_type == "attention_correlation": # Measure correlation with attention patterns if attention_weights is None: # Generate synthetic attention weights attention_weights = F.softmax(torch.randn(batch_size, 4, seq_len, seq_len, device=outputs.device), dim=-1) - + # Average attention across heads avg_attention = attention_weights.mean(dim=1) # [batch, seq, seq] - + # For each neuron, measure correlation with attention-weighted representations for i in range(n_neurons): neuron_activations = outputs[:, :, i] # [batch, seq] - + # Compute attention-weighted activations weighted_activations = torch.matmul(avg_attention, neuron_activations.unsqueeze(-1)).squeeze(-1) - + # Correlation between original and attention-weighted if neuron_activations.numel() > 1: flat_original = neuron_activations.reshape(-1) flat_weighted = weighted_activations.reshape(-1) - + if flat_original.std() > 0 and flat_weighted.std() > 0: correlation = torch.corrcoef(torch.stack([flat_original, flat_weighted]))[0, 1] alignment_scores[i] = correlation.abs() - + elif self.alignment_type == "semantic_coherence": # Measure semantic coherence of representations if embeddings is None: # Generate synthetic embeddings embeddings = torch.randn(self.vocab_size, self.semantic_dims, device=outputs.device) - + if token_ids is None: token_ids = torch.randint(0, self.vocab_size, (batch_size, seq_len), device=outputs.device) - + # Get embeddings for tokens token_embeddings = embeddings[token_ids] # [batch, seq, embed_dim] - + # Project outputs to semantic space projection = torch.randn(n_neurons, self.semantic_dims, device=outputs.device) - output_embeddings = torch.matmul(outputs, projection) - + torch.matmul(outputs, projection) + # Measure coherence for each neuron for i in range(n_neurons): neuron_outputs = outputs[:, :, i:i+1] # [batch, seq, 1] neuron_embeddings = neuron_outputs @ projection[i:i+1, :] # [batch, seq, embed_dim] - + # Cosine similarity with token embeddings neuron_norm = F.normalize(neuron_embeddings, p=2, dim=-1) token_norm = F.normalize(token_embeddings, p=2, dim=-1) - + similarities = (neuron_norm * token_norm).sum(dim=-1) # [batch, seq] - + # Average similarity as coherence measure alignment_scores[i] = similarities.mean() - + else: raise ValueError(f"Unknown alignment type: {self.alignment_type}") - - return alignment_scores \ No newline at end of file + + return alignment_scores diff --git a/src/alignment/metrics/task_specific/reinforcement_learning.py b/src/alignment/metrics/task_specific/reinforcement_learning.py index 72f2a6ef..e41ca200 100644 --- a/src/alignment/metrics/task_specific/reinforcement_learning.py +++ b/src/alignment/metrics/task_specific/reinforcement_learning.py @@ -2,27 +2,29 @@ Reinforcement learning-specific alignment metrics. """ +from typing import Optional + import torch import torch.nn.functional as F -from typing import Optional, Dict, Any, Tuple, List -from ...core.registry import register_metric + from ...core.base import BaseMetric +from ...core.registry import register_metric @register_metric("reinforcement_learning_alignment") class ReinforcementLearningAlignment(BaseMetric): """ Measures alignment for reinforcement learning tasks. - + This metric evaluates how well neuron activations align with value functions, policy gradients, and reward signals in RL settings. """ - + name = "reinforcement_learning_alignment" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, alignment_type: str = "value_correlation", @@ -32,7 +34,7 @@ def __init__( ): """ Initialize RL alignment metric. - + Args: alignment_type: Type of alignment measure - 'value_correlation': Correlation with value function @@ -48,7 +50,7 @@ def __init__( self.discount_factor = discount_factor self.n_actions = n_actions self.use_advantage = use_advantage - + def compute( self, inputs: torch.Tensor, @@ -63,7 +65,7 @@ def compute( ) -> torch.Tensor: """ Compute RL alignment scores. - + Args: inputs: Input activations (state representations) [batch_size, state_dim] weights: Weight matrix [output_dim, input_dim] @@ -73,16 +75,16 @@ def compute( rewards: Rewards received [batch_size] next_states: Next states values: Value function estimates [batch_size] - + Returns: Alignment scores for each neuron [output_dim] """ if outputs is None: outputs = inputs @ weights.T - + n_neurons = weights.shape[0] alignment_scores = torch.zeros(n_neurons, device=outputs.device) - + # Generate synthetic RL data if not provided batch_size = outputs.shape[0] if states is None: @@ -93,35 +95,35 @@ def compute( rewards = torch.randn(batch_size, device=outputs.device) if next_states is None: next_states = states + torch.randn_like(states) * 0.1 - + if self.alignment_type == "value_correlation": # Measure correlation with value function if values is None: # Estimate values using simple linear projection value_projection = torch.randn(outputs.shape[1], 1, device=outputs.device) values = (outputs @ value_projection).squeeze() - + # For each neuron, compute correlation with value estimates for i in range(n_neurons): neuron_activations = outputs[:, i] - + if neuron_activations.std() > 0 and values.std() > 0: correlation = torch.corrcoef( torch.stack([neuron_activations, values]) )[0, 1] alignment_scores[i] = correlation.abs() - + elif self.alignment_type == "policy_gradient": # Measure alignment with policy gradients # Create policy network output (action probabilities) policy_projection = torch.randn(n_neurons, self.n_actions, device=outputs.device) action_logits = outputs @ policy_projection - action_probs = F.softmax(action_logits, dim=-1) - + F.softmax(action_logits, dim=-1) + # Compute log probabilities of taken actions action_log_probs = F.log_softmax(action_logits, dim=-1) taken_action_log_probs = action_log_probs.gather(1, actions.unsqueeze(1)).squeeze() - + # Compute advantages if using advantage function if self.use_advantage and values is not None: # Compute returns (simple 1-step return for demonstration) @@ -129,44 +131,44 @@ def compute( advantages = returns - values else: advantages = rewards - + # Policy gradient: grad log π(a|s) * A # We measure how neuron activation correlates with this quantity policy_gradient_signal = taken_action_log_probs * advantages.detach() - + for i in range(n_neurons): neuron_activations = outputs[:, i] - + if neuron_activations.std() > 0: # Compute correlation with policy gradient signal correlation = torch.corrcoef( torch.stack([neuron_activations, policy_gradient_signal]) )[0, 1] alignment_scores[i] = correlation.abs() - + elif self.alignment_type == "reward_prediction": # Measure how well neurons predict future rewards for i in range(n_neurons): neuron_activations = outputs[:, i] - + # Simple linear regression to predict rewards if neuron_activations.std() > 0: # Normalize X = (neuron_activations - neuron_activations.mean()) / neuron_activations.std() y = rewards - + # Compute correlation as measure of predictive power if y.std() > 0: correlation = torch.corrcoef(torch.stack([X, y]))[0, 1] alignment_scores[i] = correlation.abs() - + elif self.alignment_type == "temporal_difference": # Measure alignment with TD errors if values is None: # Estimate values value_projection = torch.randn(outputs.shape[1], 1, device=outputs.device) values = (outputs @ value_projection).squeeze() - + # Compute next values next_outputs = next_states @ weights.T next_values = (next_outputs @ value_projection).squeeze() @@ -175,21 +177,21 @@ def compute( next_outputs = next_states @ weights.T value_projection = torch.randn(outputs.shape[1], 1, device=outputs.device) next_values = (next_outputs @ value_projection).squeeze() - + # TD error: r + γV(s') - V(s) td_errors = rewards + self.discount_factor * next_values - values - + # Measure how neuron activations correlate with TD errors for i in range(n_neurons): neuron_activations = outputs[:, i] - + if neuron_activations.std() > 0 and td_errors.std() > 0: correlation = torch.corrcoef( torch.stack([neuron_activations, td_errors.abs()]) )[0, 1] alignment_scores[i] = correlation.abs() - + else: raise ValueError(f"Unknown alignment type: {self.alignment_type}") - - return alignment_scores \ No newline at end of file + + return alignment_scores diff --git a/src/alignment/metrics/task_specific/vision.py b/src/alignment/metrics/task_specific/vision.py index 544fe560..4fa768ef 100644 --- a/src/alignment/metrics/task_specific/vision.py +++ b/src/alignment/metrics/task_specific/vision.py @@ -2,13 +2,14 @@ Vision task-specific alignment metrics. """ +import logging +from typing import Optional, Tuple + import torch import torch.nn.functional as F -from typing import Optional, Dict, Any, Tuple -import logging -from ...core.registry import register_metric -from ...core.base import BaseMetric +from ...core.base import BaseMetric +from ...core.registry import register_metric logger = logging.getLogger(__name__) @@ -17,16 +18,16 @@ class VisionTaskAlignment(BaseMetric): """ Measures alignment for vision tasks. - + This metric evaluates how well neuron activations align with visual features such as edges, textures, objects, and spatial relationships. """ - + name = "vision_task_alignment" requires_inputs = True requires_weights = True requires_outputs = False - + def __init__( self, alignment_type: str = "spatial_coherence", @@ -37,7 +38,7 @@ def __init__( ): """ Initialize vision task alignment metric. - + Args: alignment_type: Type of alignment measure - 'spatial_coherence': Spatial coherence of activations @@ -54,7 +55,7 @@ def __init__( self.patch_size = patch_size self.n_orientations = n_orientations self.require_real_data = require_real_data - + def compute( self, inputs: torch.Tensor, @@ -67,7 +68,7 @@ def compute( ) -> torch.Tensor: """ Compute vision task alignment scores. - + Args: inputs: Input activations [batch_size, channels, height, width] or flattened weights: Weight matrix [output_dim, input_dim] @@ -75,7 +76,7 @@ def compute( images: Original images [batch_size, channels, height, width] feature_maps: Intermediate feature maps labels: Object labels for images - + Returns: Alignment scores for each neuron [output_dim] """ @@ -91,10 +92,10 @@ def compute( # Already flattened outputs = inputs @ weights.T device = outputs.device - + n_neurons = weights.shape[0] alignment_scores = torch.zeros(n_neurons, device=outputs.device) - + if self.alignment_type == "spatial_coherence": # Measure spatial coherence of neuron activations if outputs.dim() == 2: @@ -117,60 +118,60 @@ def compute( pass else: raise ValueError(f"Unexpected output dimension: {outputs.dim()}") - + # Compute spatial autocorrelation for each neuron for i in range(n_neurons): if outputs.dim() == 4: neuron_maps = outputs[:, i] # [batch, height, width] else: neuron_maps = outputs[:, :, i] # Alternative format - + # Compute local spatial correlation # Shift maps and compute correlation shifted_right = torch.roll(neuron_maps, shifts=1, dims=-1) shifted_down = torch.roll(neuron_maps, shifts=1, dims=-2) - + # Average correlation with shifted versions corr_right = F.cosine_similarity( neuron_maps.reshape(neuron_maps.shape[0], -1), shifted_right.reshape(shifted_right.shape[0], -1), dim=1 ).mean() - + corr_down = F.cosine_similarity( neuron_maps.reshape(neuron_maps.shape[0], -1), shifted_down.reshape(shifted_down.shape[0], -1), dim=1 ).mean() - + alignment_scores[i] = (corr_right + corr_down) / 2 - + elif self.alignment_type == "edge_detection": # Measure alignment with edge detection filters # Create Sobel-like edge detection kernels - + # Horizontal and vertical edge kernels kernel_h = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=torch.float32, device=device) kernel_v = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=torch.float32, device=device) - + if images is None: if self.require_real_data: raise ValueError("VisionTaskAlignment(edge_detection) requires images but none were provided.") logger.warning("images not provided; generating synthetic edge patterns for alignment computation") images = self._generate_edge_patterns(outputs.shape[0], device) - + # Compute edge responses in images if images.dim() == 4 and images.shape[1] == 3: # Convert to grayscale images_gray = images.mean(dim=1, keepdim=True) else: images_gray = images - + # Apply edge detection edges_h = F.conv2d(images_gray, kernel_h.unsqueeze(0).unsqueeze(0), padding=1) edges_v = F.conv2d(images_gray, kernel_v.unsqueeze(0).unsqueeze(0), padding=1) edge_magnitude = torch.sqrt(edges_h**2 + edges_v**2) - + # Measure correlation between neuron activations and edges for i in range(n_neurons): if outputs.dim() == 4: @@ -178,23 +179,23 @@ def compute( else: # Reshape if needed neuron_response = outputs[:, i].reshape(outputs.shape[0], 1, -1) - neuron_response = neuron_response.reshape(outputs.shape[0], 1, + neuron_response = neuron_response.reshape(outputs.shape[0], 1, int(neuron_response.shape[2]**0.5), int(neuron_response.shape[2]**0.5)) - + # Resize if necessary if neuron_response.shape[-2:] != edge_magnitude.shape[-2:]: neuron_response = F.interpolate(neuron_response, size=edge_magnitude.shape[-2:], mode='bilinear') - + # Compute correlation correlation = F.cosine_similarity( neuron_response.reshape(neuron_response.shape[0], -1), edge_magnitude.reshape(edge_magnitude.shape[0], -1), dim=1 ).mean() - + alignment_scores[i] = correlation.abs() - + elif self.alignment_type == "texture_response": # Measure response to texture patterns if feature_maps is None: @@ -202,33 +203,33 @@ def compute( raise ValueError("VisionTaskAlignment(texture_response) requires feature_maps but none were provided.") logger.warning("feature_maps not provided; generating synthetic texture patterns for alignment computation") feature_maps = self._generate_texture_patterns(outputs.shape[0], n_neurons, device) - + # Compute texture energy for different frequencies for i in range(n_neurons): if outputs.dim() == 4: neuron_response = outputs[:, i] else: neuron_response = outputs[:, i].reshape(-1) - + # Compute frequency response using FFT if neuron_response.dim() >= 2: fft_response = torch.fft.fft2(neuron_response) power_spectrum = torch.abs(fft_response) ** 2 - + # Measure concentration of energy in different frequency bands low_freq_energy = power_spectrum[..., :power_spectrum.shape[-2]//4, :power_spectrum.shape[-1]//4].sum() high_freq_energy = power_spectrum[..., power_spectrum.shape[-2]//4:, power_spectrum.shape[-1]//4:].sum() - + # Ratio indicates texture selectivity if low_freq_energy > 0: texture_selectivity = high_freq_energy / low_freq_energy else: texture_selectivity = high_freq_energy - + alignment_scores[i] = texture_selectivity / (1 + texture_selectivity) else: alignment_scores[i] = 0.0 - + elif self.alignment_type == "object_selectivity": # Measure object-specific selectivity if labels is None: @@ -238,76 +239,76 @@ def compute( # Create synthetic object labels n_objects = 10 labels = torch.randint(0, n_objects, (outputs.shape[0],), device=device) - + # Compute selectivity for each neuron unique_labels = labels.unique() - + for i in range(n_neurons): if outputs.dim() == 4: neuron_response = outputs[:, i].reshape(outputs.shape[0], -1).mean(dim=1) else: neuron_response = outputs[:, i] - + # Compute mean response per object class class_responses = [] for label in unique_labels: mask = labels == label if mask.sum() > 0: class_responses.append(neuron_response[mask].mean()) - + if len(class_responses) > 1: class_responses = torch.stack(class_responses) - + # Selectivity as ratio of max to mean response max_response = class_responses.max() mean_response = class_responses.mean() - + if mean_response > 0: selectivity = max_response / mean_response else: selectivity = 0.0 - + alignment_scores[i] = selectivity / (1 + selectivity) - + else: raise ValueError(f"Unknown alignment type: {self.alignment_type}") - + return alignment_scores - + def _generate_edge_patterns(self, batch_size: int, device: torch.device) -> torch.Tensor: """Generate synthetic images with edge patterns.""" h, w = self.image_size images = torch.zeros(batch_size, 1, h, w, device=device) - + for i in range(batch_size): # Random edge orientation angle = torch.rand(1).item() * 2 * 3.14159 - + # Create edge pattern x = torch.arange(w, device=device).float() - w / 2 y = torch.arange(h, device=device).float() - h / 2 xx, yy = torch.meshgrid(x, y, indexing='xy') - + # Rotated coordinates edge_pattern = torch.sin(xx * torch.cos(angle) + yy * torch.sin(angle)) images[i, 0] = edge_pattern - + return images - + def _generate_texture_patterns(self, batch_size: int, n_channels: int, device: torch.device) -> torch.Tensor: """Generate synthetic texture patterns.""" h, w = self.image_size textures = torch.randn(batch_size, n_channels, h, w, device=device) - + # Apply different frequency filters for i in range(n_channels): - freq = 2 ** (i % 5) # Different frequencies + 2 ** (i % 5) # Different frequencies kernel_size = max(3, 15 - 2 * (i % 5)) - + # Create Gaussian kernel for smoothing kernel = torch.ones(1, 1, kernel_size, kernel_size, device=device) / (kernel_size ** 2) - + # Apply convolution for texture effect textures[:, i:i+1] = F.conv2d(textures[:, i:i+1], kernel, padding=kernel_size//2) - - return textures \ No newline at end of file + + return textures diff --git a/src/alignment/models/__init__.py b/src/alignment/models/__init__.py index 385dd8f2..e9a14dd1 100644 --- a/src/alignment/models/__init__.py +++ b/src/alignment/models/__init__.py @@ -5,26 +5,13 @@ functionality needed for alignment analysis to standard PyTorch models. """ -from .base import BaseModelWrapper -from .wrappers import ( - ModelWrapper, - AlignmentNetwork, - ActivationTracker, -) -from .transformers import ( - TransformerWrapperEnhanced, - LLaMAWrapper, -) -from .architectures.standard_models import ( - MLP, - CNN2P2, - SimpleConvNet, - create_model, -) -from . import hub # registers torchvision/timm/huggingface model loaders - # Register standard models from ..core.registry import register_model +from . import hub # registers torchvision/timm/huggingface model loaders +from .architectures.standard_models import CNN2P2, MLP, SimpleConvNet, create_model +from .base import BaseModelWrapper +from .transformers import LLaMAWrapper, TransformerWrapperEnhanced +from .wrappers import ActivationTracker, AlignmentNetwork, ModelWrapper # Register the models register_model("mlp")(MLP) diff --git a/src/alignment/models/architectures/standard_models.py b/src/alignment/models/architectures/standard_models.py index f5b1c5d2..9d0216a1 100644 --- a/src/alignment/models/architectures/standard_models.py +++ b/src/alignment/models/architectures/standard_models.py @@ -6,7 +6,7 @@ """ import logging -from typing import List, Optional, Dict, Tuple, Any, Union +from typing import Any, Dict, List, Optional, Tuple, Union import torch import torch.nn as nn diff --git a/src/alignment/models/base.py b/src/alignment/models/base.py index 6ad9f376..8565bb6e 100644 --- a/src/alignment/models/base.py +++ b/src/alignment/models/base.py @@ -5,13 +5,15 @@ specific to the models module. """ -from typing import Dict, List, Optional, Tuple, Any, Union +import logging +from collections import OrderedDict +from typing import Any, Dict, List, Optional, Tuple, Union + import torch import torch.nn as nn -from collections import OrderedDict -import logging from alignment.core.base import BaseModel + from .hooks import HookManager # Conditional import for layer detector (graceful fallback) diff --git a/src/alignment/models/hooks.py b/src/alignment/models/hooks.py index 7f84edab..c99c0a32 100644 --- a/src/alignment/models/hooks.py +++ b/src/alignment/models/hooks.py @@ -5,11 +5,12 @@ to prevent memory leaks. """ +import logging from contextlib import contextmanager -from typing import List, Dict, Callable, Optional, Any +from typing import Any, Callable, Dict, List, Optional + import torch import torch.nn as nn -import logging logger = logging.getLogger(__name__) diff --git a/src/alignment/models/hub.py b/src/alignment/models/hub.py index f742f97f..0cafded2 100644 --- a/src/alignment/models/hub.py +++ b/src/alignment/models/hub.py @@ -14,8 +14,9 @@ - Returned objects are nn.Module compatible with our wrappers. """ -from typing import Optional, Any import logging +from typing import Any, Optional + import torch import torch.nn as nn diff --git a/src/alignment/models/transformers.py b/src/alignment/models/transformers.py index fa3086a7..d4159dd5 100644 --- a/src/alignment/models/transformers.py +++ b/src/alignment/models/transformers.py @@ -13,13 +13,14 @@ - Custom transformer implementations """ -from typing import Dict, List, Optional, Any, Tuple +import logging +from typing import Any, Dict, List, Optional, Tuple + import torch import torch.nn as nn -import logging -from .base import BaseModelWrapper from ..core.registry import register_model +from .base import BaseModelWrapper logger = logging.getLogger(__name__) diff --git a/src/alignment/models/wrappers.py b/src/alignment/models/wrappers.py index 50a851cb..e1396bdb 100644 --- a/src/alignment/models/wrappers.py +++ b/src/alignment/models/wrappers.py @@ -5,16 +5,17 @@ including the general-purpose ModelWrapper and specialized wrappers. """ -from typing import Dict, List, Optional, Tuple, Any, Union, Callable +import copy +import logging +from collections import OrderedDict +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + import torch import torch.nn as nn import torch.nn.functional as F -from collections import OrderedDict -import logging -import copy -from .base import BaseModelWrapper from ..core.registry import register_model +from .base import BaseModelWrapper logger = logging.getLogger(__name__) diff --git a/src/alignment/pruning/__init__.py b/src/alignment/pruning/__init__.py index e4c8ef8b..d3cd4fcf 100644 --- a/src/alignment/pruning/__init__.py +++ b/src/alignment/pruning/__init__.py @@ -5,7 +5,7 @@ Strategies: - Magnitude-based: MagnitudePruning, IterativeMagnitudePruning, GlobalMagnitudePruning -- Gradient-based: GradientPruning, FisherPruning, MomentumPruning +- Gradient-based: GradientPruning, FisherPruning, MomentumPruning - Random: RandomPruning, LayerwiseRandomPruning, BernoulliPruning - Parallel: ParallelModePruning, TensorizedPruning, AsyncParallelPruning @@ -16,51 +16,51 @@ Example: Basic pruning:: - + from alignment.pruning import get_pruning_strategy, PruningConfig - + # Prune low-magnitude weights strategy = get_pruning_strategy('magnitude') mask = strategy.prune(layer, amount=0.5) - + # Prune high-magnitude weights config = PruningConfig(amount=0.5, pruning_mode='high') strategy = get_pruning_strategy('magnitude', config=config) - + Parallel pruning:: - + from alignment.pruning.strategies import ParallelModePruning - + # Apply multiple modes simultaneously strategy = ParallelModePruning(modes=['low', 'high', 'random']) result = strategy.prune_parallel(layer, amount=0.5) - + # Access individual masks low_mask = result.masks['low'] high_mask = result.masks['high'] """ -from typing import Optional, Union, Type import logging +from typing import Optional, Type, Union from .base import BasePruningStrategy, IterativePruningStrategy, PruningConfig from .strategies import ( - MagnitudePruning, - IterativeMagnitudePruning, + AlignmentPruning, + AsyncParallelPruning, + BernoulliPruning, + CascadingAlignmentPruning, + FisherPruning, + GlobalAlignmentPruning, GlobalMagnitudePruning, GradientPruning, - FisherPruning, - MomentumPruning, - RandomPruning, + HybridPruning, + IterativeMagnitudePruning, LayerwiseRandomPruning, - BernoulliPruning, + MagnitudePruning, + MomentumPruning, ParallelModePruning, + RandomPruning, TensorizedPruning, - AsyncParallelPruning, - AlignmentPruning, - HybridPruning, - GlobalAlignmentPruning, - CascadingAlignmentPruning, ) logger = logging.getLogger(__name__) @@ -71,23 +71,23 @@ 'magnitude': MagnitudePruning, 'iterative_magnitude': IterativeMagnitudePruning, 'global_magnitude': GlobalMagnitudePruning, - + # Gradient-based strategies 'gradient': GradientPruning, 'fisher': FisherPruning, 'momentum': MomentumPruning, - + # Alignment-based strategies 'alignment': AlignmentPruning, 'hybrid': HybridPruning, 'global_alignment': GlobalAlignmentPruning, 'cascading_alignment': CascadingAlignmentPruning, - + # Random strategies (kept for backward compatibility) # Note: Consider using selection_mode='random' instead 'random': RandomPruning, 'bernoulli': BernoulliPruning, - + # Parallel strategies 'parallel_mode': ParallelModePruning, 'tensorized': TensorizedPruning, @@ -101,14 +101,14 @@ def get_pruning_strategy( ) -> BasePruningStrategy: """ Get a pruning strategy by name. - + Args: name: Name of the pruning strategy **kwargs: Additional arguments for the strategy - + Returns: Initialized pruning strategy - + Raises: ValueError: If strategy name is not found """ @@ -118,7 +118,7 @@ def get_pruning_strategy( f"Unknown pruning strategy: {name}. " f"Available strategies: {available}" ) - + strategy_class = PRUNING_STRATEGIES[name] return strategy_class(**kwargs) @@ -133,34 +133,34 @@ def list_pruning_strategies() -> list: 'BasePruningStrategy', 'IterativePruningStrategy', 'PruningConfig', - + # Magnitude strategies 'MagnitudePruning', 'IterativeMagnitudePruning', 'GlobalMagnitudePruning', - + # Gradient strategies 'GradientPruning', 'FisherPruning', 'MomentumPruning', - + # Alignment strategies 'AlignmentPruning', 'HybridPruning', 'GlobalAlignmentPruning', 'CascadingAlignmentPruning', - + # Random strategies 'RandomPruning', 'LayerwiseRandomPruning', 'BernoulliPruning', - + # Parallel strategies 'ParallelModePruning', 'TensorizedPruning', 'AsyncParallelPruning', - + # Functions 'get_pruning_strategy', 'list_pruning_strategies', -] \ No newline at end of file +] diff --git a/src/alignment/pruning/base.py b/src/alignment/pruning/base.py index 65509f1e..1fb4f911 100644 --- a/src/alignment/pruning/base.py +++ b/src/alignment/pruning/base.py @@ -6,10 +6,11 @@ """ from abc import ABC, abstractmethod -from typing import Dict, Optional, Union, Tuple, Any +from dataclasses import dataclass +from typing import Any, Dict, Optional + import torch import torch.nn as nn -from dataclasses import dataclass @dataclass @@ -27,21 +28,21 @@ class PruningConfig: class BasePruningStrategy(ABC): """ Abstract base class for all pruning strategies. - + This class defines the interface that all pruning strategies must implement. Subclasses should implement the compute_importance_scores method to define how importance is calculated for pruning decisions. """ - + def __init__(self, config: Optional[PruningConfig] = None): """ Initialize the pruning strategy. - + Args: config: Pruning configuration. If None, uses default config. """ self.config = config or PruningConfig() - + @abstractmethod def compute_importance_scores( self, @@ -51,17 +52,17 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores for the given module. - + Args: module: The module to compute importance scores for inputs: Optional input tensor for the module **kwargs: Additional strategy-specific arguments - + Returns: Tensor of importance scores with same shape as module weights """ pass - + def create_pruning_mask( self, importance_scores: torch.Tensor, @@ -72,40 +73,40 @@ def create_pruning_mask( ) -> torch.Tensor: """ Create a pruning mask based on importance scores. - + Args: importance_scores: Tensor of importance scores amount: Fraction to prune (overrides config if provided) structured: Whether to do structured pruning (overrides config) dim: Dimension for structured pruning (default: 0 for output dimension) pruning_mode: 'low' to prune low values, 'high' to prune high values, 'random' for random - + Returns: Binary mask tensor (1 = keep, 0 = prune) """ amount = amount if amount is not None else self.config.amount structured = structured if structured is not None else self.config.structured pruning_mode = pruning_mode if pruning_mode is not None else self.config.pruning_mode - + if structured: # Default to dimension 0 (output dimension) for structured pruning if dim is None: dim = 0 - + # Aggregate importance scores along non-pruned dimensions dims_to_reduce = list(range(importance_scores.ndim)) dims_to_reduce.pop(dim) - + if dims_to_reduce: aggregated_scores = importance_scores.abs().sum(dim=dims_to_reduce) else: aggregated_scores = importance_scores.abs() - + # Get number of structures to prune k = int(amount * aggregated_scores.numel()) if k == 0: return torch.ones_like(importance_scores) - + if pruning_mode == 'random': # Random selection of structures indices = torch.randperm(aggregated_scores.numel(), device=aggregated_scores.device)[:k] @@ -117,7 +118,7 @@ def create_pruning_mask( else: # pruning_mode == 'high' threshold = aggregated_scores.flatten().kthvalue(aggregated_scores.numel() - k).values mask = aggregated_scores < threshold - + # Expand mask to original shape shape = [1] * importance_scores.ndim shape[dim] = importance_scores.shape[dim] @@ -126,10 +127,10 @@ def create_pruning_mask( # Unstructured pruning importance_flat = importance_scores.flatten() k = int(amount * importance_flat.numel()) - + if k == 0: return torch.ones_like(importance_scores) - + if pruning_mode == 'random': # Random selection of weights mask = torch.rand_like(importance_scores) > amount @@ -139,9 +140,9 @@ def create_pruning_mask( else: # pruning_mode == 'high' threshold = importance_flat.kthvalue(importance_flat.numel() - k).values mask = importance_scores < threshold - + return mask.float() - + def apply_pruning( self, module: nn.Module, @@ -150,7 +151,7 @@ def apply_pruning( ): """ Apply pruning mask to a module. - + Args: module: Module to prune mask: Binary mask to apply @@ -158,48 +159,48 @@ def apply_pruning( """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have a weight parameter") - + if not make_permanent: # Store original weights BEFORE applying mask if not hasattr(module, '_original_weight'): module.register_buffer('_original_weight', module.weight.data.clone()) - + # Register mask as buffer for forward passes module.register_buffer('weight_mask', mask) - + # Apply mask to weights module.weight.data *= mask - + # Hook to apply mask during forward pass def apply_mask_hook(mod, inputs): # Apply mask to original weights to maintain pruning mod.weight.data = mod._original_weight * mod.weight_mask return inputs - + # Hook to mask gradients during backward pass # Capture the mask in the closure to avoid attribute access issues weight_mask = module.weight_mask def mask_gradient_hook(grad): # Mask gradients to prevent updates to pruned weights return grad * weight_mask - + # Remove old hooks if exist if hasattr(module, '_pruning_hook'): module._pruning_hook.remove() if hasattr(module, '_gradient_hook_handle'): module._gradient_hook_handle.remove() - + # Register hooks module._pruning_hook = module.register_forward_pre_hook(apply_mask_hook) module._gradient_hook_handle = module.weight.register_hook(mask_gradient_hook) else: # Apply mask permanently module.weight.data *= mask - + def remove_pruning(self, module: nn.Module): """ Remove pruning from a module (make pruning permanent). - + Args: module: Module to remove pruning from """ @@ -212,23 +213,23 @@ def remove_pruning(self, module: nn.Module): module.weight.data *= module.weight_mask # Remove mask buffer delattr(module, 'weight_mask') - + # Remove hooks if hasattr(module, '_pruning_hook'): module._pruning_hook.remove() delattr(module, '_pruning_hook') - + if hasattr(module, '_gradient_hook_handle'): module._gradient_hook_handle.remove() delattr(module, '_gradient_hook_handle') - + def get_sparsity(self, module: nn.Module) -> float: """ Get the current sparsity level of a module. - + Args: module: Module to check - + Returns: Fraction of weights that are zero """ @@ -237,7 +238,7 @@ def get_sparsity(self, module: nn.Module) -> float: zeros = (module.weight == 0).sum().item() return zeros / total return 0.0 - + def prune( self, module: nn.Module, @@ -247,36 +248,36 @@ def prune( ) -> torch.Tensor: """ Main pruning method that combines all steps. - + Args: module: Module to prune inputs: Optional inputs for computing importance amount: Optional amount to prune (overrides config) **kwargs: Additional strategy-specific arguments - + Returns: The pruning mask that was applied """ # Compute importance scores importance_scores = self.compute_importance_scores(module, inputs, **kwargs) - + # Create mask mask = self.create_pruning_mask(importance_scores, amount) - + # Apply mask self.apply_pruning(module, mask) - + return mask class IterativePruningStrategy(BasePruningStrategy): """ Base class for iterative pruning strategies. - + This class extends BasePruningStrategy to support iterative pruning with fine-tuning between iterations. """ - + def iterative_prune( self, model: nn.Module, @@ -288,7 +289,7 @@ def iterative_prune( ) -> Dict[str, Any]: """ Perform iterative pruning on a model. - + Args: model: Model to prune dataloader: DataLoader for fine-tuning @@ -296,7 +297,7 @@ def iterative_prune( criterion: Loss criterion for fine-tuning fine_tune_fn: Custom fine-tuning function **kwargs: Additional arguments for pruning - + Returns: Dictionary containing pruning results and statistics """ @@ -305,22 +306,22 @@ def iterative_prune( 'loss_per_iteration': [], 'accuracy_per_iteration': [] } - + # Calculate amount to prune per iteration total_amount = self.config.amount iterations = self.config.iterations amount_per_iter = 1 - (1 - total_amount) ** (1 / iterations) - + for iteration in range(iterations): # Prune each prunable module for name, module in model.named_modules(): if self._is_prunable(module): current_sparsity = self.get_sparsity(module) additional_amount = amount_per_iter * (1 - current_sparsity) - + if additional_amount > 0: self.prune(module, amount=additional_amount, **kwargs) - + # Calculate current model sparsity total_params = 0 zero_params = 0 @@ -328,10 +329,10 @@ def iterative_prune( if hasattr(module, 'weight'): total_params += module.weight.numel() zero_params += (module.weight == 0).sum().item() - + current_sparsity = zero_params / total_params if total_params > 0 else 0 results['sparsity_per_iteration'].append(current_sparsity) - + # Fine-tune if requested if self.config.fine_tune_epochs > 0 and dataloader is not None: if fine_tune_fn is not None: @@ -354,9 +355,9 @@ def iterative_prune( loss = criterion(outputs, targets) loss.backward() optimizer.step() - + return results - + def _is_prunable(self, module: nn.Module) -> bool: """Check if a module can be pruned.""" - return isinstance(module, (nn.Linear, nn.Conv1d, nn.Conv2d, nn.Conv3d)) \ No newline at end of file + return isinstance(module, (nn.Linear, nn.Conv1d, nn.Conv2d, nn.Conv3d)) diff --git a/src/alignment/pruning/dependency_aware.py b/src/alignment/pruning/dependency_aware.py index b2258ece..0f5ee816 100644 --- a/src/alignment/pruning/dependency_aware.py +++ b/src/alignment/pruning/dependency_aware.py @@ -9,11 +9,12 @@ This ensures pruning doesn't cause shape mismatches. """ -from typing import Dict, List, Optional, Tuple, Any -import torch -import torch.nn as nn import logging from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import torch +import torch.nn as nn logger = logging.getLogger(__name__) @@ -33,21 +34,21 @@ class LayerDependency: class DependencyGraph: """ Build and manage dependency graph for a neural network. - + Analyzes connections between layers to enable safe structured pruning. """ - + def __init__(self, model: nn.Module): """ Build dependency graph from model. - + Args: model: PyTorch model to analyze """ self.model = model self.graph: Dict[str, LayerDependency] = {} self._build_graph() - + def _build_graph(self): """Build dependency graph by analyzing model structure.""" # Get all relevant layers @@ -56,13 +57,13 @@ def _build_graph(self): if isinstance(module, (nn.Linear, nn.Conv2d, nn.Conv1d, nn.MultiheadAttention)): layer_type = self._get_layer_type(module) layers.append((name, module, layer_type)) - + # Build graph entries for idx, (name, module, layer_type) in enumerate(layers): # Determine dependencies depends_on = [layers[idx-1][0]] if idx > 0 else None feeds_into = [layers[idx+1][0]] if idx < len(layers) - 1 else None - + self.graph[name] = LayerDependency( name=name, module=module, @@ -71,12 +72,12 @@ def _build_graph(self): depends_on=depends_on, feeds_into=feeds_into ) - + # Detect skip connections (heuristic for ResNet-style models) self._detect_skip_connections() - + logger.info(f"Built dependency graph with {len(self.graph)} layers") - + def _get_layer_type(self, module: nn.Module) -> str: """Determine layer type.""" if isinstance(module, nn.Linear): @@ -87,18 +88,18 @@ def _get_layer_type(self, module: nn.Module) -> str: return 'attention' else: return 'other' - + def _detect_skip_connections(self): """ Detect skip/residual connections. - + Heuristic: Look for layers with same in/out dimensions that might be part of residual blocks. """ # This is a simplified heuristic # For production, would need more sophisticated analysis # (e.g., tracing actual forward pass) - + for name, dep in self.graph.items(): if dep.layer_type == 'conv': module = dep.module @@ -106,34 +107,34 @@ def _detect_skip_connections(self): if module.in_channels == module.out_channels: # Potential residual connection dep.skip_connection_with = [] # Placeholder - + logger.debug("Skip connection detection complete") class DependencyAwarePruning: """ Apply structured pruning with automatic dependency handling. - + Ensures that pruning one layer's outputs correctly propagates to the next layer's inputs, preventing shape mismatches. - + Example: >>> pruner = DependencyAwarePruning(model) >>> layer_scores = {'conv1': scores1, 'conv2': scores2} >>> result = pruner.prune(layer_scores, amount=0.5) >>> print(f"Pruned {result['stats']['total_params_pruned']} parameters") """ - + def __init__(self, model: nn.Module): """ Initialize dependency-aware pruning. - + Args: model: PyTorch model to prune """ self.model = model self.dependency_graph = DependencyGraph(model) - + def prune( self, layer_scores: Dict[str, torch.Tensor], @@ -143,13 +144,13 @@ def prune( ) -> Dict[str, Any]: """ Apply structured pruning with dependency awareness. - + Args: layer_scores: Dict mapping layer names to importance scores amount: Fraction to prune (0-1) mode: 'low' (prune low scores) or 'high' dry_run: If True, only compute plan without applying - + Returns: Dictionary with: - 'masks': Pruning masks per layer @@ -158,17 +159,17 @@ def prune( """ # 1. Create initial masks from scores initial_masks = self._create_initial_masks(layer_scores, amount, mode) - + # 2. Propagate masks to handle dependencies propagated_masks = self._propagate_masks(initial_masks) - + # 3. Validate shapes validation = self._validate_pruning_plan(propagated_masks) - + if not validation['valid']: logger.error(f"Pruning plan validation failed: {validation['errors']}") raise ValueError(f"Invalid pruning plan: {validation['errors']}") - + # 4. Apply masks (if not dry-run) if dry_run: stats = self._compute_stats(propagated_masks) @@ -176,13 +177,13 @@ def prune( else: stats = self._apply_masks(propagated_masks) logger.info(f"Applied pruning: {stats['total_params_pruned']}/{stats['total_params']} parameters pruned") - + return { 'masks': propagated_masks, 'stats': stats, 'validation': validation } - + def _create_initial_masks( self, layer_scores: Dict[str, torch.Tensor], @@ -191,34 +192,34 @@ def _create_initial_masks( ) -> Dict[str, torch.Tensor]: """Create initial output masks from importance scores.""" from ..services.mask_ops import MaskOperations - + initial_masks = {} - + for layer_name, scores in layer_scores.items(): if layer_name not in self.dependency_graph.graph: logger.warning(f"Layer {layer_name} not in dependency graph, skipping") continue - + # Create structured mask (output neurons/channels) mask = MaskOperations.create_structured_mask( scores, amount=amount, mode=mode ) - + initial_masks[layer_name] = mask - + logger.debug(f"{layer_name}: {mask.sum()}/{len(mask)} outputs kept") - + return initial_masks - + def _propagate_masks( self, output_masks: Dict[str, torch.Tensor] ) -> Dict[str, Dict[str, torch.Tensor]]: """ Propagate output masks to dependent layers. - + Returns: Dict[layer_name] -> { 'output_mask': mask for output neurons/channels, @@ -227,17 +228,17 @@ def _propagate_masks( } """ propagated = {} - + # Sort layers by dependency order sorted_layers = sorted( self.dependency_graph.graph.values(), key=lambda x: x.index ) - + for dep in sorted_layers: layer_name = dep.name module = dep.module - + # Get output mask (from scores or default to all-keep) if layer_name in output_masks: out_mask = output_masks[layer_name] @@ -245,21 +246,21 @@ def _propagate_masks( # Default: keep all outputs out_dim = self._get_output_dimension(module) out_mask = torch.ones(out_dim, dtype=torch.bool, device=next(module.parameters()).device) - + # Get input mask (from previous layer's output or default) in_mask = self._get_input_mask(dep, propagated) - + # Create weight mask based on layer type weight_mask = self._create_weight_mask(module, out_mask, in_mask) - + propagated[layer_name] = { 'output_mask': out_mask, 'input_mask': in_mask, 'weight_mask': weight_mask } - + return propagated - + def _get_input_mask( self, layer_dep: LayerDependency, @@ -272,11 +273,11 @@ def _get_input_mask( if prev_layer in propagated: # Use previous layer's output mask as our input mask return propagated[prev_layer]['output_mask'].clone() - + # Default: keep all inputs in_dim = self._get_input_dimension(layer_dep.module) return torch.ones(in_dim, dtype=torch.bool, device=next(layer_dep.module.parameters()).device) - + def _create_weight_mask( self, module: nn.Module, @@ -287,27 +288,27 @@ def _create_weight_mask( if isinstance(module, nn.Linear): # Linear: [out_features, in_features] weight_mask = out_mask.unsqueeze(1) & in_mask.unsqueeze(0) - + elif isinstance(module, nn.Conv2d): # Conv2d: [out_channels, in_channels, k_h, k_w] weight_mask = ( out_mask.view(-1, 1, 1, 1) & in_mask.view(1, -1, 1, 1) ).expand_as(module.weight) - + elif isinstance(module, nn.Conv1d): # Conv1d: [out_channels, in_channels, k] weight_mask = ( out_mask.view(-1, 1, 1) & in_mask.view(1, -1, 1) ).expand_as(module.weight) - + else: # Default: mask entire weight tensor weight_mask = torch.ones_like(module.weight, dtype=torch.bool) - + return weight_mask - + def _get_output_dimension(self, module: nn.Module) -> int: """Get output dimension of a module.""" if hasattr(module, 'out_features'): @@ -318,7 +319,7 @@ def _get_output_dimension(self, module: nn.Module) -> int: return module.embed_dim else: raise ValueError(f"Cannot determine output dimension for {type(module)}") - + def _get_input_dimension(self, module: nn.Module) -> int: """Get input dimension of a module.""" if hasattr(module, 'in_features'): @@ -329,31 +330,31 @@ def _get_input_dimension(self, module: nn.Module) -> int: return module.embed_dim else: raise ValueError(f"Cannot determine input dimension for {type(module)}") - + def _validate_pruning_plan( self, propagated_masks: Dict[str, Dict[str, torch.Tensor]] ) -> Dict[str, Any]: """ Validate that pruning plan maintains shape compatibility. - + Returns: {'valid': bool, 'errors': List[str], 'warnings': List[str]} """ errors = [] warnings = [] - + for layer_name, masks in propagated_masks.items(): dep = self.dependency_graph.graph[layer_name] module = dep.module - + # Check weight mask shape matches weight if masks['weight_mask'].shape != module.weight.shape: errors.append( f"{layer_name}: Weight mask shape {masks['weight_mask'].shape} " f"doesn't match weight shape {module.weight.shape}" ) - + # Check output mask dimension expected_out = self._get_output_dimension(module) if len(masks['output_mask']) != expected_out: @@ -361,7 +362,7 @@ def _validate_pruning_plan( f"{layer_name}: Output mask length {len(masks['output_mask'])} " f"doesn't match expected {expected_out}" ) - + # Check input mask dimension expected_in = self._get_input_dimension(module) if len(masks['input_mask']) != expected_in: @@ -369,22 +370,22 @@ def _validate_pruning_plan( f"{layer_name}: Input mask length {len(masks['input_mask'])} " f"doesn't match expected {expected_in}" ) - + # Check for complete pruning (warn if layer fully pruned) if not masks['output_mask'].any(): warnings.append(f"{layer_name}: All outputs pruned - layer is dead!") - + # Check inter-layer compatibility for layer_name, masks in propagated_masks.items(): dep = self.dependency_graph.graph[layer_name] - + if dep.feeds_into: next_layer = dep.feeds_into[0] if next_layer in propagated_masks: # Our output mask should match next layer's input mask our_out = masks['output_mask'] their_in = propagated_masks[next_layer]['input_mask'] - + if len(our_out) != len(their_in): errors.append( f"Dimension mismatch: {layer_name}.out ({len(our_out)}) " @@ -394,20 +395,20 @@ def _validate_pruning_plan( errors.append( f"Mask mismatch: {layer_name}.out_mask != {next_layer}.in_mask" ) - + return { 'valid': len(errors) == 0, 'errors': errors, 'warnings': warnings } - + def _apply_masks( self, propagated_masks: Dict[str, Dict[str, torch.Tensor]] ) -> Dict[str, Any]: """ Apply pruning masks to model weights. - + Returns: Statistics about pruning """ @@ -417,23 +418,23 @@ def _apply_masks( 'total_params_kept': 0, 'total_params_pruned': 0 } - + for layer_name, masks in propagated_masks.items(): module = self.dependency_graph.graph[layer_name].module - + # Apply weight mask with torch.no_grad(): module.weight.data *= masks['weight_mask'].float() - + # Apply bias mask if exists if hasattr(module, 'bias') and module.bias is not None: module.bias.data *= masks['output_mask'].float() - + # Compute statistics params_total = masks['weight_mask'].numel() params_kept = masks['weight_mask'].sum().item() params_pruned = params_total - params_kept - + stats['layers'][layer_name] = { 'params_total': params_total, 'params_kept': params_kept, @@ -442,15 +443,15 @@ def _apply_masks( 'outputs_kept': masks['output_mask'].sum().item(), 'outputs_total': len(masks['output_mask']) } - + stats['total_params'] += params_total stats['total_params_kept'] += params_kept stats['total_params_pruned'] += params_pruned - + stats['overall_sparsity'] = stats['total_params_pruned'] / stats['total_params'] if stats['total_params'] > 0 else 0 - + return stats - + def _compute_stats( self, propagated_masks: Dict[str, Dict[str, torch.Tensor]] @@ -462,12 +463,12 @@ def _compute_stats( 'total_params_kept': 0, 'total_params_pruned': 0 } - + for layer_name, masks in propagated_masks.items(): params_total = masks['weight_mask'].numel() params_kept = masks['weight_mask'].sum().item() params_pruned = params_total - params_kept - + stats['layers'][layer_name] = { 'params_total': params_total, 'params_kept': params_kept, @@ -476,39 +477,39 @@ def _compute_stats( 'outputs_kept': masks['output_mask'].sum().item(), 'outputs_total': len(masks['output_mask']) } - + stats['total_params'] += params_total stats['total_params_kept'] += params_kept stats['total_params_pruned'] += params_pruned - + stats['overall_sparsity'] = stats['total_params_pruned'] / stats['total_params'] - + return stats - + def print_pruning_plan(self, propagated_masks: Dict, show_details: bool = False): """Print human-readable pruning plan.""" print("\n" + "=" * 80) print("Pruning Plan (Dependency-Aware)") print("=" * 80) - + for layer_name in sorted(propagated_masks.keys()): masks = propagated_masks[layer_name] dep = self.dependency_graph.graph[layer_name] - + out_kept = masks['output_mask'].sum().item() out_total = len(masks['output_mask']) in_kept = masks['input_mask'].sum().item() in_total = len(masks['input_mask']) - + print(f"\n{layer_name} ({dep.layer_type}):") print(f" Outputs: {out_kept}/{out_total} kept ({100*out_kept/out_total:.1f}%)") print(f" Inputs: {in_kept}/{in_total} kept ({100*in_kept/in_total:.1f}%)") - + if show_details: weight_kept = masks['weight_mask'].sum().item() weight_total = masks['weight_mask'].numel() print(f" Weights: {weight_kept}/{weight_total} kept ({100*weight_kept/weight_total:.1f}%)") - + print("\n" + "=" * 80) @@ -522,7 +523,7 @@ def prune_model_with_dependencies( ) -> Dict[str, Any]: """ Convenience function for dependency-aware pruning. - + Args: model: Model to prune layer_scores: Importance scores per layer @@ -530,17 +531,17 @@ def prune_model_with_dependencies( mode: 'low' or 'high' dry_run: If True, don't actually prune verbose: Print pruning plan - + Returns: Pruning results """ pruner = DependencyAwarePruning(model) - + result = pruner.prune(layer_scores, amount, mode, dry_run) - + if verbose: pruner.print_pruning_plan(result['masks']) print(f"\nOverall: {result['stats']['overall_sparsity']:.1%} sparsity") - + return result diff --git a/src/alignment/pruning/distribution.py b/src/alignment/pruning/distribution.py index 2a5143d5..78d74c8c 100644 --- a/src/alignment/pruning/distribution.py +++ b/src/alignment/pruning/distribution.py @@ -5,11 +5,12 @@ how much to prune from each individual layer. """ -from typing import Dict, List, Optional, Callable, Tuple -import torch -import torch.nn as nn import logging from enum import Enum +from typing import Callable, Dict, List, Optional + +import torch +import torch.nn as nn logger = logging.getLogger(__name__) @@ -28,10 +29,10 @@ class DistributionStrategy(Enum): class PruningDistributionManager: """ Manages distribution of pruning across layers. - + Determines per-layer pruning amounts to achieve target overall sparsity while considering layer importance, size, sensitivity, etc. - + Example: >>> manager = PruningDistributionManager( ... strategy='adaptive_sensitivity', @@ -42,7 +43,7 @@ class PruningDistributionManager: ... ) >>> # amounts = {'conv1': 0.85, 'conv2': 0.75, 'fc1': 0.50, ...} """ - + def __init__( self, strategy: str = 'uniform', @@ -53,7 +54,7 @@ def __init__( ): """ Initialize distribution manager. - + Args: strategy: Distribution strategy name target_sparsity: Target overall sparsity (0-1) @@ -66,7 +67,7 @@ def __init__( self.min_amount = min_amount self.max_amount = max_amount self.kwargs = kwargs - + def compute_distribution( self, model: nn.Module, @@ -76,50 +77,50 @@ def compute_distribution( ) -> Dict[str, float]: """ Compute per-layer pruning amounts. - + Args: model: Model to analyze layer_names: Layers to prune layer_scores: Pre-computed importance scores per layer eval_fn: Evaluation function for sensitivity measurement - + Returns: Dict mapping layer names to pruning amounts """ if self.strategy == DistributionStrategy.UNIFORM: return self._uniform_distribution(layer_names) - + elif self.strategy == DistributionStrategy.GLOBAL_THRESHOLD: if layer_scores is None: raise ValueError("Global threshold requires layer_scores") return self._global_threshold_distribution(layer_scores, model) - + elif self.strategy == DistributionStrategy.ADAPTIVE_SENSITIVITY: if eval_fn is None: raise ValueError("Adaptive sensitivity requires eval_fn") return self._adaptive_sensitivity_distribution(model, layer_names, eval_fn) - + elif self.strategy == DistributionStrategy.SIZE_PROPORTIONAL: return self._size_proportional_distribution(model, layer_names) - + elif self.strategy == DistributionStrategy.IMPORTANCE_WEIGHTED: if layer_scores is None: raise ValueError("Importance weighted requires layer_scores") return self._importance_weighted_distribution(layer_scores, model) - + elif self.strategy == DistributionStrategy.CASCADING: return self._cascading_distribution(layer_scores, model, layer_names) - + elif self.strategy == DistributionStrategy.HYBRID: return self._hybrid_distribution(model, layer_names, layer_scores, eval_fn) - + else: raise ValueError(f"Unknown strategy: {self.strategy}") - + def _uniform_distribution(self, layer_names: List[str]) -> Dict[str, float]: """Same amount for all layers.""" return {name: self.target_sparsity for name in layer_names} - + def _global_threshold_distribution( self, layer_scores: Dict[str, torch.Tensor], @@ -127,32 +128,32 @@ def _global_threshold_distribution( ) -> Dict[str, float]: """ Compute amounts based on global score threshold. - + Finds threshold such that overall sparsity = target. """ # Collect all scores all_scores = [] layer_sizes = {} - + for layer_name, scores in layer_scores.items(): all_scores.append(scores.flatten()) layer_sizes[layer_name] = len(scores) - + all_scores_cat = torch.cat(all_scores) - + # Find threshold for target sparsity k = int(len(all_scores_cat) * (1 - self.target_sparsity)) # Keep k elements threshold = torch.topk(all_scores_cat, k).values[-1] - + # Compute implied amount per layer amounts = {} for layer_name, scores in layer_scores.items(): # Fraction below threshold in this layer below_threshold = (scores < threshold).float().mean().item() amounts[layer_name] = max(self.min_amount, min(self.max_amount, below_threshold)) - + return amounts - + def _adaptive_sensitivity_distribution( self, model: nn.Module, @@ -161,16 +162,16 @@ def _adaptive_sensitivity_distribution( ) -> Dict[str, float]: """ Adaptive distribution based on layer sensitivity. - + Uses AdaptiveSensitivityPruning logic. """ from .strategies.adaptive import AdaptiveSensitivityPruning - + pruner = AdaptiveSensitivityPruning(target_sparsity=self.target_sparsity) sensitivities = pruner.compute_all_sensitivities(model, layer_names, eval_fn) - + return {name: sens.recommended_amount for name, sens in sensitivities.items()} - + def _size_proportional_distribution( self, model: nn.Module, @@ -178,25 +179,25 @@ def _size_proportional_distribution( ) -> Dict[str, float]: """ Distribute based on layer size. - + Larger layers pruned more (have more params to spare). """ # Get layer sizes layer_sizes = {} total_size = 0 - + for name in layer_names: layer = dict(model.named_modules())[name] size = layer.weight.numel() layer_sizes[name] = size total_size += size - + # Compute fractions layer_fractions = { name: size / total_size for name, size in layer_sizes.items() } - + # Adjust amounts based on size # Larger fraction → prune more amounts = {} @@ -205,12 +206,12 @@ def _size_proportional_distribution( adjustment = (fraction - 0.5) * 0.4 # ±0.2 around target amount = self.target_sparsity + adjustment amounts[name] = max(self.min_amount, min(self.max_amount, amount)) - + # Normalize to hit target amounts = self._normalize_to_target(amounts, layer_sizes) - + return amounts - + def _importance_weighted_distribution( self, layer_scores: Dict[str, torch.Tensor], @@ -218,7 +219,7 @@ def _importance_weighted_distribution( ) -> Dict[str, float]: """ Distribute based on average layer importance. - + Low average importance → prune more. """ # Compute average importance per layer @@ -226,35 +227,35 @@ def _importance_weighted_distribution( name: scores.mean().item() for name, scores in layer_scores.items() } - + # Normalize importances max_importance = max(layer_importance.values()) min_importance = min(layer_importance.values()) importance_range = max_importance - min_importance - + if importance_range < 1e-6: # All same importance → uniform return self._uniform_distribution(list(layer_scores.keys())) - + # Compute amounts (inverse to importance) amounts = {} for name, importance in layer_importance.items(): # Normalize to [0, 1] norm_importance = (importance - min_importance) / importance_range - + # Inverse: high importance → low amount amount = self.target_sparsity + 0.3 * (1 - norm_importance) - 0.15 amounts[name] = max(self.min_amount, min(self.max_amount, amount)) - + # Normalize to hit target layer_sizes = { name: dict(model.named_modules())[name].weight.numel() for name in layer_scores.keys() } amounts = self._normalize_to_target(amounts, layer_sizes) - + return amounts - + def _cascading_distribution( self, layer_scores: Dict[str, torch.Tensor], @@ -263,7 +264,7 @@ def _cascading_distribution( ) -> Dict[str, float]: """ Sequential pruning until target hit. - + Order determined by importance. """ # Rank layers by average importance (lowest first) @@ -276,7 +277,7 @@ def _cascading_distribution( else: # Default order sorted_layers = layer_names - + # Compute total params and target layer_sizes = { name: dict(model.named_modules())[name].weight.numel() @@ -284,15 +285,15 @@ def _cascading_distribution( } total_params = sum(layer_sizes.values()) target_to_remove = int(total_params * self.target_sparsity) - + # Distribute sequentially amounts = {} removed_so_far = 0 - + for layer_name in sorted_layers: remaining_to_remove = target_to_remove - removed_so_far layer_size = layer_sizes[layer_name] - + if remaining_to_remove <= 0: amounts[layer_name] = 0.0 else: @@ -300,9 +301,9 @@ def _cascading_distribution( amount = min(remaining_to_remove / layer_size, self.max_amount) amounts[layer_name] = amount removed_so_far += int(layer_size * amount) - + return amounts - + def _hybrid_distribution( self, model: nn.Module, @@ -312,12 +313,12 @@ def _hybrid_distribution( ) -> Dict[str, float]: """ Hybrid: Combine multiple distribution strategies. - + Weighted average of uniform, global, and adaptive. """ # Compute multiple distributions uniform = self._uniform_distribution(layer_names) - + # Try to compute others if data available if layer_scores: global_dist = self._global_threshold_distribution(layer_scores, model) @@ -325,7 +326,7 @@ def _hybrid_distribution( else: global_dist = uniform importance = uniform - + if eval_fn: try: adaptive = self._adaptive_sensitivity_distribution(model, layer_names, eval_fn) @@ -333,7 +334,7 @@ def _hybrid_distribution( adaptive = uniform else: adaptive = uniform - + # Weighted combination weights = { 'uniform': 0.2, @@ -341,7 +342,7 @@ def _hybrid_distribution( 'importance': 0.2, 'adaptive': 0.3 } - + amounts = {} for layer_name in layer_names: amount = ( @@ -351,9 +352,9 @@ def _hybrid_distribution( weights['adaptive'] * adaptive[layer_name] ) amounts[layer_name] = max(self.min_amount, min(self.max_amount, amount)) - + return amounts - + def _normalize_to_target( self, amounts: Dict[str, float], @@ -361,11 +362,11 @@ def _normalize_to_target( ) -> Dict[str, float]: """ Normalize amounts to exactly hit target overall sparsity. - + Args: amounts: Initial per-layer amounts layer_sizes: Parameters per layer - + Returns: Normalized amounts """ @@ -373,21 +374,21 @@ def _normalize_to_target( current_total = sum(amounts[name] * layer_sizes[name] for name in amounts) total_params = sum(layer_sizes.values()) target_total = self.target_sparsity * total_params - + if current_total < 1e-6: return amounts - + # Scale factor scale = target_total / current_total - + # Apply scaling and clip normalized = {} for name in amounts: scaled_amount = amounts[name] * scale normalized[name] = max(self.min_amount, min(self.max_amount, scaled_amount)) - + return normalized - + def print_distribution( self, amounts: Dict[str, float], @@ -399,45 +400,45 @@ def print_distribution( print(f"Pruning Distribution ({self.strategy.value})") print(f"Target Overall Sparsity: {self.target_sparsity:.1%}") print("=" * 80) - + # Compute statistics layer_info = [] total_params = 0 total_pruned = 0 - + for layer_name in sorted(amounts.keys()): layer = dict(model.named_modules())[layer_name] size = layer.weight.numel() amount = amounts[layer_name] pruned = int(size * amount) - + total_params += size total_pruned += pruned - + avg_score = layer_scores[layer_name].mean().item() if layer_scores and layer_name in layer_scores else None - + layer_info.append((layer_name, size, amount, pruned, avg_score)) - + # Print table print(f"\n{'Layer':<25} {'Size':<10} {'Amount':<8} {'Pruned':<10} {'Avg Score':<10}") print("-" * 80) - + for name, size, amount, pruned, avg_score in layer_info: score_str = f"{avg_score:.4f}" if avg_score is not None else "N/A" print(f"{name:<25} {size:<10} {amount:>7.1%} {pruned:<10} {score_str:<10}") - + # Summary overall = total_pruned / total_params if total_params > 0 else 0 - + print("-" * 80) print(f"{'TOTAL':<25} {total_params:<10} {overall:>7.1%} {total_pruned:<10}") print("=" * 80) - + if abs(overall - self.target_sparsity) > 0.01: print(f"Warning: Achieved {overall:.1%} vs target {self.target_sparsity:.1%}") else: print(f"Target sparsity achieved: {overall:.1%}") - + print() @@ -450,14 +451,14 @@ def compute_pruning_distribution( ) -> Dict[str, float]: """ Convenience function for computing pruning distribution. - + Args: model: Model to analyze layer_names: Layers to prune strategy: Distribution strategy target_sparsity: Target overall sparsity **kwargs: Strategy-specific parameters (e.g., eval_fn, layer_scores) - + Returns: Per-layer pruning amounts """ @@ -465,6 +466,6 @@ def compute_pruning_distribution( strategy=strategy, target_sparsity=target_sparsity ) - + return manager.compute_distribution(model, layer_names, **kwargs) diff --git a/src/alignment/pruning/experiments/__init__.py b/src/alignment/pruning/experiments/__init__.py index bec565ce..4530eb26 100644 --- a/src/alignment/pruning/experiments/__init__.py +++ b/src/alignment/pruning/experiments/__init__.py @@ -2,10 +2,10 @@ Pruning experiments for alignment analysis. """ -from .eigenvector_based import EigenvectorDropoutExperiment, EigenvectorConfig -from .cascading_layer import CascadingLayerPruningExperiment, CascadingConfig -from .layer_wise import LayerIsolatedPruningExperiment, LayerIsolatedConfig -from .global_pruning import GlobalDropoutExperiment, GlobalDropoutConfig +from .cascading_layer import CascadingConfig, CascadingLayerPruningExperiment +from .eigenvector_based import EigenvectorConfig, EigenvectorDropoutExperiment +from .global_pruning import GlobalDropoutConfig, GlobalDropoutExperiment +from .layer_wise import LayerIsolatedConfig, LayerIsolatedPruningExperiment __all__ = [ 'EigenvectorDropoutExperiment', diff --git a/src/alignment/pruning/experiments/cascading_layer.py b/src/alignment/pruning/experiments/cascading_layer.py index c68c508b..1a7157c8 100644 --- a/src/alignment/pruning/experiments/cascading_layer.py +++ b/src/alignment/pruning/experiments/cascading_layer.py @@ -5,21 +5,22 @@ where pruning in earlier layers affects later layers. """ -from typing import Dict, List, Optional, Any, Tuple -import torch -import numpy as np import logging -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import torch -from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.core.registry import register_experiment -from alignment.models import ModelWrapper +from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.experiments.training_utils import ( + convert_training_history, create_experiment_trainer, train_with_metrics, - convert_training_history ) +from alignment.models import ModelWrapper logger = logging.getLogger(__name__) diff --git a/src/alignment/pruning/experiments/eigenvector_based.py b/src/alignment/pruning/experiments/eigenvector_based.py index 993624c4..6bbadd0a 100644 --- a/src/alignment/pruning/experiments/eigenvector_based.py +++ b/src/alignment/pruning/experiments/eigenvector_based.py @@ -5,18 +5,19 @@ dropping neurons based on their contribution to principal components. """ -from typing import Dict, List, Optional, Any, Tuple -import torch -import numpy as np import logging -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import torch -from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.core.registry import register_experiment +from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.experiments.training_utils import ( + convert_training_history, create_experiment_trainer, train_with_metrics, - convert_training_history ) logger = logging.getLogger(__name__) diff --git a/src/alignment/pruning/experiments/global_pruning.py b/src/alignment/pruning/experiments/global_pruning.py index 66c32caa..c8817b68 100644 --- a/src/alignment/pruning/experiments/global_pruning.py +++ b/src/alignment/pruning/experiments/global_pruning.py @@ -5,21 +5,22 @@ across all layers and track changes in alignment metrics. """ -from typing import Dict, List, Optional, Any +import logging +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np import torch import torch.nn as nn -import numpy as np -from pathlib import Path -import logging -from dataclasses import dataclass, field, asdict -from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.core.registry import register_experiment +from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.experiments.config_components import PruningConfig from alignment.experiments.training_utils import ( + convert_training_history, create_experiment_trainer, train_with_metrics, - convert_training_history ) logger = logging.getLogger(__name__) diff --git a/src/alignment/pruning/experiments/layer_wise.py b/src/alignment/pruning/experiments/layer_wise.py index 460aa43a..6df98def 100644 --- a/src/alignment/pruning/experiments/layer_wise.py +++ b/src/alignment/pruning/experiments/layer_wise.py @@ -5,22 +5,23 @@ based on its alignment scores, without considering other layers. """ -from typing import Dict, List, Optional, Any, Tuple -import torch -import numpy as np +import json import logging -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from pathlib import Path -import json +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import torch -from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.core.registry import register_experiment -from alignment.models import ModelWrapper +from alignment.experiments.base import BaseExperiment, ExperimentConfig from alignment.experiments.training_utils import ( + convert_training_history, create_experiment_trainer, train_with_metrics, - convert_training_history ) +from alignment.models import ModelWrapper logger = logging.getLogger(__name__) diff --git a/src/alignment/pruning/orchestrator.py b/src/alignment/pruning/orchestrator.py index 24379ec7..2deff05f 100644 --- a/src/alignment/pruning/orchestrator.py +++ b/src/alignment/pruning/orchestrator.py @@ -11,11 +11,12 @@ Provides simple high-level API for comprehensive pruning experiments. """ -import torch -import torch.nn as nn -from typing import Dict, List, Optional, Callable, Any import logging from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional + +import torch +import torch.nn as nn logger = logging.getLogger(__name__) @@ -34,16 +35,16 @@ class PruningPlan: class MasterPruningOrchestrator: """ High-level orchestrator for complete pruning workflows. - + Handles everything: - Distribution across layers (uniform, adaptive, global, etc.) - Scoring (single metric, composite, dynamic) - Direction (low, high, random) - Dependencies (conv, attention) - Parallelization (multiple strategies/networks) - + One-liner API for comprehensive experiments! - + Example: >>> orchestrator = MasterPruningOrchestrator() >>> result = orchestrator.prune_complete( @@ -57,7 +58,7 @@ class MasterPruningOrchestrator: ... ) >>> print(f"Accuracy: {result['baseline']}% → {result['final']}%") """ - + def __init__( self, verbose: bool = True, @@ -66,7 +67,7 @@ def __init__( ): """ Initialize orchestrator. - + Args: verbose: Print detailed progress parallel: Use parallel optimization when possible @@ -75,7 +76,7 @@ def __init__( self.verbose = verbose self.parallel = parallel self.num_workers = num_workers - + def prune_complete( self, model: nn.Module, @@ -93,7 +94,7 @@ def prune_complete( ) -> Dict[str, Any]: """ Complete pruning workflow with all options. - + Args: model: Model to prune target_sparsity: Overall target (e.g., 0.7 for 70%) @@ -119,7 +120,7 @@ def prune_complete( eval_fn: Function(model, val_loader) -> accuracy layers: Specific layers to prune (None = auto-detect) fine_tune_epochs: Epochs to fine-tune after pruning - + Returns: Complete results dictionary """ @@ -133,25 +134,25 @@ def prune_complete( print(f"Direction: {direction}") print(f"Dynamic scores: {use_dynamic}") print("=" * 80 + "\n") - + # Auto-detect layers if needed if layers is None: from ..core.layer_detector import detect_trackable_layers layers = detect_trackable_layers(model) if self.verbose: print(f"Auto-detected {len(layers)} trackable layers\n") - + # Baseline evaluation baseline_acc = None if eval_fn and val_loader: baseline_acc = eval_fn(model, val_loader) if self.verbose: print(f"Baseline accuracy: {baseline_acc:.2f}%\n") - + # Step 1: Compute scores if self.verbose: print("Step 1: Computing importance scores...") - + if use_dynamic and train_loader: layer_scores = self._compute_dynamic_scores( model, train_loader, layers, scoring @@ -160,80 +161,80 @@ def prune_complete( layer_scores = self._compute_static_scores( model, val_loader or train_loader, layers, scoring ) - + if self.verbose: print(f"Computed scores for {len(layer_scores)} layers\n") - + # Step 2: Compute distribution if self.verbose: print(f"Step 2: Computing {distribution} distribution...") - + from .distribution import PruningDistributionManager - + dist_manager = PruningDistributionManager( strategy=distribution, target_sparsity=target_sparsity ) - + per_layer_amounts = dist_manager.compute_distribution( model, layers, layer_scores=layer_scores, eval_fn=lambda m: eval_fn(m, val_loader) if eval_fn and val_loader else None ) - + if self.verbose: dist_manager.print_distribution(per_layer_amounts, model, layer_scores) - + # Step 3: Create masks if self.verbose: print("Step 3: Creating pruning masks...") - + from ..services import MaskOperations - + masks = {} for layer_name in layers: if layer_name not in layer_scores or layer_name not in per_layer_amounts: continue - + mask = MaskOperations.create_structured_mask( layer_scores[layer_name], amount=per_layer_amounts[layer_name], mode=direction ) masks[layer_name] = mask - + if self.verbose: print(f"Created masks for {len(masks)} layers\n") - + # Step 4: Apply with dependency awareness if self.verbose: print("Step 4: Applying pruning (dependency-aware)...") - + from .dependency_aware import DependencyAwarePruning - + dep_pruner = DependencyAwarePruning(model) - + # Convert masks to scores for dependency pruner interface pruning_result = dep_pruner.prune( layer_scores, amount=target_sparsity, # Overall target dry_run=False ) - + if self.verbose: - print(f"Applied pruning\n") - + print("Applied pruning\n") + # Step 5: Fine-tune if trainer_fn and train_loader and fine_tune_epochs > 0: if self.verbose: print(f"Step 5: Fine-tuning for {fine_tune_epochs} epochs...") - + trainer_fn(model, train_loader, epochs=fine_tune_epochs) - + if self.verbose: print("Fine-tuning complete\n") - + # Step 6: Final evaluation final_acc = None if eval_fn and val_loader: @@ -243,7 +244,7 @@ def prune_complete( if baseline_acc: drop = baseline_acc - final_acc print(f"Accuracy drop: {drop:.2f}%\n") - + # Return complete results return { 'baseline_accuracy': baseline_acc, @@ -256,7 +257,7 @@ def prune_complete( 'masks': masks, 'pruning_stats': pruning_result['stats'] } - + def _compute_static_scores( self, model: nn.Module, @@ -265,23 +266,23 @@ def _compute_static_scores( scoring: str ) -> Dict[str, torch.Tensor]: """Compute scores on current (trained) model.""" - from ..services import ActivationCaptureService, NodeScoringService - from ..models import BaseModelWrapper from ..metrics import get_metric - + from ..models import BaseModelWrapper + from ..services import ActivationCaptureService, NodeScoringService + # Wrap model wrapper = BaseModelWrapper(model, tracked_layers=layers) capture = ActivationCaptureService(wrapper) - + # Get batch inputs, targets = next(iter(data_loader)) if torch.cuda.is_available(): inputs = inputs.cuda() targets = targets.cuda() - + # Capture activations data = capture.capture(inputs, include_weights=True) - + # Compute scores based on method if scoring == 'magnitude': scores = {} @@ -289,29 +290,29 @@ def _compute_static_scores( if layer in data.weights: weights = data.weights[layer] scores[layer] = weights.abs().mean(dim=list(range(1, weights.ndim))) - + elif scoring == 'rayleigh_quotient': rq = get_metric('rayleigh_quotient') scores = {} for layer in layers: if layer in data.inputs and layer in data.weights: scores[layer] = rq.compute(data.inputs[layer], data.weights[layer]) - + elif scoring == 'composite': scorer = NodeScoringService(metrics={ 'rq': get_metric('rayleigh_quotient'), 'redundancy': get_metric('pairwise_redundancy_gaussian', mode='output_based', num_pairs=10), 'synergy': get_metric('synergy_gaussian_mmi', num_pairs=10) }) - + layer_scores_obj = scorer.compute_layerwise_scores(data, targets) scores = {name: ls.composite for name, ls in layer_scores_obj.items()} - + else: raise ValueError(f"Unknown scoring method: {scoring}") - + return scores - + def _compute_dynamic_scores( self, model: nn.Module, @@ -321,7 +322,7 @@ def _compute_dynamic_scores( ) -> Dict[str, torch.Tensor]: """ Compute scores using training dynamics. - + Note: Requires training with callback - placeholder for now. Future: Integrate with training history. """ @@ -330,7 +331,7 @@ def _compute_dynamic_scores( "Falling back to static scores. " "See dynamic_scoring.py for full implementation." ) - + # Fallback to static return self._compute_static_scores(model, train_loader, layers, scoring) @@ -342,12 +343,12 @@ def prune_with_all_options( ) -> Dict: """ One-liner for complete pruning with all options. - + Args: model: Model to prune target_sparsity: Target overall sparsity **kwargs: All options (distribution, scoring, etc.) - + Returns: Complete results """ diff --git a/src/alignment/pruning/parallel_optimizer.py b/src/alignment/pruning/parallel_optimizer.py index c0d1520f..1fe3e14d 100644 --- a/src/alignment/pruning/parallel_optimizer.py +++ b/src/alignment/pruning/parallel_optimizer.py @@ -7,12 +7,13 @@ 3. Multiple layers (concurrent processing) """ -import torch -import torch.nn as nn -from typing import Dict, List, Optional, Any, Callable import copy -from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor import logging +from concurrent.futures import ThreadPoolExecutor +from typing import Callable, Dict, List, Optional + +import torch +import torch.nn as nn logger = logging.getLogger(__name__) @@ -20,15 +21,15 @@ class ParallelPruningOptimizer: """ Optimize pruning by parallelizing computation. - + Key optimizations: 1. Shared activation capture (one forward pass for all metrics) 2. Batched metric computation (vectorized across neurons) 3. Parallel strategy comparison (test multiple approaches) 4. Multi-network ensemble pruning - + Performance: N strategies × M networks in ~1.5x time of single case - + Example: >>> optimizer = ParallelPruningOptimizer() >>> results = optimizer.compare_strategies_parallel( @@ -39,7 +40,7 @@ class ParallelPruningOptimizer: ... ) >>> # Results for all strategy×amount combinations in parallel! """ - + def __init__( self, num_workers: int = 4, @@ -48,7 +49,7 @@ def __init__( ): """ Initialize parallel optimizer. - + Args: num_workers: Number of parallel workers use_gpu: Whether to use GPU for computation @@ -57,7 +58,7 @@ def __init__( self.num_workers = num_workers self.use_gpu = use_gpu self.shared_computation = shared_computation - + def compare_strategies_parallel( self, base_model: nn.Module, @@ -69,7 +70,7 @@ def compare_strategies_parallel( ) -> Dict[Tuple[str, float], Dict]: """ Compare multiple pruning strategies in parallel. - + Args: base_model: Base model (will be copied for each strategy) strategies: List of strategy names to compare @@ -77,7 +78,7 @@ def compare_strategies_parallel( data_loader: Data for metric computation eval_fn: Evaluation function layers: Layers to prune (None = auto-detect) - + Returns: Dict[(strategy, amount)] -> {'accuracy': X, 'mask': M, ...} """ @@ -87,18 +88,18 @@ def compare_strategies_parallel( for strategy in strategies for amount in amounts ] - + logger.info(f"Running {len(experiments)} experiments in parallel...") - + # Shared computation: capture activations once if self.shared_computation: shared_data = self._capture_shared_data(base_model, data_loader, layers) else: shared_data = None - + # Parallel execution results = {} - + # For GPU, sequential is better (avoid memory issues) # For CPU, can parallelize if self.use_gpu or self.num_workers == 1: @@ -120,16 +121,16 @@ def compare_strategies_parallel( ): (strategy, amount) for strategy, amount in experiments } - + for future in futures: strategy, amount = futures[future] results[(strategy, amount)] = future.result() - + # Print comparison self._print_comparison(results, strategies, amounts) - + return results - + def _capture_shared_data( self, model: nn.Module, @@ -137,25 +138,25 @@ def _capture_shared_data( layers: Optional[List[str]] ) -> Dict: """Capture activations and weights once for all strategies.""" - from ..services import ActivationCaptureService from ..models import BaseModelWrapper - + from ..services import ActivationCaptureService + wrapper = BaseModelWrapper(model, tracked_layers=layers) capture = ActivationCaptureService(wrapper) - + # Capture on a batch inputs, targets = next(iter(data_loader)) if self.use_gpu and torch.cuda.is_available(): inputs = inputs.cuda() targets = targets.cuda() - + data = capture.capture(inputs, include_weights=True) - + return { 'activation_data': data, 'targets': targets } - + def _run_single_experiment( self, base_model: nn.Module, @@ -166,17 +167,17 @@ def _run_single_experiment( layers: Optional[List[str]] ) -> Dict: """Run a single pruning experiment.""" - from ..services import NodeScoringService, MaskOperations from ..metrics import get_metric - + from ..services import MaskOperations, NodeScoringService + # Clone model model = copy.deepcopy(base_model) - + # Compute scores using shared data if shared_data and strategy in ['alignment', 'composite']: data = shared_data['activation_data'] targets = shared_data['targets'] - + if strategy == 'alignment': scorer = NodeScoringService(metrics={ 'rq': get_metric('rayleigh_quotient') @@ -186,13 +187,13 @@ def _run_single_experiment( 'rq': get_metric('rayleigh_quotient'), 'redundancy': get_metric('pairwise_redundancy_gaussian', mode='output_based') }) - + layer_scores = scorer.compute_layerwise_scores(data, targets) scores_dict = { name: layer_scores[name].composite for name in layer_scores } - + elif strategy == 'magnitude': # Magnitude scores scores_dict = {} @@ -200,7 +201,7 @@ def _run_single_experiment( if layers is None or name in layers: if hasattr(module, 'weight'): scores_dict[name] = module.weight.abs().mean(dim=list(range(1, module.weight.ndim))) - + elif strategy == 'random': # Random scores scores_dict = {} @@ -209,32 +210,32 @@ def _run_single_experiment( if hasattr(module, 'weight'): out_dim = module.weight.shape[0] scores_dict[name] = torch.rand(out_dim) - + else: raise ValueError(f"Unknown strategy: {strategy}") - + # Create masks masks = {} for layer_name, scores in scores_dict.items(): mask = MaskOperations.create_structured_mask(scores, amount, mode='low') masks[layer_name] = mask - + # Apply pruning for layer_name, mask in masks.items(): module = dict(model.named_modules())[layer_name] if hasattr(module, 'weight'): module.weight.data *= mask.unsqueeze(1).float() - + # Evaluate accuracy = eval_fn(model) - + return { 'strategy': strategy, 'amount': amount, 'accuracy': accuracy, 'masks': masks } - + def _print_comparison( self, results: Dict, @@ -245,14 +246,14 @@ def _print_comparison( print("\n" + "=" * 80) print("Parallel Strategy Comparison") print("=" * 80) - + # Create table print(f"\n{'Strategy':<20} ", end='') for amount in amounts: print(f"{amount:>8.0%} ", end='') print() print("-" * 80) - + for strategy in strategies: print(f"{strategy:<20} ", end='') for amount in amounts: @@ -263,9 +264,9 @@ def _print_comparison( else: print(f"{'N/A':>9} ", end='') print() - + print("=" * 80 + "\n") - + def prune_ensemble_parallel( self, networks: List[nn.Module], @@ -275,13 +276,13 @@ def prune_ensemble_parallel( ) -> List[Dict]: """ Prune multiple networks in parallel with shared computation. - + Args: networks: List of networks (same architecture) strategy: Pruning strategy amount: Pruning amount shared_inputs: Input batch (same for all networks) - + Returns: List of results per network """ @@ -290,30 +291,30 @@ def prune_ensemble_parallel( shared_cov = torch.cov(shared_inputs.T) else: shared_cov = None - + # Process each network results = [] - + for net_idx, network in enumerate(networks): # Compute scores (using shared covariance if available) scores = self._compute_scores_with_shared_cov( network, shared_inputs, shared_cov, strategy ) - + # Prune masks = self._create_and_apply_masks(network, scores, amount) - + results.append({ 'network_idx': net_idx, 'masks': masks, 'strategy': strategy, 'amount': amount }) - + logger.info(f"Pruned {len(networks)} networks in parallel") - + return results - + def _compute_scores_with_shared_cov( self, network: nn.Module, @@ -323,12 +324,12 @@ def _compute_scores_with_shared_cov( ) -> Dict[str, torch.Tensor]: """Compute scores using shared covariance.""" from ..metrics import get_metric - + scores = {} - + if strategy == 'alignment' or strategy == 'composite': rq = get_metric('rayleigh_quotient') - + for name, module in network.named_modules(): if hasattr(module, 'weight'): if shared_cov is not None: @@ -336,26 +337,26 @@ def _compute_scores_with_shared_cov( weights = module.weight if weights.ndim > 2: weights = weights.reshape(weights.shape[0], -1) - + # RQ = (w @ cov @ w.T).diag() / (w @ w.T).diag() / tr(cov) wc = weights @ shared_cov numerator = (wc * weights).sum(dim=1) denominator = (weights ** 2).sum(dim=1) rq_scores = numerator / (denominator + 1e-12) rq_scores = rq_scores / (shared_cov.trace() + 1e-12) - + scores[name] = rq_scores else: # Compute normally scores[name] = rq.compute(inputs, module.weight) - + else: # magnitude or other for name, module in network.named_modules(): if hasattr(module, 'weight'): scores[name] = module.weight.abs().mean(dim=list(range(1, module.weight.ndim))) - + return scores - + def _create_and_apply_masks( self, network: nn.Module, @@ -364,17 +365,17 @@ def _create_and_apply_masks( ) -> Dict[str, torch.Tensor]: """Create and apply masks.""" from ..services import MaskOperations - + masks = {} - + for layer_name, layer_scores in scores.items(): mask = MaskOperations.create_structured_mask(layer_scores, amount, mode='low') masks[layer_name] = mask - + # Apply module = dict(network.named_modules())[layer_name] if hasattr(module, 'weight'): module.weight.data *= mask.unsqueeze(1).float() - + return masks diff --git a/src/alignment/pruning/strategies/__init__.py b/src/alignment/pruning/strategies/__init__.py index 80172ee2..94e80438 100644 --- a/src/alignment/pruning/strategies/__init__.py +++ b/src/alignment/pruning/strategies/__init__.py @@ -2,61 +2,44 @@ Pruning strategies for the alignment framework. """ +from .alignment_based import AlignmentPruning, GlobalAlignmentPruning, HybridPruning +from .cascading import CascadingAlignmentPruning +from .gradient import FisherPruning, GradientPruning, MomentumPruning from .magnitude import ( - MagnitudePruning, - IterativeMagnitudePruning, GlobalMagnitudePruning, + IterativeMagnitudePruning, + MagnitudePruning, ) -from .gradient import ( - GradientPruning, - FisherPruning, - MomentumPruning, -) -from .random import ( - RandomPruning, - LayerwiseRandomPruning, - BernoulliPruning, -) -from .parallel import ( - ParallelModePruning, - TensorizedPruning, - AsyncParallelPruning, -) +from .parallel import AsyncParallelPruning, ParallelModePruning, TensorizedPruning from .parallel_batch import ParallelBatchPruning - -from .alignment_based import ( - AlignmentPruning, - HybridPruning, - GlobalAlignmentPruning, -) -from .cascading import CascadingAlignmentPruning +from .random import BernoulliPruning, LayerwiseRandomPruning, RandomPruning __all__ = [ # Magnitude 'MagnitudePruning', 'IterativeMagnitudePruning', 'GlobalMagnitudePruning', - + # Gradient 'GradientPruning', 'FisherPruning', 'MomentumPruning', - + # Random 'RandomPruning', 'LayerwiseRandomPruning', 'BernoulliPruning', - + # Parallel 'ParallelModePruning', 'TensorizedPruning', 'AsyncParallelPruning', 'ParallelBatchPruning', - + # Alignment-based 'AlignmentPruning', 'HybridPruning', 'GlobalAlignmentPruning', 'CascadingAlignmentPruning', -] \ No newline at end of file +] diff --git a/src/alignment/pruning/strategies/adaptive.py b/src/alignment/pruning/strategies/adaptive.py index eb174714..118f8b34 100644 --- a/src/alignment/pruning/strategies/adaptive.py +++ b/src/alignment/pruning/strategies/adaptive.py @@ -5,11 +5,12 @@ achieving target overall sparsity with minimal accuracy loss. """ -import torch -import torch.nn as nn -from typing import Dict, List, Optional, Callable, Any import logging from dataclasses import dataclass +from typing import Callable, Dict, List, Optional + +import torch +import torch.nn as nn from ..base import BasePruningStrategy, PruningConfig @@ -28,17 +29,17 @@ class LayerSensitivity: class AdaptiveSensitivityPruning(BasePruningStrategy): """ Adaptive pruning that adjusts amounts per layer based on sensitivity. - + Algorithm: 1. Measure each layer's sensitivity to perturbation 2. Assign pruning amounts inversely to sensitivity 3. Normalize to achieve target overall sparsity 4. Prune each layer with its custom amount - + Result: Sensitive layers pruned less, robust layers pruned more. - + Expected improvement: +5-10% accuracy retention vs uniform pruning - + Example: >>> strategy = AdaptiveSensitivityPruning( ... target_sparsity=0.7, @@ -51,7 +52,7 @@ class AdaptiveSensitivityPruning(BasePruningStrategy): >>> # fc1: high → prune 50% >>> # Overall: 70% average """ - + def __init__( self, target_sparsity: float = 0.5, @@ -64,7 +65,7 @@ def __init__( ): """ Initialize adaptive sensitivity pruning. - + Args: target_sparsity: Target overall sparsity (0-1) metric: Metric to use for importance scores @@ -81,10 +82,10 @@ def __init__( self.sensitivity_method = sensitivity_method self.min_amount = min_amount self.max_amount = max_amount - + # Will be populated during sensitivity analysis self.layer_sensitivities: Dict[str, LayerSensitivity] = {} - + def compute_importance_scores( self, module: nn.Module, @@ -93,30 +94,30 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores using specified metric. - + This is used within each layer after amounts are determined. """ from ...metrics import get_metric - + # Get metric metric = get_metric(self.metric_name, **self.metric_kwargs) - + # Compute scores if hasattr(metric, 'requires_outputs') and metric.requires_outputs: outputs = kwargs.get('outputs') scores = metric.compute(inputs=inputs, weights=module.weight, outputs=outputs) else: scores = metric.compute(inputs=inputs, weights=module.weight) - + # Expand to weight shape if structured if self.config.structured and scores.ndim < module.weight.ndim: # Expand neuron/channel scores to full weight tensor shape = [1] * module.weight.ndim shape[0] = scores.shape[0] scores = scores.reshape(shape).expand_as(module.weight) - + return scores - + def measure_layer_sensitivity( self, model: nn.Module, @@ -127,60 +128,60 @@ def measure_layer_sensitivity( ) -> float: """ Measure sensitivity of a single layer. - + Args: model: Full model layer_name: Name of layer to test eval_fn: Function that evaluates model and returns accuracy perturbation_scale: Scale of perturbation num_trials: Number of trials to average - + Returns: Sensitivity (accuracy drop when layer perturbed) """ # Get baseline accuracy baseline_acc = eval_fn(model) - + # Get layer layer = dict(model.named_modules())[layer_name] - + if not hasattr(layer, 'weight'): return 0.0 - + # Store original weight original_weight = layer.weight.data.clone() - + # Measure sensitivity via perturbation sensitivities = [] - + for _ in range(num_trials): # Perturb layer if self.sensitivity_method == 'perturbation': perturbation = perturbation_scale * torch.randn_like(layer.weight) layer.weight.data = original_weight + perturbation - + elif self.sensitivity_method == 'masking': # Random masking (simulate pruning) mask = torch.rand_like(layer.weight) > 0.3 # Mask 30% layer.weight.data = original_weight * mask - + # Evaluate perturbed_acc = eval_fn(model) - + # Sensitivity = drop in accuracy sensitivity = max(0, baseline_acc - perturbed_acc) sensitivities.append(sensitivity) - + # Restore layer.weight.data = original_weight - + # Average sensitivity avg_sensitivity = sum(sensitivities) / len(sensitivities) - + logger.debug(f"{layer_name}: sensitivity = {avg_sensitivity:.4f}") - + return avg_sensitivity - + def compute_all_sensitivities( self, model: nn.Module, @@ -189,28 +190,28 @@ def compute_all_sensitivities( ) -> Dict[str, LayerSensitivity]: """ Compute sensitivities for all specified layers. - + Args: model: Model to analyze layer_names: Layers to analyze eval_fn: Evaluation function - + Returns: Dict mapping layer names to LayerSensitivity objects """ logger.info(f"Computing sensitivities for {len(layer_names)} layers...") - + sensitivities = {} - + for layer_name in layer_names: layer = dict(model.named_modules())[layer_name] - + # Measure sensitivity sens = self.measure_layer_sensitivity(model, layer_name, eval_fn) - + # Get layer size size = layer.weight.numel() - + # Store sensitivities[layer_name] = LayerSensitivity( name=layer_name, @@ -218,76 +219,76 @@ def compute_all_sensitivities( size=size, recommended_amount=0.0 # Will be computed next ) - + # Compute recommended amounts sensitivities = self._compute_adaptive_amounts(sensitivities) - + self.layer_sensitivities = sensitivities - + return sensitivities - + def _compute_adaptive_amounts( self, sensitivities: Dict[str, LayerSensitivity] ) -> Dict[str, LayerSensitivity]: """ Compute adaptive pruning amounts based on sensitivities. - + High sensitivity → prune less Low sensitivity → prune more - + Normalized to achieve target overall sparsity. """ if not sensitivities: return sensitivities - + # Get sensitivity values sens_values = [s.sensitivity for s in sensitivities.values()] max_sens = max(sens_values) if sens_values else 1.0 min_sens = min(sens_values) if sens_values else 0.0 sens_range = max_sens - min_sens if max_sens > min_sens else 1.0 - + # Compute initial amounts (inverse to sensitivity) initial_amounts = {} for name, layer_sens in sensitivities.items(): # Normalize sensitivity to [0, 1] norm_sens = (layer_sens.sensitivity - min_sens) / sens_range if sens_range > 0 else 0.5 - + # Inverse relationship: high sensitivity → low amount # amount = max_amount - (max_amount - min_amount) × norm_sens amount = self.max_amount - (self.max_amount - self.min_amount) * norm_sens - + initial_amounts[name] = amount - + # Normalize to achieve target overall sparsity total_params = sum(s.size for s in sensitivities.values()) target_params_to_prune = int(total_params * self.target_sparsity) - + # Compute current total current_pruned = sum( sensitivities[name].size * initial_amounts[name] for name in sensitivities ) - + # Scale amounts to hit target scale_factor = target_params_to_prune / current_pruned if current_pruned > 0 else 1.0 - + # Apply scaling and update for name, layer_sens in sensitivities.items(): adapted_amount = initial_amounts[name] * scale_factor - + # Clip to valid range adapted_amount = max(self.min_amount, min(self.max_amount, adapted_amount)) - + layer_sens.recommended_amount = adapted_amount - + logger.info( f"{name}: sensitivity={layer_sens.sensitivity:.4f}, " f"amount={adapted_amount:.1%}" ) - + return sensitivities - + def prune_adaptive( self, model: nn.Module, @@ -297,69 +298,69 @@ def prune_adaptive( ) -> Dict[str, torch.Tensor]: """ Prune model adaptively based on layer sensitivities. - + Args: model: Model to prune layer_names: Layers to prune eval_fn: Evaluation function (model) -> accuracy inputs_per_layer: Optional cached inputs for each layer - + Returns: Dict of masks per layer """ # 1. Compute sensitivities if not already done if not self.layer_sensitivities: self.compute_all_sensitivities(model, layer_names, eval_fn) - + # 2. Prune each layer with its adaptive amount masks = {} - + for layer_name in layer_names: layer = dict(model.named_modules())[layer_name] layer_sens = self.layer_sensitivities[layer_name] - + # Get inputs if available inputs = inputs_per_layer.get(layer_name) if inputs_per_layer else None - + # Compute importance scores scores = self.compute_importance_scores(layer, inputs=inputs) - + # Create mask with adaptive amount mask = self.create_pruning_mask( scores, amount=layer_sens.recommended_amount, structured=self.config.structured ) - + masks[layer_name] = mask - + logger.info( f"{layer_name}: pruning {layer_sens.recommended_amount:.1%} " f"(sensitivity: {layer_sens.sensitivity:.4f})" ) - + return masks - + def print_sensitivity_report(self): """Print human-readable sensitivity report.""" if not self.layer_sensitivities: print("No sensitivity data available. Run compute_all_sensitivities() first.") return - + print("\n" + "=" * 80) print("Layer Sensitivity Analysis") print("=" * 80) - + # Sort by sensitivity sorted_layers = sorted( self.layer_sensitivities.values(), key=lambda x: x.sensitivity, reverse=True ) - + print(f"\n{'Layer':<30} {'Sensitivity':<12} {'Size':<10} {'Pruning %'}") print("-" * 80) - + for layer_sens in sorted_layers: print( f"{layer_sens.name:<30} " @@ -367,17 +368,17 @@ def print_sensitivity_report(self): f"{layer_sens.size:<10} " f"{layer_sens.recommended_amount:>8.1%}" ) - + print("\n" + "=" * 80) - + # Summary statistics total_size = sum(s.size for s in self.layer_sensitivities.values()) total_pruned = sum( - s.size * s.recommended_amount + s.size * s.recommended_amount for s in self.layer_sensitivities.values() ) overall_sparsity = total_pruned / total_size - + print(f"Overall sparsity: {overall_sparsity:.1%}") print(f"Target sparsity: {self.target_sparsity:.1%}") print("=" * 80 + "\n") diff --git a/src/alignment/pruning/strategies/alignment_based.py b/src/alignment/pruning/strategies/alignment_based.py index f7e876d5..dcd07d20 100644 --- a/src/alignment/pruning/strategies/alignment_based.py +++ b/src/alignment/pruning/strategies/alignment_based.py @@ -5,13 +5,14 @@ allowing pruning decisions to be guided by neuron-input alignment measures. """ +import logging +from typing import Dict, Optional + import torch import torch.nn as nn -from typing import Optional, Dict, Any, Literal -import logging -from ..base import BasePruningStrategy from ...metrics import get_metric +from ..base import BasePruningStrategy logger = logging.getLogger(__name__) @@ -19,36 +20,36 @@ class AlignmentPruning(BasePruningStrategy): """ Alignment-based pruning strategy. - + This strategy prunes based on alignment metrics between neurons and their inputs. Since alignment metrics are computed per neuron, this naturally supports structured pruning (removing entire neurons/channels). - + For structured pruning (default): - Removes entire neurons based on their alignment scores - All weights connected to pruned neurons are removed - + For unstructured pruning: - Distributes neuron scores to individual weights - Less meaningful since alignment is a neuron-level property - + Examples: >>> from alignment.pruning.strategies import AlignmentPruning >>> from alignment.pruning import PruningConfig >>> >>> # Structured pruning - remove entire neurons with low alignment >>> config = PruningConfig( - ... amount=0.5, + ... amount=0.5, ... pruning_mode='low', ... structured=True # Default for alignment ... ) >>> strategy = AlignmentPruning(metric='rayleigh_quotient', config=config) - >>> + >>> >>> # Need to provide inputs for alignment computation >>> inputs = torch.randn(batch_size, input_dim) >>> mask = strategy.prune(layer, inputs=inputs) """ - + def __init__( self, metric: str = 'rayleigh_quotient', @@ -57,10 +58,10 @@ def __init__( ): """ Initialize alignment-based pruning strategy. - + Args: metric: Name of alignment metric to use - Options: 'rayleigh_quotient', 'mutual_information', 'cka', + Options: 'rayleigh_quotient', 'mutual_information', 'cka', 'weight_cosine_similarity', 'gradient_similarity' config: Pruning configuration. Note: structured=True is recommended since alignment is a neuron-level property @@ -69,12 +70,12 @@ def __init__( super().__init__(config) self.metric_name = metric self.metric_kwargs = metric_kwargs - + # Default to structured pruning for alignment-based methods if config and not hasattr(config, 'structured'): logger.info("AlignmentPruning defaulting to structured=True (neuron pruning)") config.structured = True - + # Initialize the metric try: metric_class = get_metric(metric) @@ -85,7 +86,7 @@ def __init__( except Exception as e: logger.error(f"Failed to initialize metric {metric}: {e}") raise - + def compute_importance_scores( self, module: nn.Module, @@ -94,67 +95,67 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores based on alignment metrics. - + For structured pruning (default): Returns neuron-wise scores that will be used to prune entire neurons. - + For unstructured pruning: Expands neuron scores to individual weights (less meaningful). - + Args: module: Module to compute scores for inputs: Input activations to the module (required) **kwargs: Additional arguments - + Returns: Tensor of importance scores - - Structured: Shape [num_output_neurons] + - Structured: Shape [num_output_neurons] - Unstructured: Shape matching module weights - + Raises: ValueError: If inputs are not provided or module has no weights """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + if inputs is None: raise ValueError( "AlignmentPruning requires inputs to compute alignment. " "Pass inputs to the prune() method." ) - + weights = module.weight.data - + # Compute alignment scores (neuron-wise) # Shape: [num_output_neurons] alignment_scores = self.metric.compute(inputs=inputs, weights=weights) - + # Ensure alignment_scores is on the same device as weights if alignment_scores.device != weights.device: alignment_scores = alignment_scores.to(weights.device) - + # For structured pruning, return neuron-wise scores directly # The base class will handle creating masks for entire neurons if self.config.structured: return alignment_scores - + # For unstructured pruning, expand to weight-wise scores # Note: This is less meaningful since alignment is inherently per-neuron if len(weights.shape) == 2: # Linear layer # Each weight in a neuron gets the same importance score importance = alignment_scores.unsqueeze(1).expand_as(weights) - + elif len(weights.shape) >= 3: # Conv layer # Each weight in a channel gets the same importance score out_channels = weights.shape[0] importance = alignment_scores.view(out_channels, 1, 1, 1) importance = importance.expand_as(weights) - + else: raise ValueError(f"Unsupported weight shape: {weights.shape}") - + return importance - + def prune( self, module: nn.Module, @@ -164,34 +165,34 @@ def prune( ) -> torch.Tensor: """ Prune module based on alignment scores. - + Overrides base prune() to ensure structured pruning works correctly for neuron-based alignment scores. - + Args: module: Module to prune inputs: Input activations (required) amount: Fraction to prune **kwargs: Additional arguments - + Returns: Pruning mask """ # Get importance scores importance_scores = self.compute_importance_scores(module, inputs, **kwargs) - + # For structured pruning with neuron-wise scores if self.config.structured and importance_scores.dim() == 1: # Create mask with special handling for neuron-wise scores weights = module.weight.data - + # Determine which neurons to keep/prune amount = amount if amount is not None else self.config.amount k = int(amount * importance_scores.numel()) - + if k == 0: return torch.ones_like(weights) - + # Handle different pruning modes if self.config.pruning_mode == 'random': # Random selection of neurons to prune @@ -209,17 +210,17 @@ def prune( indices_to_prune = sorted_indices[:k] keep_mask = torch.ones(importance_scores.numel(), dtype=torch.bool, device=importance_scores.device) keep_mask[indices_to_prune] = False - + # Expand mask to all weights in the neuron/channel if len(weights.shape) == 2: # Linear mask = keep_mask.unsqueeze(1).expand_as(weights).float() else: # Conv mask = keep_mask.view(-1, 1, 1, 1).expand_as(weights).float() - + # Apply the mask self.apply_pruning(module, mask) return mask - + # For unstructured pruning, use base class implementation mask = self.create_pruning_mask(importance_scores, amount) self.apply_pruning(module, mask) @@ -229,10 +230,10 @@ def prune( class HybridPruning(BasePruningStrategy): """ Hybrid pruning strategy combining magnitude and alignment information. - + This strategy combines traditional magnitude-based importance with alignment metrics for more informed pruning decisions. - + Examples: >>> from alignment.pruning.strategies import HybridPruning >>> @@ -243,7 +244,7 @@ class HybridPruning(BasePruningStrategy): ... ) >>> mask = strategy.prune(layer, inputs=inputs, amount=0.5) """ - + def __init__( self, alignment_metric: str = 'rayleigh_quotient', @@ -253,7 +254,7 @@ def __init__( ): """ Initialize hybrid pruning strategy. - + Args: alignment_metric: Name of alignment metric to use alpha: Weight for alignment score (1-alpha for magnitude) @@ -265,7 +266,7 @@ def __init__( self.alignment_metric_name = alignment_metric self.alpha = alpha self.metric_kwargs = metric_kwargs - + # Initialize the alignment metric try: from ...metrics import get_metric @@ -277,7 +278,7 @@ def __init__( except Exception as e: logger.error(f"Failed to initialize metric {alignment_metric}: {e}") raise - + def compute_importance_scores( self, module: nn.Module, @@ -286,40 +287,40 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores combining magnitude and alignment. - + Args: module: Module to compute scores for inputs: Input activations (required for alignment) **kwargs: Additional arguments - + Returns: Combined importance scores """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + weights = module.weight.data - + # Magnitude-based importance magnitude_importance = weights.abs() - + # Normalize magnitude scores mag_min = magnitude_importance.min() mag_max = magnitude_importance.max() if mag_max > mag_min: magnitude_importance = (magnitude_importance - mag_min) / (mag_max - mag_min) - + if inputs is None or self.alpha == 0: # No inputs provided or pure magnitude return magnitude_importance - + # Alignment-based importance alignment_scores = self.alignment_metric.compute(inputs=inputs, weights=weights) - + # Ensure alignment_scores is on the same device if alignment_scores.device != weights.device: alignment_scores = alignment_scores.to(weights.device) - + # Expand alignment scores to match weight dimensions if len(weights.shape) == 2: # Linear alignment_importance = alignment_scores.unsqueeze(1).expand_as(weights) @@ -329,32 +330,32 @@ def compute_importance_scores( alignment_importance = alignment_importance.expand_as(weights) else: raise ValueError(f"Unsupported weight shape: {weights.shape}") - + # Normalize alignment scores align_min = alignment_importance.min() align_max = alignment_importance.max() if align_max > align_min: alignment_importance = (alignment_importance - align_min) / (align_max - align_min) - + # Combine scores combined_importance = ( - self.alpha * alignment_importance + + self.alpha * alignment_importance + (1 - self.alpha) * magnitude_importance ) - - return combined_importance + + return combined_importance class GlobalAlignmentPruning(AlignmentPruning): """ Global alignment-based pruning strategy. - + This strategy collects alignment scores from all neurons across all layers, sorts them globally, and prunes the globally least aligned neurons. - + This is different from layer-wise pruning where each layer is pruned independently to achieve the same sparsity level. - + Examples: >>> from alignment.pruning.strategies import GlobalAlignmentPruning >>> from alignment.pruning import PruningConfig @@ -366,18 +367,18 @@ class GlobalAlignmentPruning(AlignmentPruning): ... structured=True # Always true for alignment ... ) >>> strategy = GlobalAlignmentPruning(metric='rayleigh_quotient', config=config) - >>> + >>> >>> # Need to provide inputs for each layer >>> masks = strategy.prune_model(model, layer_inputs_dict) """ - + def __init__(self, metric: str = 'rayleigh_quotient', config=None, **metric_kwargs): """Initialize global alignment pruning strategy.""" super().__init__(metric, config, **metric_kwargs) # Ensure global pruning is enabled if self.config: self.config.global_pruning = True - + def prune_model( self, model: nn.Module, @@ -386,30 +387,30 @@ def prune_model( ) -> Dict[str, torch.Tensor]: """ Prune entire model globally based on alignment scores. - + Args: model: Model to prune layer_inputs: Dictionary mapping layer names to their input tensors amount: Global sparsity level to achieve - + Returns: Dictionary mapping layer names to their pruning masks """ amount = amount if amount is not None else self.config.amount - + # Collect all alignment scores and layer info all_scores = [] layer_info = [] - + for name, module in model.named_modules(): if hasattr(module, 'weight') and name in layer_inputs: # Compute alignment scores for this layer inputs = layer_inputs[name] weights = module.weight.data - + # Get neuron-wise alignment scores alignment_scores = self.metric.compute(inputs=inputs, weights=weights) - + # Store scores and info all_scores.append(alignment_scores.cpu()) layer_info.append({ @@ -419,19 +420,19 @@ def prune_model( 'num_neurons': alignment_scores.numel(), 'weight_shape': weights.shape }) - + if not all_scores: logger.warning("No layers found for global pruning") return {} - + # Concatenate all scores global_scores = torch.cat(all_scores) - + # Find global threshold k = int(amount * global_scores.numel()) if k == 0: return {} - + # Get indices to prune based on pruning mode if self.config.pruning_mode == 'low': # Prune neurons with lowest alignment @@ -446,7 +447,7 @@ def prune_model( prune_indices = torch.randperm(global_scores.numel())[:k] else: raise ValueError(f"Unknown pruning_mode: {self.config.pruning_mode}") - + # Convert global indices to per-layer masks using prefix sums (efficient) masks = {} prefix_counts = [] @@ -484,9 +485,9 @@ def prune_model( f"Layer {layer['name']}: pruned {pruned_neurons}/{num_neurons} neurons " f"({pruned_neurons/num_neurons*100:.1f}%)" ) - + # Log global statistics total_neurons = sum(layer['num_neurons'] for layer in layer_info) logger.info(f"Global pruning complete: {k}/{total_neurons} neurons pruned ({amount*100:.1f}%)") - - return masks \ No newline at end of file + + return masks diff --git a/src/alignment/pruning/strategies/cascading.py b/src/alignment/pruning/strategies/cascading.py index 983a8ee0..5ca78d7e 100644 --- a/src/alignment/pruning/strategies/cascading.py +++ b/src/alignment/pruning/strategies/cascading.py @@ -6,13 +6,14 @@ the effects of previous pruning. """ +import logging +from typing import Dict, List, Optional, Tuple + import torch import torch.nn as nn -from typing import Dict, List, Optional, Tuple -import logging -from ..base import BasePruningStrategy, PruningConfig from ...metrics import get_metric +from ..base import BasePruningStrategy, PruningConfig logger = logging.getLogger(__name__) @@ -20,15 +21,15 @@ class CascadingAlignmentPruning(BasePruningStrategy): """ Cascading alignment-based pruning strategy. - + This strategy prunes layers sequentially, recomputing alignment scores after each layer to account for how previous pruning affects subsequent layers. - + Key differences from standard pruning: 1. Prunes layers in order (forward or backward) 2. Recomputes alignment scores after each layer 3. Later layers see the effects of earlier pruning - + Examples: >>> from alignment.pruning.strategies import CascadingAlignmentPruning >>> from alignment.pruning import PruningConfig @@ -45,7 +46,7 @@ class CascadingAlignmentPruning(BasePruningStrategy): ... ) >>> masks = strategy.prune_model(model, get_layer_inputs_fn) """ - + def __init__( self, metric: str = 'rayleigh_quotient', @@ -55,7 +56,7 @@ def __init__( ): """ Initialize cascading alignment pruning. - + Args: metric: Alignment metric to use direction: 'forward' (input→output) or 'backward' (output→input) @@ -66,11 +67,11 @@ def __init__( self.metric_name = metric self.direction = direction self.metric_kwargs = metric_kwargs - + # Default to structured pruning if config and not hasattr(config, 'structured'): config.structured = True - + # Initialize metric try: metric_class = get_metric(metric) @@ -80,7 +81,7 @@ def __init__( except Exception as e: logger.error(f"Failed to initialize metric {metric}: {e}") raise - + def compute_importance_scores( self, module: nn.Module, @@ -89,31 +90,31 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute alignment scores for a single module. - + Args: module: Module to compute scores for inputs: Input activations **kwargs: Additional arguments - + Returns: Alignment scores (neuron-wise) """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + if inputs is None: raise ValueError("CascadingAlignmentPruning requires inputs") - + weights = module.weight.data - + # Compute neuron-wise alignment scores alignment_scores = self.metric.compute(inputs=inputs, weights=weights) - + if alignment_scores.device != weights.device: alignment_scores = alignment_scores.to(weights.device) - + return alignment_scores - + def prune_model( self, model: nn.Module, @@ -123,59 +124,59 @@ def prune_model( ) -> Dict[str, torch.Tensor]: """ Prune model using cascading approach. - + Args: model: Model to prune get_layer_inputs_fn: Function that returns current inputs for each layer Should return Dict[layer_name, tensor] amount: Amount to prune (overrides config) exclude_layers: Layer names to exclude from pruning - + Returns: Dictionary of masks for each layer """ amount = amount if amount is not None else self.config.amount exclude_layers = exclude_layers or [] - + # Get prunable layers in order layers = [] for name, module in model.named_modules(): - if (hasattr(module, 'weight') and + if (hasattr(module, 'weight') and len(module.weight.shape) >= 2 and name not in exclude_layers): layers.append((name, module)) - + # Reverse if backward direction if self.direction == 'backward': layers = layers[::-1] - + logger.info(f"Cascading {self.direction} pruning of {len(layers)} layers") - + # Prune layers sequentially masks = {} - + for idx, (layer_name, module) in enumerate(layers): logger.info(f"Pruning layer {idx+1}/{len(layers)}: {layer_name}") - + # Get current inputs for this layer # This should reflect any previous pruning layer_inputs_dict = get_layer_inputs_fn() - + if layer_name not in layer_inputs_dict: logger.warning(f"No inputs found for layer {layer_name}, skipping") continue - + layer_inputs = layer_inputs_dict[layer_name] - + # Compute alignment scores with current network state alignment_scores = self.compute_importance_scores(module, layer_inputs) - + # Create mask for structured pruning if self.config.structured and alignment_scores.dim() == 1: # Determine which neurons to prune num_neurons = alignment_scores.numel() k = int(amount * num_neurons) - + if k == 0: mask = torch.ones_like(module.weight) else: @@ -186,7 +187,7 @@ def prune_model( else: # 'high' mode threshold = alignment_scores.kthvalue(num_neurons - k).values keep_mask = alignment_scores < threshold - + # Expand to weight dimensions if len(module.weight.shape) == 2: # Linear mask = keep_mask.unsqueeze(1).expand_as(module.weight).float() @@ -195,25 +196,25 @@ def prune_model( else: # Unstructured pruning mask = self.create_pruning_mask(alignment_scores, amount) - + # Apply pruning self.apply_pruning(module, mask) masks[layer_name] = mask - + # Log statistics sparsity = 1 - (mask != 0).float().mean().item() logger.info(f" Layer {layer_name}: {sparsity*100:.1f}% pruned") - + return masks - + def _get_layer_order(self, model: nn.Module) -> List[Tuple[str, nn.Module]]: """Get layers in processing order based on direction.""" layers = [] for name, module in model.named_modules(): if hasattr(module, 'weight') and len(module.weight.shape) >= 2: layers.append((name, module)) - + if self.direction == 'backward': layers = layers[::-1] - - return layers \ No newline at end of file + + return layers diff --git a/src/alignment/pruning/strategies/gradient.py b/src/alignment/pruning/strategies/gradient.py index 52a804df..b34df71c 100644 --- a/src/alignment/pruning/strategies/gradient.py +++ b/src/alignment/pruning/strategies/gradient.py @@ -5,9 +5,10 @@ that have small gradients or small gradient-weight products. """ +from typing import Literal, Optional + import torch import torch.nn as nn -from typing import Optional, Literal from ..base import BasePruningStrategy @@ -15,21 +16,21 @@ class GradientPruning(BasePruningStrategy): """ Gradient-based pruning strategy. - + This strategy prunes weights based on gradient information. It can use either gradient magnitudes alone or the product of gradients and weights (Taylor approximation of loss change). - + Examples: >>> from alignment.pruning.strategies import GradientPruning >>> >>> # Using gradient magnitude >>> strategy = GradientPruning(mode='gradient') - >>> + >>> >>> # Forward and backward pass to get gradients >>> loss = criterion(model(inputs), targets) >>> loss.backward() - >>> + >>> >>> # Prune based on gradients >>> mask = strategy.prune(layer, amount=0.5) >>> @@ -37,7 +38,7 @@ class GradientPruning(BasePruningStrategy): >>> strategy = GradientPruning(mode='taylor') >>> mask = strategy.prune(layer, amount=0.5) """ - + def __init__( self, config=None, @@ -45,7 +46,7 @@ def __init__( ): """ Initialize gradient pruning strategy. - + Args: config: Pruning configuration mode: Pruning mode @@ -54,7 +55,7 @@ def __init__( """ super().__init__(config) self.mode = mode - + def compute_importance_scores( self, module: nn.Module, @@ -63,21 +64,21 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores based on gradients. - + Args: module: Module to compute scores for inputs: Not used **kwargs: Not used - + Returns: Tensor of importance scores - + Raises: ValueError: If module has no gradients """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + if module.weight.grad is None: raise ValueError( f"Module {module} has no gradients. " @@ -85,7 +86,7 @@ def compute_importance_scores( "Note: Gradient-based pruning is not suitable for post-training pruning " "on converged models as gradients will be near-zero." ) - + if self.mode == 'gradient': # Use gradient magnitude importance = module.weight.grad.abs() @@ -94,42 +95,42 @@ def compute_importance_scores( importance = (module.weight.grad * module.weight.data).abs() else: raise ValueError(f"Unknown mode: {self.mode}") - + return importance class FisherPruning(BasePruningStrategy): """ Fisher information-based pruning strategy. - + This strategy approximates the Fisher information matrix diagonal using gradient squares accumulated over multiple batches. - + Examples: >>> from alignment.pruning.strategies import FisherPruning >>> strategy = FisherPruning() - >>> + >>> >>> # Accumulate Fisher information over multiple batches >>> for inputs, targets in dataloader: >>> loss = criterion(model(inputs), targets) >>> loss.backward() >>> strategy.accumulate_fisher(model) >>> optimizer.zero_grad() - >>> + >>> >>> # Prune based on accumulated Fisher information >>> masks = strategy.prune_model(model, amount=0.5) """ - + def __init__(self, config=None): """Initialize Fisher pruning strategy.""" super().__init__(config) self.fisher_info = {} self.n_samples = 0 - + def accumulate_fisher(self, model: nn.Module): """ Accumulate Fisher information from current gradients. - + Args: model: Model to accumulate Fisher information for """ @@ -137,12 +138,12 @@ def accumulate_fisher(self, model: nn.Module): if hasattr(module, 'weight') and module.weight.grad is not None: if name not in self.fisher_info: self.fisher_info[name] = torch.zeros_like(module.weight.data) - + # Accumulate squared gradients self.fisher_info[name] += module.weight.grad.data ** 2 - + self.n_samples += 1 - + def compute_importance_scores( self, module: nn.Module, @@ -152,13 +153,13 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores based on Fisher information. - + Args: module: Module to compute scores for inputs: Not used module_name: Name of the module in fisher_info dict **kwargs: Not used - + Returns: Tensor of importance scores """ @@ -168,15 +169,15 @@ def compute_importance_scores( return module.weight.grad.abs() else: return module.weight.data.abs() - + # Use accumulated Fisher information fisher = self.fisher_info[module_name] / self.n_samples - + # Importance is Fisher info times weight squared importance = fisher * (module.weight.data ** 2) - + return importance - + def prune_model( self, model: nn.Module, @@ -184,16 +185,16 @@ def prune_model( ) -> dict: """ Prune model based on accumulated Fisher information. - + Args: model: Model to prune amount: Fraction to prune - + Returns: Dictionary mapping module names to pruning masks """ masks = {} - + for name, module in model.named_modules(): if hasattr(module, 'weight'): importance = self.compute_importance_scores( @@ -202,9 +203,9 @@ def prune_model( mask = self.create_pruning_mask(importance, amount) self.apply_pruning(module, mask) masks[name] = mask - + return masks - + def reset_fisher(self): """Reset accumulated Fisher information.""" self.fisher_info.clear() @@ -214,33 +215,33 @@ def reset_fisher(self): class MomentumPruning(BasePruningStrategy): """ Momentum-based pruning strategy. - + This strategy maintains a momentum buffer of importance scores, providing more stable pruning decisions over time. - + Examples: >>> from alignment.pruning.strategies import MomentumPruning >>> strategy = MomentumPruning(momentum=0.9) - >>> + >>> >>> # Update importance with momentum over multiple iterations >>> for epoch in range(epochs): >>> for inputs, targets in dataloader: >>> loss = criterion(model(inputs), targets) >>> loss.backward() - >>> + >>> >>> # Update momentum buffer >>> strategy.update_momentum(model) >>> optimizer.step() >>> optimizer.zero_grad() - >>> + >>> >>> # Prune based on momentum buffer >>> masks = strategy.prune_model(model, amount=0.5) """ - + def __init__(self, config=None, momentum: float = 0.9): """ Initialize momentum pruning strategy. - + Args: config: Pruning configuration momentum: Momentum factor (0-1) @@ -248,11 +249,11 @@ def __init__(self, config=None, momentum: float = 0.9): super().__init__(config) self.momentum = momentum self.importance_buffer = {} - + def update_momentum(self, model: nn.Module): """ Update momentum buffer with current gradients. - + Args: model: Model to update momentum for """ @@ -262,7 +263,7 @@ def update_momentum(self, model: nn.Module): current_importance = ( module.weight.grad.data * module.weight.data ).abs() - + if name not in self.importance_buffer: # Initialize buffer self.importance_buffer[name] = current_importance @@ -272,7 +273,7 @@ def update_momentum(self, model: nn.Module): self.momentum * self.importance_buffer[name] + (1 - self.momentum) * current_importance ) - + def compute_importance_scores( self, module: nn.Module, @@ -282,13 +283,13 @@ def compute_importance_scores( ) -> torch.Tensor: """ Get importance scores from momentum buffer. - + Args: module: Module to get scores for inputs: Not used module_name: Name of module in buffer **kwargs: Not used - + Returns: Tensor of importance scores """ @@ -298,9 +299,9 @@ def compute_importance_scores( return (module.weight.grad * module.weight.data).abs() else: return module.weight.data.abs() - + return self.importance_buffer[module_name] - + def prune_model( self, model: nn.Module, @@ -308,16 +309,16 @@ def prune_model( ) -> dict: """ Prune model based on momentum buffer. - + Args: model: Model to prune amount: Fraction to prune - + Returns: Dictionary mapping module names to pruning masks """ masks = {} - + for name, module in model.named_modules(): if hasattr(module, 'weight'): importance = self.compute_importance_scores( @@ -326,9 +327,9 @@ def prune_model( mask = self.create_pruning_mask(importance, amount) self.apply_pruning(module, mask) masks[name] = mask - + return masks - + def reset_momentum(self): """Reset momentum buffer.""" - self.importance_buffer.clear() \ No newline at end of file + self.importance_buffer.clear() diff --git a/src/alignment/pruning/strategies/magnitude.py b/src/alignment/pruning/strategies/magnitude.py index 44385afa..3625f5ff 100644 --- a/src/alignment/pruning/strategies/magnitude.py +++ b/src/alignment/pruning/strategies/magnitude.py @@ -5,9 +5,10 @@ with the smallest absolute values. """ +from typing import Optional + import torch import torch.nn as nn -from typing import Optional from ..base import BasePruningStrategy, IterativePruningStrategy @@ -15,10 +16,10 @@ class MagnitudePruning(BasePruningStrategy): """ Magnitude-based pruning strategy. - + This strategy prunes weights with the smallest absolute values, based on the assumption that small weights contribute less to the network's output. - + Examples: >>> from alignment.pruning.strategies import MagnitudePruning >>> from alignment.pruning import PruningConfig @@ -32,7 +33,7 @@ class MagnitudePruning(BasePruningStrategy): >>> strategy = MagnitudePruning(config) >>> mask = strategy.prune(conv_layer) """ - + def compute_importance_scores( self, module: nn.Module, @@ -41,18 +42,18 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores based on weight magnitudes. - + Args: module: Module to compute scores for inputs: Not used for magnitude pruning **kwargs: Not used - + Returns: Tensor of importance scores (absolute weight values) """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + # Importance is simply the absolute value of weights return module.weight.data.abs() @@ -60,10 +61,10 @@ def compute_importance_scores( class IterativeMagnitudePruning(IterativePruningStrategy): """ Iterative magnitude-based pruning strategy. - + This strategy gradually prunes weights over multiple iterations, allowing the network to adapt between pruning steps. - + Examples: >>> from alignment.pruning.strategies import IterativeMagnitudePruning >>> from alignment.pruning import PruningConfig @@ -74,7 +75,7 @@ class IterativeMagnitudePruning(IterativePruningStrategy): ... fine_tune_epochs=5 ... ) >>> strategy = IterativeMagnitudePruning(config) - >>> + >>> >>> results = strategy.iterative_prune( ... model=model, ... dataloader=train_loader, @@ -82,7 +83,7 @@ class IterativeMagnitudePruning(IterativePruningStrategy): ... criterion=nn.CrossEntropyLoss() ... ) """ - + def compute_importance_scores( self, module: nn.Module, @@ -91,37 +92,37 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores based on weight magnitudes. - + Args: module: Module to compute scores for inputs: Not used for magnitude pruning **kwargs: Not used - + Returns: Tensor of importance scores (absolute weight values) """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + return module.weight.data.abs() class GlobalMagnitudePruning(BasePruningStrategy): """ Global magnitude-based pruning strategy. - + This strategy prunes weights globally across all layers based on their magnitudes, ensuring that the specified sparsity is achieved across the entire network rather than per-layer. - + Examples: >>> from alignment.pruning.strategies import GlobalMagnitudePruning >>> strategy = GlobalMagnitudePruning() - >>> + >>> >>> # Prune entire model to 70% sparsity >>> masks = strategy.prune_model(model, amount=0.7) """ - + def compute_importance_scores( self, module: nn.Module, @@ -130,14 +131,14 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute importance scores for a single module. - + Note: For global pruning, use prune_model() instead of prune(). """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + return module.weight.data.abs() - + def prune_model( self, model: nn.Module, @@ -145,39 +146,39 @@ def prune_model( ) -> dict: """ Prune entire model globally based on weight magnitudes. - + Args: model: Model to prune amount: Global sparsity level to achieve - + Returns: Dictionary mapping module names to their pruning masks """ amount = amount if amount is not None else self.config.amount - + # Collect all weights and their importance scores all_scores = [] module_info = [] - + for name, module in model.named_modules(): if hasattr(module, 'weight'): scores = module.weight.data.abs() all_scores.append(scores.flatten()) module_info.append((name, module, scores.shape)) - + if not all_scores: return {} - + # Concatenate all scores global_scores = torch.cat(all_scores) - + # Find global threshold k = int(amount * global_scores.numel()) if k == 0: return {} - + threshold = global_scores.kthvalue(k).values - + # Apply threshold to each module masks = {} for name, module, shape in module_info: @@ -185,5 +186,5 @@ def prune_model( mask = (scores > threshold).float() self.apply_pruning(module, mask) masks[name] = mask - - return masks \ No newline at end of file + + return masks diff --git a/src/alignment/pruning/strategies/movement.py b/src/alignment/pruning/strategies/movement.py index 53c66231..41382ece 100644 --- a/src/alignment/pruning/strategies/movement.py +++ b/src/alignment/pruning/strategies/movement.py @@ -7,13 +7,13 @@ Reference: Sanh et al., "Movement Pruning: Adaptive Sparsity by Fine-Tuning" (2020) """ +import logging +from typing import Dict, Optional + import torch import torch.nn as nn -from typing import Optional, Dict, Any -import logging from ..base import BasePruningStrategy, PruningConfig -from ...core.registry import register_metric logger = logging.getLogger(__name__) @@ -21,20 +21,20 @@ class MovementPruning(BasePruningStrategy): """ Movement-based pruning strategy. - + Identifies weights that consistently move toward zero during training and prunes them. More sophisticated than simple magnitude pruning. - + Score = |weight| × sign(weight) × sign(gradient) - + Interpretation: - Positive score: Weight moving away from zero (important) - Negative score: Weight moving toward zero (candidate for pruning) - + Reference: Sanh et al., "Movement Pruning: Adaptive Sparsity by Fine-Tuning" NeurIPS 2020 - + Example: >>> strategy = MovementPruning() >>> # During training, accumulate gradients @@ -43,11 +43,11 @@ class MovementPruning(BasePruningStrategy): >>> # Before optimizer.step(): >>> strategy.update_movement_history(model) >>> optimizer.step() - >>> + >>> >>> # After training: >>> mask = strategy.prune(model, amount=0.5) """ - + def __init__( self, config: Optional[PruningConfig] = None, @@ -56,7 +56,7 @@ def __init__( ): """ Initialize movement pruning. - + Args: config: Pruning configuration use_momentum: Whether to use momentum for movement estimation @@ -66,13 +66,13 @@ def __init__( self.use_momentum = use_momentum self.momentum = momentum self.movement_history: Dict[str, torch.Tensor] = {} - + def update_movement_history(self, model: nn.Module): """ Update movement history during training. - + Call this BEFORE optimizer.step() to track weight movement. - + Args: model: Model being trained """ @@ -80,11 +80,11 @@ def update_movement_history(self, model: nn.Module): for name, param in model.named_parameters(): if param.grad is None: continue - + # Compute movement score: sign(weight) × sign(gradient) # Negative = moving toward zero movement = torch.sign(param.data) * torch.sign(param.grad.data) - + if name not in self.movement_history: self.movement_history[name] = movement.clone() else: @@ -97,7 +97,7 @@ def update_movement_history(self, model: nn.Module): else: # Simple accumulation self.movement_history[name] += movement - + def compute_importance_scores( self, module: nn.Module, @@ -107,18 +107,18 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute movement-based importance scores. - + Args: module: Module to score inputs: Not used for movement pruning module_name: Name of module (for retrieving history) **kwargs: Additional arguments - + Returns: Importance scores (same shape as module.weight) """ weight = module.weight - + # Get movement history if available if module_name and module_name + '.weight' in self.movement_history: movement = self.movement_history[module_name + '.weight'] @@ -129,32 +129,32 @@ def compute_importance_scores( ) # Fallback to magnitude return weight.abs() - + # Movement score: magnitude × movement direction # High positive = moving away from zero = important # Negative = moving toward zero = prune scores = weight.abs() * movement - + return scores - + def reset_history(self): """Reset movement history.""" self.movement_history.clear() - + def get_movement_statistics(self) -> Dict[str, Dict[str, float]]: """ Get statistics about weight movement. - + Returns: Dict with statistics per parameter """ stats = {} - + for name, movement in self.movement_history.items(): moving_away = (movement > 0).sum().item() moving_toward = (movement < 0).sum().item() total = movement.numel() - + stats[name] = { 'moving_away_zero': moving_away, 'moving_toward_zero': moving_toward, @@ -163,18 +163,18 @@ def get_movement_statistics(self) -> Dict[str, Dict[str, float]]: 'mean_movement': movement.mean().item(), 'std_movement': movement.std().item() } - + return stats class AdaptiveMovementPruning(MovementPruning): """ Adaptive movement pruning that adjusts pruning amount based on movement. - + Automatically determines pruning amount based on how many weights are consistently moving toward zero. """ - + def __init__( self, base_amount: float = 0.5, @@ -183,7 +183,7 @@ def __init__( ): """ Initialize adaptive movement pruning. - + Args: base_amount: Base pruning amount adaptation_strength: How much to adapt based on movement (0-1) @@ -191,7 +191,7 @@ def __init__( super().__init__(**kwargs) self.base_amount = base_amount self.adaptation_strength = adaptation_strength - + def compute_adaptive_amount( self, module: nn.Module, @@ -199,36 +199,36 @@ def compute_adaptive_amount( ) -> float: """ Compute adaptive pruning amount for this module. - + Args: module: Module to prune module_name: Name of module - + Returns: Adjusted pruning amount """ if module_name + '.weight' not in self.movement_history: return self.base_amount - + movement = self.movement_history[module_name + '.weight'] - + # Fraction moving toward zero toward_zero = (movement < 0).float().mean().item() - + # Adapt pruning amount # More weights moving toward zero → prune more aggressively adapted_amount = self.base_amount + self.adaptation_strength * (toward_zero - 0.5) - + # Clip to valid range adapted_amount = max(0.1, min(0.9, adapted_amount)) - + logger.info( f"{module_name}: {toward_zero:.1%} moving toward zero, " f"adapted amount: {adapted_amount:.1%}" ) - + return adapted_amount - + def prune( self, module: nn.Module, @@ -238,14 +238,14 @@ def prune( ) -> torch.Tensor: """ Prune with adaptive amount. - + If amount is None, uses adaptive computation. """ if amount is None and module_name: amount = self.compute_adaptive_amount(module, module_name) elif amount is None: amount = self.base_amount - + # Use parent's prune method with adapted amount return super().prune(module, amount=amount, module_name=module_name, **kwargs) diff --git a/src/alignment/pruning/strategies/parallel.py b/src/alignment/pruning/strategies/parallel.py index b23b99a5..e726c663 100644 --- a/src/alignment/pruning/strategies/parallel.py +++ b/src/alignment/pruning/strategies/parallel.py @@ -5,11 +5,12 @@ in parallel, either as separate experiments or as a combined tensor operation. """ -import torch -import torch.nn as nn -from typing import Dict, List, Optional, Union, Tuple import concurrent.futures from dataclasses import dataclass +from typing import Dict, List, Optional, Union + +import torch +import torch.nn as nn from ..base import BasePruningStrategy, PruningConfig @@ -26,22 +27,22 @@ class ParallelPruningResult: class ParallelModePruning(BasePruningStrategy): """ Apply multiple pruning modes (low, high, random) in parallel. - + This strategy computes importance scores once and generates multiple masks for different pruning modes simultaneously. - + Examples: >>> strategy = ParallelModePruning(modes=['low', 'high', 'random']) >>> result = strategy.prune_parallel(model.fc1, amount=0.5) - >>> + >>> >>> # Access individual masks >>> low_mask = result.masks['low'] >>> high_mask = result.masks['high'] - >>> + >>> >>> # Or get combined analysis >>> combined = strategy.combine_masks(result.masks, method='union') """ - + def __init__( self, config: Optional[PruningConfig] = None, @@ -50,7 +51,7 @@ def __init__( ): """ Initialize parallel mode pruning. - + Args: config: Pruning configuration modes: List of pruning modes to apply ('low', 'high', 'random') @@ -59,22 +60,22 @@ def __init__( super().__init__(config) self.modes = modes self.base_strategy = base_strategy - + # Import strategies dynamically to avoid circular imports - from . import MagnitudePruning, GradientPruning, RandomPruning - + from . import GradientPruning, MagnitudePruning, RandomPruning + self.strategy_map = { 'magnitude': MagnitudePruning, 'gradient': GradientPruning, 'random': RandomPruning, } - + # Initialize base strategy for importance computation if base_strategy in self.strategy_map: self.importance_strategy = self.strategy_map[base_strategy](config) else: raise ValueError(f"Unknown base strategy: {base_strategy}") - + def compute_importance_scores( self, module: nn.Module, @@ -83,7 +84,7 @@ def compute_importance_scores( ) -> torch.Tensor: """Compute importance scores using the base strategy.""" return self.importance_strategy.compute_importance_scores(module, inputs, **kwargs) - + def prune_parallel( self, module: nn.Module, @@ -93,25 +94,25 @@ def prune_parallel( ) -> ParallelPruningResult: """ Apply multiple pruning modes in parallel. - + Args: module: Module to prune inputs: Optional inputs for importance computation amount: Pruning amount **kwargs: Additional arguments - + Returns: ParallelPruningResult containing all masks and metadata """ amount = amount if amount is not None else self.config.amount - + # Compute importance scores once importance_scores = self.compute_importance_scores(module, inputs, **kwargs) - + # Generate masks for each mode in parallel masks = {} sparsities = {} - + for mode in self.modes: if mode == 'random': # Random doesn't use importance scores @@ -123,21 +124,21 @@ def prune_parallel( amount=amount, pruning_mode=mode ) - + masks[mode] = mask sparsities[mode] = (mask == 0).float().mean().item() - + return ParallelPruningResult( masks=masks, sparsities=sparsities, importance_scores=importance_scores ) - + def _create_random_mask(self, shape: torch.Size, amount: float) -> torch.Tensor: """Create a random pruning mask.""" mask = torch.rand(shape) > amount return mask.float() - + def combine_masks( self, masks: Dict[str, torch.Tensor], @@ -145,16 +146,16 @@ def combine_masks( ) -> torch.Tensor: """ Combine multiple masks using specified method. - + Args: masks: Dictionary of masks to combine method: Combination method ('intersection', 'union', 'majority') - + Returns: Combined mask """ mask_list = list(masks.values()) - + if method == 'intersection': # Keep only weights that all masks agree to keep combined = torch.stack(mask_list).prod(dim=0) @@ -167,16 +168,16 @@ def combine_masks( combined = combined.float() else: raise ValueError(f"Unknown combination method: {method}") - + return combined class TensorizedPruning(BasePruningStrategy): """ Tensorized pruning that computes all pruning variations as a single tensor operation. - + This strategy is optimized for GPU computation and analysis of pruning patterns. - + Examples: >>> strategy = TensorizedPruning() >>> # Get a 3D tensor: [num_modes, height, width] for Conv2d @@ -186,7 +187,7 @@ class TensorizedPruning(BasePruningStrategy): ... amounts=[0.1, 0.3, 0.5, 0.7, 0.9] ... ) """ - + def compute_importance_scores( self, module: nn.Module, @@ -197,7 +198,7 @@ def compute_importance_scores( if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") return module.weight.data.abs() - + def compute_pruning_tensor( self, module: nn.Module, @@ -207,24 +208,24 @@ def compute_pruning_tensor( ) -> torch.Tensor: """ Compute a tensor containing all pruning variations. - + Args: module: Module to analyze modes: Pruning modes to include amounts: Sparsity levels to include inputs: Optional inputs for importance computation - + Returns: Tensor of shape [len(modes), len(amounts), *weight_shape] """ # Get importance scores importance_scores = self.compute_importance_scores(module, inputs) weight_shape = importance_scores.shape - + # Flatten for efficient computation scores_flat = importance_scores.flatten() n_params = scores_flat.numel() - + # Pre-compute all thresholds thresholds = {} for amount in amounts: @@ -234,14 +235,14 @@ def compute_pruning_tensor( 'low': scores_flat.kthvalue(k).values, 'high': scores_flat.kthvalue(n_params - k).values } - + # Create pruning tensor pruning_tensor = torch.zeros( len(modes), len(amounts), *weight_shape, dtype=torch.float32, device=importance_scores.device ) - + for i, mode in enumerate(modes): for j, amount in enumerate(amounts): if amount in thresholds: @@ -253,7 +254,7 @@ def compute_pruning_tensor( mask = torch.rand_like(importance_scores) > amount else: continue - + pruning_tensor[i, j] = mask.float() else: # Edge cases (0% or 100% pruning) @@ -261,45 +262,45 @@ def compute_pruning_tensor( pruning_tensor[i, j] = torch.ones_like(importance_scores) else: pruning_tensor[i, j] = torch.zeros_like(importance_scores) - + return pruning_tensor - + def analyze_pruning_patterns( self, pruning_tensor: torch.Tensor ) -> Dict[str, torch.Tensor]: """ Analyze patterns in the pruning tensor. - + Args: pruning_tensor: Tensor from compute_pruning_tensor - + Returns: Dictionary of analysis metrics """ analysis = {} - + # Sparsity progression analysis['sparsity_progression'] = (pruning_tensor == 0).float().mean(dim=-1).mean(dim=-1) - + # Overlap between modes if pruning_tensor.shape[0] >= 2: overlap = pruning_tensor[0] * pruning_tensor[1] # Low & High overlap analysis['mode_overlap'] = overlap.mean(dim=-1).mean(dim=-1) - + # Variance across amounts analysis['pruning_variance'] = pruning_tensor.var(dim=1) - + return analysis class AsyncParallelPruning(BasePruningStrategy): """ Asynchronous parallel pruning for multiple models or layers. - + This strategy uses Python's concurrent.futures to prune multiple modules in parallel across CPU cores. - + Examples: >>> strategy = AsyncParallelPruning() >>> modules = [model.layer1, model.layer2, model.layer3] @@ -309,7 +310,7 @@ class AsyncParallelPruning(BasePruningStrategy): ... modes=['low', 'high'] ... ) """ - + def compute_importance_scores( self, module: nn.Module, @@ -320,7 +321,7 @@ def compute_importance_scores( if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") return module.weight.data.abs() - + def prune_modules_parallel( self, modules: List[nn.Module], @@ -330,25 +331,25 @@ def prune_modules_parallel( ) -> List[Dict[str, torch.Tensor]]: """ Prune multiple modules in parallel. - + Args: modules: List of modules to prune amounts: Single amount or list of amounts per module modes: Pruning modes to apply max_workers: Maximum number of parallel workers - + Returns: List of pruning results per module """ if isinstance(amounts, float): amounts = [amounts] * len(modules) - + def prune_single_module(module, amount): """Prune a single module with all modes.""" masks = {} - + importance_scores = self.compute_importance_scores(module) - + for mode in modes: if mode == 'random': mask = torch.rand_like(module.weight) > amount @@ -360,9 +361,9 @@ def prune_single_module(module, amount): pruning_mode=mode ) masks[mode] = mask - + return masks - + # Execute in parallel results = [] with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: @@ -370,8 +371,8 @@ def prune_single_module(module, amount): executor.submit(prune_single_module, module, amount) for module, amount in zip(modules, amounts) ] - + for future in concurrent.futures.as_completed(futures): results.append(future.result()) - - return results \ No newline at end of file + + return results diff --git a/src/alignment/pruning/strategies/parallel_batch.py b/src/alignment/pruning/strategies/parallel_batch.py index 7bb71f07..80df9fe6 100644 --- a/src/alignment/pruning/strategies/parallel_batch.py +++ b/src/alignment/pruning/strategies/parallel_batch.py @@ -1,10 +1,11 @@ """Parallel batch pruning strategy for efficient evaluation of multiple configurations.""" +import logging +import time +from typing import Dict, List + import torch import torch.nn as nn -import time -from typing import Dict, List, Tuple, Optional -import logging logger = logging.getLogger(__name__) @@ -12,17 +13,17 @@ class ParallelBatchPruning: """ Pruning strategy that evaluates all networks and sparsity levels in parallel. - + This strategy processes multiple networks and pruning configurations simultaneously using vectorized operations for maximum efficiency. """ - + def __init__(self, config=None): self.config = config self.eval_batches = getattr(config, 'eval_batches', None) if config else None - + def run_pruning_experiments( - self, + self, networks: List[nn.Module], data_loader, strategies: List[str], @@ -32,7 +33,7 @@ def run_pruning_experiments( ) -> Dict[str, any]: """ Run pruning experiments for all configurations in parallel. - + Args: networks: List of neural networks to prune data_loader: DataLoader for evaluation @@ -40,35 +41,35 @@ def run_pruning_experiments( selection_modes: List of selection modes (low, high, random) pruning_amounts: List of sparsity levels to test device: Device to run computations on - + Returns: Dictionary with pruning results for all strategies """ logger.info("Running parallel batch pruning experiments") - + results = {"strategies": {}} - + # Save original states efficiently original_states = [] for model in networks: - state = {name: module.weight.data.clone() - for name, module in model.named_modules() + state = {name: module.weight.data.clone() + for name, module in model.named_modules() if hasattr(module, 'weight')} original_states.append(state) - + # Process each strategy for strategy_name in strategies: logger.info(f"Testing pruning strategy: {strategy_name}") - + for selection_mode in selection_modes: logger.info(f" Selection mode: {selection_mode}") - + # Run parallel pruning for this configuration batch_results = self._parallel_batch_pruning( - networks, data_loader, strategy_name, selection_mode, + networks, data_loader, strategy_name, selection_mode, pruning_amounts, original_states, device ) - + # Store aggregated results strategy_key = f"{strategy_name}_{selection_mode}" strategy_results = { @@ -79,22 +80,22 @@ def run_pruning_experiments( "losses_after_finetune": batch_results["losses_after"].mean(dim=0).tolist(), "improvements": (batch_results["accuracies_after"] - batch_results["accuracies_before"]).mean(dim=0).tolist() } - + # Add standard deviations if multiple networks if len(networks) > 1: strategy_results["accuracies_before_finetune_std"] = batch_results["accuracies_before"].std(dim=0).tolist() strategy_results["accuracies_after_finetune_std"] = batch_results["accuracies_after"].std(dim=0).tolist() - + results["strategies"][strategy_key] = strategy_results - + # Restore original weights for net_idx, model in enumerate(networks): for name, module in model.named_modules(): if name in original_states[net_idx]: module.weight.data = original_states[net_idx][name] - + return results - + def _parallel_batch_pruning( self, networks: List[nn.Module], @@ -111,104 +112,104 @@ def _parallel_batch_pruning( num_networks = len(networks) num_amounts = len(pruning_amounts) total_configs = num_networks * num_amounts - + logger.info(f" Processing {total_configs} configurations in parallel") logger.info(f" Networks: {num_networks}, Sparsity levels: {num_amounts}") - + # Initialize result tensors on device for speed accuracies_before = torch.zeros(num_networks, num_amounts, device=device) losses_before = torch.zeros(num_networks, num_amounts, device=device) sparsities = torch.zeros(num_networks, num_amounts) - + # Create all masks upfront logger.info(" Creating masks for all configurations...") all_masks = self._create_all_masks(networks, strategy_name, selection_mode, pruning_amounts) - + # Evaluate all configurations in parallel logger.info(" Starting parallel evaluation...") start_time = time.time() - + # Pre-allocate for all configurations all_correct = torch.zeros(total_configs, device=device) all_loss = torch.zeros(total_configs, device=device) total_samples = 0 - + # Set all networks to eval mode for net in networks: net.eval() - + criterion = nn.CrossEntropyLoss(reduction='none') batch_count = 0 - + with torch.no_grad(): for inputs, targets in data_loader: inputs = inputs.to(device) targets = targets.to(device) batch_size = targets.size(0) - + # Collect outputs from ALL configurations all_outputs = [] - + for net_idx in range(num_networks): net = networks[net_idx] - + for amount_idx in range(num_amounts): # Apply configuration self._apply_mask_config(net, all_masks[net_idx][amount_idx], original_states[net_idx]) - + # Forward pass outputs = net(inputs) all_outputs.append(outputs) - + # Calculate sparsity (only once) if batch_count == 0: sparsities[net_idx, amount_idx] = self._calculate_sparsity(net) - + # Stack all outputs for vectorized processing stacked_outputs = torch.stack(all_outputs, dim=0) - + # Compute losses for all configs at once expanded_targets = targets.unsqueeze(0).expand(total_configs, -1) all_batch_losses = criterion( - stacked_outputs.reshape(-1, stacked_outputs.size(-1)), + stacked_outputs.reshape(-1, stacked_outputs.size(-1)), expanded_targets.reshape(-1) ).reshape(total_configs, batch_size) - + # Sum losses all_loss += all_batch_losses.sum(dim=1) - + # Get predictions and count correct all_preds = stacked_outputs.argmax(dim=2) correct = all_preds.eq(expanded_targets).sum(dim=1) all_correct += correct - + total_samples += batch_size batch_count += 1 - + # Check if we've evaluated enough batches if self.eval_batches is not None and batch_count >= self.eval_batches: break - + # Reshape results back to [num_networks, num_amounts] all_correct = all_correct.reshape(num_networks, num_amounts) all_loss = all_loss.reshape(num_networks, num_amounts) - + # Convert to accuracies and average losses accuracies_before = (all_correct * 100.0 / total_samples).cpu() losses_before = (all_loss / batch_count).cpu() - + eval_time = time.time() - start_time logger.info(f" Parallel evaluation completed in {eval_time:.2f} seconds") logger.info(f" Average accuracy: {accuracies_before.mean():.2f}%") - + # For now, no fine-tuning in parallel mode accuracies_after = accuracies_before.clone() losses_after = losses_before.clone() - + # Reset networks to train mode for net in networks: net.train() - + return { "accuracies_before": accuracies_before, "losses_before": losses_before, @@ -216,7 +217,7 @@ def _parallel_batch_pruning( "losses_after": losses_after, "sparsities": sparsities } - + def _create_all_masks( self, networks: List[nn.Module], @@ -226,10 +227,10 @@ def _create_all_masks( ) -> List[List[Dict[str, torch.Tensor]]]: """Create all masks for all networks and pruning amounts.""" all_masks = [] - + for net in networks: network_masks = [] - + for amount in pruning_amounts: if strategy_name == "magnitude": masks = self._create_magnitude_masks(net, amount, selection_mode) @@ -240,13 +241,13 @@ def _create_all_masks( masks = self._create_magnitude_masks(net, amount, selection_mode) else: raise ValueError(f"Unknown strategy: {strategy_name}") - + network_masks.append(masks) - + all_masks.append(network_masks) - + return all_masks - + def _create_magnitude_masks( self, model: nn.Module, @@ -255,12 +256,12 @@ def _create_magnitude_masks( ) -> Dict[str, torch.Tensor]: """Create magnitude-based masks for a model.""" masks = {} - + for name, module in model.named_modules(): if hasattr(module, 'weight'): weight = module.weight.data importance = weight.abs() - + if hasattr(self.config, 'alignment_structured_pruning') and self.config.alignment_structured_pruning and len(weight.shape) >= 2: # Structured pruning - prune entire neurons neuron_importance = importance.mean(dim=tuple(range(1, len(weight.shape)))) @@ -268,11 +269,11 @@ def _create_magnitude_masks( else: # Unstructured pruning mask = self._create_mask(importance, amount, selection_mode) - + masks[name] = mask - + return masks - + def _create_random_masks( self, model: nn.Module, @@ -280,11 +281,11 @@ def _create_random_masks( ) -> Dict[str, torch.Tensor]: """Create random masks for a model.""" masks = {} - + for name, module in model.named_modules(): if hasattr(module, 'weight'): weight = module.weight.data - + if hasattr(self.config, 'alignment_structured_pruning') and self.config.alignment_structured_pruning and len(weight.shape) >= 2: # Structured random pruning num_neurons = weight.shape[0] @@ -298,11 +299,11 @@ def _create_random_masks( else: # Unstructured random pruning mask = torch.rand_like(weight) > amount - + masks[name] = mask.float() - + return masks - + def _create_mask( self, importance: torch.Tensor, @@ -314,13 +315,13 @@ def _create_mask( return torch.ones_like(importance) elif amount >= 1: return torch.zeros_like(importance) - + flat_importance = importance.flatten() k = int(amount * flat_importance.numel()) - + if k == 0: return torch.ones_like(importance) - + if selection_mode == "low": threshold = torch.kthvalue(flat_importance, k).values mask = importance > threshold @@ -331,9 +332,9 @@ def _create_mask( mask = torch.rand_like(importance) > amount else: raise ValueError(f"Unknown selection mode: {selection_mode}") - + return mask.float() - + def _create_structured_mask( self, neuron_importance: torch.Tensor, @@ -344,7 +345,7 @@ def _create_structured_mask( """Create a structured mask that prunes entire neurons.""" num_neurons = neuron_importance.numel() num_to_prune = int(amount * num_neurons) - + if num_to_prune == 0: mask = torch.ones_like(neuron_importance) elif num_to_prune >= num_neurons: @@ -364,7 +365,7 @@ def _create_structured_mask( mask[indices] = 0 else: raise ValueError(f"Unknown selection mode: {selection_mode}") - + # Expand mask to match weight dimensions if len(weight_shape) == 2: # Linear layer: [out_features, in_features] @@ -372,9 +373,9 @@ def _create_structured_mask( elif len(weight_shape) == 4: # Conv layer: [out_channels, in_channels, height, width] mask = mask.view(-1, 1, 1, 1).expand_as(torch.zeros(weight_shape)) - + return mask - + def _apply_mask_config( self, model: nn.Module, @@ -387,20 +388,20 @@ def _apply_mask_config( if name in original_state: # Restore original weights module.weight.data = original_state[name].clone() - + # Apply mask if exists if name in masks: module.weight.data *= masks[name] - + def _calculate_sparsity(self, model: nn.Module) -> float: """Calculate the sparsity of a model.""" total_params = 0 zero_params = 0 - + for module in model.modules(): if hasattr(module, 'weight'): weight = module.weight.data total_params += weight.numel() zero_params += (weight == 0).sum().item() - - return zero_params / total_params if total_params > 0 else 0.0 \ No newline at end of file + + return zero_params / total_params if total_params > 0 else 0.0 diff --git a/src/alignment/pruning/strategies/random.py b/src/alignment/pruning/strategies/random.py index 167a5e71..e6b13076 100644 --- a/src/alignment/pruning/strategies/random.py +++ b/src/alignment/pruning/strategies/random.py @@ -5,9 +5,10 @@ regardless of their values. Used as a baseline for comparison. """ +from typing import Optional + import torch import torch.nn as nn -from typing import Optional from ..base import BasePruningStrategy @@ -15,11 +16,11 @@ class RandomPruning(BasePruningStrategy): """ Random pruning strategy. - + This strategy randomly prunes weights regardless of their values. It's primarily used as a baseline to compare against informed pruning strategies. - + Examples: >>> from alignment.pruning.strategies import RandomPruning >>> from alignment.pruning import PruningConfig @@ -37,11 +38,11 @@ class RandomPruning(BasePruningStrategy): >>> strategy = RandomPruning(seed=42) >>> mask = strategy.prune(layer, amount=0.5) """ - + def __init__(self, config=None, seed: Optional[int] = None): """ Initialize random pruning strategy. - + Args: config: Pruning configuration seed: Random seed for reproducibility @@ -50,7 +51,7 @@ def __init__(self, config=None, seed: Optional[int] = None): self.seed = seed if seed is not None: torch.manual_seed(seed) - + def compute_importance_scores( self, module: nn.Module, @@ -59,23 +60,23 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute random importance scores. - + For structured pruning, generates neuron-level scores. For unstructured pruning, generates weight-level scores. - + Args: module: Module to compute scores for inputs: Not used **kwargs: Not used - + Returns: Tensor of random importance scores """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + weights = module.weight.data - + # For structured pruning, generate neuron-level scores if self.config.structured: if len(weights.shape) == 2: # Linear layer @@ -95,17 +96,17 @@ def compute_importance_scores( else: # For unstructured pruning, generate weight-level random scores importance_scores = torch.rand_like(weights) - + return importance_scores class LayerwiseRandomPruning(RandomPruning): """ Layerwise random pruning with different sparsity per layer. - + This strategy allows specifying different pruning amounts for different layers while still using random selection. - + Examples: >>> from alignment.pruning.strategies import LayerwiseRandomPruning >>> @@ -119,7 +120,7 @@ class LayerwiseRandomPruning(RandomPruning): >>> strategy = LayerwiseRandomPruning(layer_sparsity=layer_sparsity) >>> masks = strategy.prune_model(model) """ - + def __init__( self, config=None, @@ -129,7 +130,7 @@ def __init__( ): """ Initialize layerwise random pruning. - + Args: config: Pruning configuration layer_sparsity: Dict mapping layer names to sparsity levels @@ -139,19 +140,19 @@ def __init__( super().__init__(config, seed) self.layer_sparsity = layer_sparsity or {} self.default_sparsity = default_sparsity - + def prune_model(self, model: nn.Module) -> dict: """ Prune model with per-layer random sparsity. - + Args: model: Model to prune - + Returns: Dictionary mapping module names to pruning masks """ masks = {} - + for name, module in model.named_modules(): if hasattr(module, 'weight'): # Get sparsity for this layer @@ -159,24 +160,24 @@ def prune_model(self, model: nn.Module) -> dict: amount = self.layer_sparsity[name] else: amount = self.default_sparsity - + # Generate random mask importance = self.compute_importance_scores(module) mask = self.create_pruning_mask(importance, amount) self.apply_pruning(module, mask) masks[name] = mask - + return masks class BernoulliPruning(BasePruningStrategy): """ Bernoulli (probabilistic) pruning strategy. - + Instead of pruning exactly k% of weights, each weight is pruned with probability p = k/100. This can be useful for theoretical analysis and stochastic pruning approaches. - + Examples: >>> from alignment.pruning.strategies import BernoulliPruning >>> @@ -192,7 +193,7 @@ class BernoulliPruning(BasePruningStrategy): >>> strategy = BernoulliPruning(probability_map=prob_map) >>> masks = strategy.prune_model(model) """ - + def __init__( self, config=None, @@ -202,7 +203,7 @@ def __init__( ): """ Initialize Bernoulli pruning strategy. - + Args: config: Pruning configuration probability: Default pruning probability @@ -215,7 +216,7 @@ def __init__( self.seed = seed if seed is not None: torch.manual_seed(seed) - + def compute_importance_scores( self, module: nn.Module, @@ -224,40 +225,40 @@ def compute_importance_scores( ) -> torch.Tensor: """ Compute Bernoulli importance scores. - + Higher scores mean higher probability of keeping the weight. - + Args: module: Module to compute scores for inputs: Not used **kwargs: Can contain 'probability' override - + Returns: Tensor of importance scores """ if not hasattr(module, 'weight'): raise ValueError(f"Module {module} does not have weights") - + # Get probability for this module prob = kwargs.get('probability', self.probability) - + # Check if module type has specific probability for module_type, type_prob in self.probability_map.items(): if isinstance(module, module_type): prob = type_prob break - + # Generate Bernoulli samples (inverted: 1 means keep) # We return uniform random values and let create_pruning_mask # handle the thresholding importance = torch.rand_like(module.weight.data) - + # Adjust scores so that approximately (1-prob) fraction # will be below the threshold importance = importance / prob if prob > 0 else importance - + return importance - + def create_pruning_mask( self, importance_scores: torch.Tensor, @@ -267,39 +268,39 @@ def create_pruning_mask( ) -> torch.Tensor: """ Create Bernoulli pruning mask. - + Overrides base method to use threshold of 1.0 for Bernoulli sampling. - + Args: importance_scores: Tensor of importance scores amount: Not used (probability is set in compute_importance_scores) structured: Whether to do structured pruning dim: Dimension for structured pruning - + Returns: Binary mask tensor """ # For Bernoulli pruning, we threshold at 1.0 mask = (importance_scores > 1.0).float() - + if structured and dim is not None: # For structured pruning, prune entire structure if # majority of weights would be pruned dims_to_reduce = list(range(importance_scores.ndim)) dims_to_reduce.pop(dim) - + if dims_to_reduce: structure_scores = mask.sum(dim=dims_to_reduce) structure_sizes = torch.ones_like(structure_scores) for d in dims_to_reduce: structure_sizes *= importance_scores.shape[d] - + # Keep structure if more than half weights would survive structure_mask = structure_scores > (structure_sizes / 2) - + # Expand to original shape shape = [1] * importance_scores.ndim shape[dim] = importance_scores.shape[dim] mask = structure_mask.reshape(shape).expand_as(importance_scores).float() - - return mask \ No newline at end of file + + return mask diff --git a/src/alignment/pruning/strategies/ultimate.py b/src/alignment/pruning/strategies/ultimate.py index e147de9c..5f4766b7 100644 --- a/src/alignment/pruning/strategies/ultimate.py +++ b/src/alignment/pruning/strategies/ultimate.py @@ -10,14 +10,13 @@ Expected: Best possible accuracy retention at high sparsity. """ -import torch -import torch.nn as nn -from typing import Dict, List, Optional, Callable, Any, Tuple import logging +from typing import Any, Callable, Dict, List, Optional + +import torch.nn as nn -from ..base import BasePruningStrategy, PruningConfig -from .adaptive import AdaptiveSensitivityPruning from ..dependency_aware import DependencyAwarePruning +from .adaptive import AdaptiveSensitivityPruning logger = logging.getLogger(__name__) @@ -25,20 +24,20 @@ class UltimatePruningStrategy: """ State-of-the-art pruning combining multiple advanced techniques. - + Multi-stage progressive pruning with adaptive per-layer amounts and redundancy-aware composite scoring. - + Stages: 1. Sensitivity Analysis → adaptive per-layer amounts 2. Coarse Pruning (magnitude) → safe initial pruning 3. Refined Pruning (composite) → redundancy-aware 4. Cleanup → remove truly dead neurons - + Expected performance: - 70% sparsity: ~5% accuracy drop (vs 10% for magnitude) - 85% sparsity: ~12% drop (vs 20% for magnitude) - + Example: >>> strategy = UltimatePruningStrategy( ... target_sparsity=0.7, @@ -47,7 +46,7 @@ class UltimatePruningStrategy: >>> result = strategy.prune(model, train_loader, val_loader) >>> print(f"Final accuracy: {result['accuracy']:.2f}%") """ - + def __init__( self, target_sparsity: float = 0.7, @@ -59,7 +58,7 @@ def __init__( ): """ Initialize ultimate pruning strategy. - + Args: target_sparsity: Target overall sparsity stages: Pruning schedule @@ -75,18 +74,18 @@ def __init__( self.sensitivity_based = sensitivity_based self.use_redundancy = use_redundancy self.fine_tune_epochs_per_stage = fine_tune_epochs_per_stage - + # Initialize sub-strategies if sensitivity_based: self.adaptive_pruner = AdaptiveSensitivityPruning( target_sparsity=target_sparsity ) - + self.dependency_pruner = DependencyAwarePruning - + # Define pruning stages self.stages = self._get_pruning_stages(stages) - + def _get_pruning_stages(self, mode: str) -> List[Dict]: """Define pruning stages based on mode.""" if mode == 'full': @@ -116,7 +115,7 @@ def _get_pruning_stages(self, mode: str) -> List[Dict]: 'fine_tune_epochs': self.fine_tune_epochs_per_stage * 2 } ] - + elif mode == 'fast': return [ { @@ -132,7 +131,7 @@ def _get_pruning_stages(self, mode: str) -> List[Dict]: 'fine_tune_epochs': self.fine_tune_epochs_per_stage * 2 } ] - + else: # one-shot return [ { @@ -142,7 +141,7 @@ def _get_pruning_stages(self, mode: str) -> List[Dict]: 'fine_tune_epochs': self.fine_tune_epochs_per_stage * 3 } ] - + def prune( self, model: nn.Module, @@ -154,7 +153,7 @@ def prune( ) -> Dict[str, Any]: """ Execute ultimate pruning strategy. - + Args: model: Model to prune train_loader: Training data (for fine-tuning) @@ -162,7 +161,7 @@ def prune( layers_to_prune: Specific layers (None = auto-detect) trainer_fn: Function(model, train_loader, epochs) for fine-tuning eval_fn: Function(model, val_loader) -> accuracy - + Returns: Results dictionary with masks, stats, accuracy history """ @@ -170,16 +169,16 @@ def prune( if layers_to_prune is None: from ...core.layer_detector import detect_trackable_layers layers_to_prune = detect_trackable_layers(model) - + logger.info(f"Pruning {len(layers_to_prune)} layers with {self.stages_mode} strategy") - + # Baseline evaluation if eval_fn: baseline_acc = eval_fn(model, val_loader) logger.info(f"Baseline accuracy: {baseline_acc:.2f}%") else: baseline_acc = None - + # Step 1: Sensitivity analysis (if enabled) if self.sensitivity_based and eval_fn: logger.info("Stage 0: Computing layer sensitivities...") @@ -189,7 +188,7 @@ def prune( self.adaptive_pruner.print_sensitivity_report() else: sensitivities = None - + # Track results results = { 'baseline_accuracy': baseline_acc, @@ -197,16 +196,16 @@ def prune( 'final_masks': {}, 'sensitivity_report': sensitivities } - + # Execute stages for stage_idx, stage in enumerate(self.stages): logger.info(f"\n{'='*80}") logger.info(f"Stage {stage_idx + 1}/{len(self.stages)}: {stage['name']}") logger.info(f"{'='*80}") - + # Compute target amount for this stage stage_target = self.target_sparsity * stage['target_fraction'] - + # Prune stage_result = self._execute_stage( model, @@ -215,37 +214,37 @@ def prune( stage['metric'], sensitivities ) - + # Fine-tune if trainer provided if trainer_fn and stage['fine_tune_epochs'] > 0: logger.info(f"Fine-tuning for {stage['fine_tune_epochs']} epochs...") trainer_fn(model, train_loader, epochs=stage['fine_tune_epochs']) - + # Evaluate if eval_fn: stage_acc = eval_fn(model, val_loader) logger.info(f"Accuracy after stage: {stage_acc:.2f}%") stage_result['accuracy'] = stage_acc - + results['stage_results'].append(stage_result) - + # Final evaluation if eval_fn: final_acc = eval_fn(model, val_loader) results['final_accuracy'] = final_acc results['accuracy_drop'] = baseline_acc - final_acc if baseline_acc else None - + logger.info(f"\n{'='*80}") - logger.info(f"FINAL RESULTS") + logger.info("FINAL RESULTS") logger.info(f"{'='*80}") logger.info(f"Baseline: {baseline_acc:.2f}%") logger.info(f"Final: {final_acc:.2f}%") logger.info(f"Drop: {results['accuracy_drop']:.2f}%") logger.info(f"Sparsity: {self.target_sparsity:.1%}") logger.info(f"{'='*80}\n") - + return results - + def _execute_stage( self, model: nn.Module, @@ -255,15 +254,13 @@ def _execute_stage( sensitivities: Optional[Dict] ) -> Dict: """Execute a single pruning stage.""" - from ...metrics import get_metric - from ...services import NodeScoringService, MaskOperations - + stage_result = { 'metric': metric_name, 'target': stage_target, 'masks': {} } - + # Compute layer-specific amounts if adaptive if sensitivities: # Use adaptive amounts, scaled to stage target @@ -274,34 +271,34 @@ def _execute_stage( else: # Uniform amount layer_amounts = {name: stage_target for name in layer_names} - + # Compute scores and masks per layer layer_scores = {} - + for layer_name in layer_names: layer = dict(model.named_modules())[layer_name] - amount = layer_amounts.get(layer_name, stage_target) - + layer_amounts.get(layer_name, stage_target) + # Compute scores based on metric if metric_name == 'magnitude': scores = layer.weight.abs().flatten() if scores.ndim > 1: scores = scores.mean(dim=list(range(1, scores.ndim))) - + elif metric_name == 'rayleigh_quotient': # Would need inputs - skip for now or use cached scores = layer.weight.norm(dim=1) # Fallback - + elif metric_name == 'composite' and self.use_redundancy: # Use redundancy-aware composite # Would need full pipeline - simplified here scores = layer.weight.norm(dim=1) - + else: scores = layer.weight.norm(dim=1) - + layer_scores[layer_name] = scores - + # Apply with dependency awareness pruner = self.dependency_pruner(model) result = pruner.prune( @@ -309,10 +306,10 @@ def _execute_stage( amount=stage_target, dry_run=False ) - + stage_result['masks'] = result['masks'] stage_result['stats'] = result['stats'] - + return stage_result @@ -323,12 +320,12 @@ def create_ultimate_pruner( ) -> UltimatePruningStrategy: """ Factory function for creating ultimate pruning strategy. - + Args: target_sparsity: Target overall sparsity mode: 'full' (best quality), 'fast' (faster), 'oneshot' **config: Additional configuration - + Returns: Configured UltimatePruningStrategy """ diff --git a/src/alignment/pruning/strategies/ultra_fast.py b/src/alignment/pruning/strategies/ultra_fast.py index 3d800fb6..3416a34c 100644 --- a/src/alignment/pruning/strategies/ultra_fast.py +++ b/src/alignment/pruning/strategies/ultra_fast.py @@ -1,11 +1,11 @@ """Ultra-fast parallel pruning strategy for alignment experiments.""" +import logging +import time +from typing import Any, Dict, List, Optional + import torch import torch.nn as nn -import copy -import time -from typing import Dict, List, Tuple, Optional, Any -import logging from ..base import BasePruningStrategy @@ -15,53 +15,53 @@ class UltraFastParallelPruning(BasePruningStrategy): """ Ultra-fast parallel pruning that evaluates all configurations simultaneously. - + This strategy processes multiple networks and pruning amounts in a single pass, dramatically reducing computation time compared to sequential processing. """ - + def __init__(self, config=None): super().__init__(config) self.networks = None self.data_loader = None self.original_states = None - + def setup(self, networks: List[nn.Module], data_loader): """Setup the pruning strategy with networks and data.""" self.networks = networks self.data_loader = data_loader - + # Save original states efficiently self.original_states = [] for model in self.networks: - state = {name: module.weight.data.clone() - for name, module in model.named_modules() + state = {name: module.weight.data.clone() + for name, module in model.named_modules() if hasattr(module, 'weight')} self.original_states.append(state) - + def run_pruning_experiments( - self, + self, strategies: List[str], selection_modes: List[str], pruning_amounts: List[float] ) -> Dict[str, Any]: """Run ultra-fast parallel pruning experiments.""" logger.info("Running ULTRA-FAST PARALLEL pruning experiments") - + results = {"strategies": {}} - + # Process each strategy for strategy_name in strategies: logger.info(f"Testing pruning strategy: {strategy_name}") - + for selection_mode in selection_modes: logger.info(f" Selection mode: {selection_mode}") - + # Always use ultra-parallel implementation batch_results = self._ultra_fast_parallel_pruning( strategy_name, selection_mode, pruning_amounts ) - + # Store results strategy_key = f"{strategy_name}_{selection_mode}" strategy_results = { @@ -72,26 +72,26 @@ def run_pruning_experiments( "losses_after_finetune": batch_results["losses_after"].mean(dim=0).tolist(), "improvements": (batch_results["accuracies_after"] - batch_results["accuracies_before"]).mean(dim=0).tolist() } - + # Add standard deviations if multiple networks if len(self.networks) > 1: strategy_results["accuracies_before_finetune_std"] = batch_results["accuracies_before"].std(dim=0).tolist() strategy_results["accuracies_after_finetune_std"] = batch_results["accuracies_after"].std(dim=0).tolist() - + results["strategies"][strategy_key] = strategy_results - + # Restore original weights for net_idx, model in enumerate(self.networks): for name, module in model.named_modules(): if name in self.original_states[net_idx]: module.weight.data = self.original_states[net_idx][name] - + return results - + def _ultra_fast_parallel_pruning( - self, - strategy_name: str, - selection_mode: str, + self, + strategy_name: str, + selection_mode: str, pruning_amounts: List[float] ) -> Dict[str, torch.Tensor]: """ @@ -101,106 +101,106 @@ def _ultra_fast_parallel_pruning( num_networks = len(self.networks) num_amounts = len(pruning_amounts) total_configs = num_networks * num_amounts - + logger.info(f" Processing {total_configs} configurations in TRUE parallel") logger.info(f" Networks: {num_networks}, Sparsity levels: {num_amounts}") - + # Initialize result tensors on GPU for speed device = self.config.device if hasattr(self.config, 'device') else 'cuda' accuracies_before = torch.zeros(num_networks, num_amounts, device=device) losses_before = torch.zeros(num_networks, num_amounts, device=device) sparsities = torch.zeros(num_networks, num_amounts) - + # Create all masks upfront logger.info(" Creating masks for all configurations...") all_masks = self._create_all_masks(strategy_name, selection_mode, pruning_amounts) - + # Evaluate all configurations in a single pass per batch logger.info(" Starting TRULY PARALLEL evaluation...") start_time = time.time() - + # Pre-allocate for all configurations all_correct = torch.zeros(total_configs, device=device) all_loss = torch.zeros(total_configs, device=device) total_samples = 0 - + # Set all networks to eval mode for net in self.networks: net.eval() - + criterion = nn.CrossEntropyLoss(reduction='none') eval_batches = getattr(self.config, 'eval_batches', None) if hasattr(self.config, 'eval_batches') else None batch_count = 0 - + with torch.no_grad(): for inputs, targets in self.data_loader: inputs = inputs.to(device) targets = targets.to(device) batch_size = targets.size(0) - + # Collect outputs from ALL configurations in one go all_outputs = [] - + for net_idx in range(num_networks): net = self.networks[net_idx] - + for amount_idx in range(num_amounts): # Apply configuration self._apply_mask_config(net, all_masks[net_idx][amount_idx], self.original_states[net_idx]) - + # Forward pass outputs = net(inputs) all_outputs.append(outputs) - + # Calculate sparsity (only once) if batch_count == 0: sparsities[net_idx, amount_idx] = self._calculate_sparsity(net) - + # Stack all outputs for vectorized processing stacked_outputs = torch.stack(all_outputs, dim=0) # [total_configs, batch_size, num_classes] - + # Compute losses for all configs at once expanded_targets = targets.unsqueeze(0).expand(total_configs, -1) all_batch_losses = criterion( - stacked_outputs.reshape(-1, stacked_outputs.size(-1)), + stacked_outputs.reshape(-1, stacked_outputs.size(-1)), expanded_targets.reshape(-1) ).reshape(total_configs, batch_size) - + # Sum losses all_loss += all_batch_losses.sum(dim=1) - + # Get predictions and count correct all_preds = stacked_outputs.argmax(dim=2) # [total_configs, batch_size] correct = all_preds.eq(expanded_targets).sum(dim=1) all_correct += correct - + total_samples += batch_size batch_count += 1 - + # Check if we've evaluated enough batches if eval_batches is not None and batch_count >= eval_batches: break - + # Reshape results back to [num_networks, num_amounts] all_correct = all_correct.reshape(num_networks, num_amounts) all_loss = all_loss.reshape(num_networks, num_amounts) - + # Convert to accuracies and average losses accuracies_before = (all_correct * 100.0 / total_samples).cpu() losses_before = (all_loss / batch_count).cpu() - + eval_time = time.time() - start_time logger.info(f" Parallel evaluation completed in {eval_time:.2f} seconds") logger.info(f" Average accuracy: {accuracies_before.mean():.2f}%") - + # For now, no fine-tuning in ultra-fast mode (can be added if needed) accuracies_after = accuracies_before.clone() losses_after = losses_before.clone() - + # Reset networks to train mode for net in self.networks: net.train() - + return { "accuracies_before": accuracies_before, "losses_before": losses_before, @@ -208,25 +208,25 @@ def _ultra_fast_parallel_pruning( "losses_after": losses_after, "sparsities": sparsities } - + def compute_importance_scores(self, module: nn.Module, inputs: Optional[torch.Tensor] = None, **kwargs) -> torch.Tensor: """Compute importance scores for a module (not used in parallel mode).""" # This is implemented for compatibility with BasePruningStrategy # The actual importance computation happens in _create_all_masks return module.weight.data.abs() - + def _create_all_masks( - self, - strategy_name: str, - selection_mode: str, + self, + strategy_name: str, + selection_mode: str, pruning_amounts: List[float] ) -> List[List[Dict[str, torch.Tensor]]]: """Create all masks for all networks and pruning amounts.""" all_masks = [] - + for net_idx, net in enumerate(self.networks): network_masks = [] - + for amount in pruning_amounts: if strategy_name == "magnitude": masks = self._create_magnitude_masks(net, amount, selection_mode) @@ -237,29 +237,29 @@ def _create_all_masks( masks = self._create_alignment_masks(net, amount, selection_mode) else: raise ValueError(f"Unknown strategy: {strategy_name}") - + network_masks.append(masks) - + all_masks.append(network_masks) - + return all_masks - + def _create_magnitude_masks( - self, - model: nn.Module, - amount: float, + self, + model: nn.Module, + amount: float, selection_mode: str ) -> Dict[str, torch.Tensor]: """Create magnitude-based masks for a model.""" masks = {} - + for name, module in model.named_modules(): if hasattr(module, 'weight'): weight = module.weight.data importance = weight.abs() - + structured = getattr(self.config, 'alignment_structured_pruning', True) if hasattr(self.config, 'alignment_structured_pruning') else True - + if structured and len(weight.shape) >= 2: # Structured pruning - prune entire neurons neuron_importance = importance.mean(dim=tuple(range(1, len(weight.shape)))) @@ -267,25 +267,25 @@ def _create_magnitude_masks( else: # Unstructured pruning mask = self._create_mask(importance, amount, selection_mode) - + masks[name] = mask - + return masks - + def _create_random_masks( - self, - model: nn.Module, + self, + model: nn.Module, amount: float ) -> Dict[str, torch.Tensor]: """Create random masks for a model.""" masks = {} - + for name, module in model.named_modules(): if hasattr(module, 'weight'): weight = module.weight.data - + structured = getattr(self.config, 'alignment_structured_pruning', True) if hasattr(self.config, 'alignment_structured_pruning') else True - + if structured and len(weight.shape) >= 2: # Structured random pruning num_neurons = weight.shape[0] @@ -299,15 +299,15 @@ def _create_random_masks( else: # Unstructured random pruning mask = torch.rand_like(weight) > amount - + masks[name] = mask.float() - + return masks - + def _create_alignment_masks( - self, - model: nn.Module, - amount: float, + self, + model: nn.Module, + amount: float, selection_mode: str ) -> Dict[str, torch.Tensor]: """Create alignment-based masks (simplified for speed).""" @@ -315,11 +315,11 @@ def _create_alignment_masks( # based on weight magnitudes weighted by gradient magnitudes # (Full alignment computation would require forward passes) return self._create_magnitude_masks(model, amount, selection_mode) - + def _create_mask( - self, - importance: torch.Tensor, - amount: float, + self, + importance: torch.Tensor, + amount: float, selection_mode: str ) -> torch.Tensor: """Create a binary mask based on importance scores.""" @@ -327,13 +327,13 @@ def _create_mask( return torch.ones_like(importance) elif amount >= 1: return torch.zeros_like(importance) - + flat_importance = importance.flatten() k = int(amount * flat_importance.numel()) - + if k == 0: return torch.ones_like(importance) - + if selection_mode == "low": threshold = torch.kthvalue(flat_importance, k).values mask = importance > threshold @@ -344,20 +344,20 @@ def _create_mask( mask = torch.rand_like(importance) > amount else: raise ValueError(f"Unknown selection mode: {selection_mode}") - + return mask.float() - + def _create_structured_mask( - self, - neuron_importance: torch.Tensor, - amount: float, + self, + neuron_importance: torch.Tensor, + amount: float, selection_mode: str, weight_shape: torch.Size ) -> torch.Tensor: """Create a structured mask that prunes entire neurons.""" num_neurons = neuron_importance.numel() num_to_prune = int(amount * num_neurons) - + if num_to_prune == 0: mask = torch.ones_like(neuron_importance) elif num_to_prune >= num_neurons: @@ -377,7 +377,7 @@ def _create_structured_mask( mask[indices] = 0 else: raise ValueError(f"Unknown selection mode: {selection_mode}") - + # Expand mask to match weight dimensions if len(weight_shape) == 2: # Linear layer: [out_features, in_features] @@ -385,13 +385,13 @@ def _create_structured_mask( elif len(weight_shape) == 4: # Conv layer: [out_channels, in_channels, height, width] mask = mask.view(-1, 1, 1, 1).expand_as(torch.zeros(weight_shape)) - + return mask - + def _apply_mask_config( - self, - model: nn.Module, - masks: Dict[str, torch.Tensor], + self, + model: nn.Module, + masks: Dict[str, torch.Tensor], original_state: Dict[str, torch.Tensor] ): """Apply masks to a model after restoring original weights.""" @@ -400,20 +400,20 @@ def _apply_mask_config( if name in original_state: # Restore original weights module.weight.data = original_state[name].clone() - + # Apply mask if exists if name in masks: module.weight.data *= masks[name] - + def _calculate_sparsity(self, model: nn.Module) -> float: """Calculate the sparsity of a model.""" total_params = 0 zero_params = 0 - + for module in model.modules(): if hasattr(module, 'weight'): weight = module.weight.data total_params += weight.numel() zero_params += (weight == 0).sum().item() - - return zero_params / total_params if total_params > 0 else 0.0 \ No newline at end of file + + return zero_params / total_params if total_params > 0 else 0.0 diff --git a/src/alignment/quantization/__init__.py b/src/alignment/quantization/__init__.py index feb55578..30d3f6b3 100644 --- a/src/alignment/quantization/__init__.py +++ b/src/alignment/quantization/__init__.py @@ -13,18 +13,17 @@ - Symmetric and asymmetric quantization """ -from .ptq import ( - quantize_model, - quantize_layer, - INT8Quantizer, - INT4Quantizer, - MixedPrecisionQuantizer -) - from .analysis import ( analyze_quantization_sensitivity, compute_quantization_error, - find_optimal_bit_allocation + find_optimal_bit_allocation, +) +from .ptq import ( + INT4Quantizer, + INT8Quantizer, + MixedPrecisionQuantizer, + quantize_layer, + quantize_model, ) __all__ = [ diff --git a/src/alignment/quantization/analysis.py b/src/alignment/quantization/analysis.py index 99a3673f..ffe154f2 100644 --- a/src/alignment/quantization/analysis.py +++ b/src/alignment/quantization/analysis.py @@ -4,10 +4,11 @@ Analyze quantization effects on model performance and neuron importance. """ +import logging +from typing import Callable, Dict, List + import torch import torch.nn as nn -from typing import Dict, List, Optional, Callable -import logging logger = logging.getLogger(__name__) @@ -20,35 +21,35 @@ def analyze_quantization_sensitivity( ) -> Dict[str, Dict]: """ Analyze how sensitive each layer is to quantization. - + Args: model: Model to analyze data_loader: Validation data eval_fn: Evaluation function precision_levels: Precisions to test - + Returns: Sensitivity scores per layer per precision """ from .ptq import quantize_layer - + baseline_score = eval_fn(model, data_loader) - + results = {} - + for name, module in model.named_modules(): if not isinstance(module, nn.Linear): continue - + layer_results = {} - + for precision in precision_levels: # Save original weight original_weight = module.weight.data.clone() - + # Quantize and dequantize quant_result = quantize_layer(module, precision) - + # Dequantize for evaluation if precision == 'int8': dequant = quant_result['weight'].float() * quant_result['scale'] @@ -57,27 +58,27 @@ def analyze_quantization_sensitivity( else: # int4 # Simplified dequantization dequant = quant_result['weight'].float() * quant_result['scale'].mean() - + # Apply quantized weight module.weight.data = dequant - + # Evaluate quantized_score = eval_fn(model, data_loader) - + # Compute sensitivity sensitivity = abs(baseline_score - quantized_score) - + layer_results[precision] = { 'score': quantized_score, 'sensitivity': sensitivity, 'relative_error': sensitivity / baseline_score if baseline_score != 0 else 0 } - + # Restore original module.weight.data = original_weight - + results[name] = layer_results - + return results @@ -88,27 +89,27 @@ def compute_quantization_error( ) -> Dict[str, float]: """ Compute quantization error metrics. - + Args: original_weight: Original FP weights quantized_weight: Quantized weights scale: Quantization scale - + Returns: Error metrics (MSE, MAE, SNR) """ # Dequantize dequantized = quantized_weight.float() * scale - + # Compute errors mse = ((original_weight - dequantized) ** 2).mean().item() mae = (original_weight - dequantized).abs().mean().item() - + # Signal-to-noise ratio signal_power = (original_weight ** 2).mean().item() noise_power = mse snr = 10 * torch.log10(torch.tensor(signal_power / (noise_power + 1e-10))) - + return { 'mse': mse, 'mae': mae, @@ -125,17 +126,17 @@ def find_optimal_bit_allocation( ) -> Dict[str, int]: """ Find optimal bit allocation per layer based on importance. - + High importance layers get more bits, low importance get fewer bits, achieving target average bits overall. - + Args: model: Model layer_scores: Importance scores per layer target_avg_bits: Target average bits (e.g., 6.0) min_bits: Minimum bits per layer max_bits: Maximum bits per layer - + Returns: Bit allocation per layer """ @@ -143,47 +144,47 @@ def find_optimal_bit_allocation( all_scores = [] layer_names = [] layer_sizes = [] - + for name, module in model.named_modules(): if isinstance(module, nn.Linear) and name in layer_scores: score = layer_scores[name].mean().item() size = module.weight.numel() - + all_scores.append(score) layer_names.append(name) layer_sizes.append(size) - + if not all_scores: return {} - + scores_tensor = torch.tensor(all_scores) scores_norm = (scores_tensor - scores_tensor.min()) / (scores_tensor.max() - scores_tensor.min() + 1e-8) - + # Allocate bits proportionally to importance # Important layers get more bits bit_range = max_bits - min_bits bits_initial = (min_bits + bit_range * scores_norm).round().int() - + # Adjust to meet target average total_size = sum(layer_sizes) current_avg = sum(bits_initial[i].item() * layer_sizes[i] for i in range(len(layer_sizes))) / total_size - + # Scale to hit target if current_avg != target_avg_bits: adjustment = target_avg_bits / current_avg bits_adjusted = (bits_initial.float() * adjustment).round().clamp(min_bits, max_bits).int() else: bits_adjusted = bits_initial - + # Create allocation dict allocation = { layer_names[i]: bits_adjusted[i].item() for i in range(len(layer_names)) } - + # Verify average actual_avg = sum(allocation[name] * layer_sizes[i] for i, name in enumerate(layer_names)) / total_size logger.info(f"Bit allocation: target={target_avg_bits}, actual={actual_avg:.2f}") - + return allocation diff --git a/src/alignment/quantization/ptq.py b/src/alignment/quantization/ptq.py index 7dc828af..66ab8d95 100644 --- a/src/alignment/quantization/ptq.py +++ b/src/alignment/quantization/ptq.py @@ -4,10 +4,11 @@ Quantize trained models without retraining. """ +import logging +from typing import Dict, Literal, Optional + import torch import torch.nn as nn -from typing import Optional, Dict, List, Literal -import logging logger = logging.getLogger(__name__) @@ -15,10 +16,10 @@ class INT8Quantizer: """ INT8 post-training quantization. - + Converts FP32/FP16 weights and activations to INT8. """ - + def __init__( self, per_channel: bool = True, @@ -26,14 +27,14 @@ def __init__( ): """ Initialize INT8 quantizer. - + Args: per_channel: Per-channel vs per-tensor quantization symmetric: Symmetric vs asymmetric quantization """ self.per_channel = per_channel self.symmetric = symmetric - + def quantize_tensor( self, tensor: torch.Tensor, @@ -41,11 +42,11 @@ def quantize_tensor( ) -> tuple: """ Quantize a tensor to INT8. - + Args: tensor: Tensor to quantize dim: Channel dimension (for per-channel) - + Returns: (quantized_tensor, scale, zero_point) """ @@ -54,7 +55,7 @@ def quantize_tensor( # Compute scale per channel dims_to_reduce = list(range(tensor.ndim)) dims_to_reduce.remove(dim) - + if self.symmetric: # Symmetric: scale based on max absolute value max_val = tensor.abs().amax(dim=dims_to_reduce, keepdim=True) @@ -77,33 +78,33 @@ def quantize_tensor( max_val = tensor.max() scale = (max_val - min_val) / 255.0 zero_point = (-min_val / scale).round().to(torch.int8) - + # Quantize if self.symmetric: quantized = (tensor / (scale + 1e-8)).round().clamp(-127, 127).to(torch.int8) else: quantized = ((tensor / (scale + 1e-8)) + zero_point).round().clamp(0, 255).to(torch.int8) - + return quantized, scale, zero_point - + def quantize_linear_layer( self, layer: nn.Linear ) -> Dict: """ Quantize a Linear layer. - + Args: layer: Linear layer to quantize - + Returns: Dict with quantized weight, scale, zero_point """ weight = layer.weight.data - + # Quantize weights (per output channel) q_weight, scale, zero_point = self.quantize_tensor(weight, dim=0) - + result = { 'weight': q_weight, 'scale': scale, @@ -111,92 +112,92 @@ def quantize_linear_layer( 'original_dtype': weight.dtype, 'original_shape': weight.shape } - + if layer.bias is not None: result['bias'] = layer.bias.data - + return result class INT4Quantizer: """ INT4 quantization for aggressive compression. - + Commonly used for LLMs to reduce memory footprint. """ - + def __init__(self, block_size: int = 128): """ Initialize INT4 quantizer. - + Args: block_size: Block size for group quantization """ self.block_size = block_size - + def quantize_tensor( self, tensor: torch.Tensor ) -> tuple: """ Quantize tensor to INT4 using block-wise quantization. - + Args: tensor: Tensor to quantize - + Returns: (quantized, scales, zero_points) """ original_shape = tensor.shape tensor_flat = tensor.flatten() - + # Pad to block size pad_size = (self.block_size - len(tensor_flat) % self.block_size) % self.block_size if pad_size > 0: tensor_flat = torch.nn.functional.pad(tensor_flat, (0, pad_size)) - + # Reshape into blocks blocks = tensor_flat.reshape(-1, self.block_size) - num_blocks = blocks.shape[0] - + blocks.shape[0] + # Quantize each block quantized_blocks = [] scales = [] zero_points = [] - + for block in blocks: # Symmetric INT4: range [-7, 7] max_val = block.abs().max() scale = max_val / 7.0 - + q_block = (block / (scale + 1e-8)).round().clamp(-7, 7).to(torch.int8) - + quantized_blocks.append(q_block) scales.append(scale) zero_points.append(torch.tensor(0, dtype=torch.int8)) - + quantized = torch.cat(quantized_blocks) - + # Remove padding if pad_size > 0: quantized = quantized[:-pad_size] - + # Reshape back quantized = quantized.reshape(original_shape) scales = torch.tensor(scales, dtype=tensor.dtype) - + return quantized, scales, torch.zeros_like(scales, dtype=torch.int8) class MixedPrecisionQuantizer: """ Mixed-precision quantization. - + Uses alignment metrics to decide precision per layer: - Important layers: Higher precision (FP16/INT8) - Less important: Lower precision (INT4) """ - + def __init__( self, importance_threshold: float = 0.5, @@ -205,7 +206,7 @@ def __init__( ): """ Initialize mixed-precision quantizer. - + Args: importance_threshold: Threshold for high vs low precision high_precision: Format for important layers @@ -214,10 +215,10 @@ def __init__( self.importance_threshold = importance_threshold self.high_precision = high_precision self.low_precision = low_precision - + self.int8_quantizer = INT8Quantizer() self.int4_quantizer = INT4Quantizer() - + def quantize_model_adaptive( self, model: nn.Module, @@ -225,25 +226,25 @@ def quantize_model_adaptive( ) -> Dict: """ Quantize model with mixed precision based on importance. - + Args: model: Model to quantize layer_importance: Importance score per layer - + Returns: Quantization results per layer """ results = {} - + for name, module in model.named_modules(): if not isinstance(module, nn.Linear): continue - + if name not in layer_importance: continue - + importance = layer_importance[name] - + # Choose precision based on importance if importance > self.importance_threshold: # High importance -> higher precision @@ -254,11 +255,11 @@ def quantize_model_adaptive( precision = self.low_precision result = self.int4_quantizer.quantize_tensor(module.weight.data) result = {'weight': result[0], 'scale': result[1], 'zero_point': result[2]} - + result['precision'] = precision result['importance'] = importance results[name] = result - + return results @@ -269,12 +270,12 @@ def quantize_model( ) -> Dict: """ Quantize entire model. - + Args: model: Model to quantize precision: Quantization precision layer_importance: For mixed-precision (optional) - + Returns: Quantization results per layer """ @@ -289,12 +290,12 @@ def quantize_model( return quantizer.quantize_model_adaptive(model, layer_importance) else: raise ValueError(f"Unknown precision: {precision}") - + results = {} for name, module in model.named_modules(): if isinstance(module, nn.Linear): results[name] = quantizer.quantize_linear_layer(module) - + return results @@ -304,11 +305,11 @@ def quantize_layer( ) -> Dict: """ Quantize a single layer. - + Args: layer: Layer to quantize precision: Quantization precision - + Returns: Quantization result """ @@ -318,7 +319,7 @@ def quantize_layer( quantizer = INT4Quantizer() else: raise ValueError(f"Unknown precision: {precision}") - + if isinstance(layer, nn.Linear): return quantizer.quantize_linear_layer(layer) else: diff --git a/src/alignment/services/__init__.py b/src/alignment/services/__init__.py index 90dd855d..669aead9 100644 --- a/src/alignment/services/__init__.py +++ b/src/alignment/services/__init__.py @@ -8,26 +8,22 @@ from .activation_capture import ( ActivationCaptureService, ActivationData, - create_capture_service -) -from .scoring import ( - NodeScoringService, - CompositeScores, - create_scoring_service + create_capture_service, ) from .mask_ops import MaskOperations +from .scoring import CompositeScores, NodeScoringService, create_scoring_service __all__ = [ # Activation capture 'ActivationCaptureService', 'ActivationData', 'create_capture_service', - + # Scoring 'NodeScoringService', 'CompositeScores', 'create_scoring_service', - + # Mask operations 'MaskOperations', ] diff --git a/src/alignment/services/activation_capture.py b/src/alignment/services/activation_capture.py index ea03f4c9..698325ba 100644 --- a/src/alignment/services/activation_capture.py +++ b/src/alignment/services/activation_capture.py @@ -5,11 +5,11 @@ lifecycle management and preprocessing. """ -from typing import Dict, List, Optional, Any, Tuple +import logging from dataclasses import dataclass +from typing import Any, Dict, List, Optional + import torch -import torch.nn as nn -import logging logger = logging.getLogger(__name__) @@ -26,20 +26,20 @@ class ActivationData: class ActivationCaptureService: """ Centralized service for activation and weight collection. - + This service provides: - Automatic hook lifecycle management - Preprocessing options - Memory-efficient batch processing - Clean API for experiments - + Example: >>> service = ActivationCaptureService(model_wrapper) >>> data = service.capture(input_batch, layers=['conv1', 'fc1']) >>> print(data.inputs['conv1'].shape) >>> print(data.outputs['conv1'].shape) """ - + def __init__( self, model_wrapper: Any, # BaseModelWrapper or similar @@ -48,7 +48,7 @@ def __init__( ): """ Initialize activation capture service. - + Args: model_wrapper: Model wrapper with HookManager support default_mode: Default preprocessing mode ('flatten', 'preserve_spatial') @@ -57,7 +57,7 @@ def __init__( self.model_wrapper = model_wrapper self.default_mode = default_mode self.conv_spatial = conv_spatial - + def capture( self, input_batch: torch.Tensor, @@ -68,20 +68,20 @@ def capture( ) -> ActivationData: """ Capture activations and weights for specified layers. - + Args: input_batch: Input tensor [batch, ...] layers: Layers to capture (None = all tracked) mode: Preprocessing mode (None = use default) include_weights: Whether to capture weights preprocess: Whether to preprocess activations - + Returns: ActivationData with inputs, outputs, and weights """ mode = mode or self.default_mode layers = layers or self.model_wrapper.tracked_layers - + # Capture activations using model wrapper try: output, activations = self.model_wrapper.forward_with_activations( @@ -90,36 +90,36 @@ def capture( except Exception as e: logger.error(f"Failed to capture activations: {e}") raise - + # Separate inputs and outputs inputs = {} outputs = {} for layer in layers: input_key = f"{layer}_input" output_key = f"{layer}_output" - + if input_key in activations: inputs[layer] = activations[input_key] if output_key in activations: outputs[layer] = activations[output_key] - + # Preprocess if requested if preprocess: inputs = self._preprocess_activations(inputs, mode) outputs = self._preprocess_activations(outputs, mode) - + # Capture weights if requested weights = {} if include_weights: weights = self.model_wrapper.get_layer_weights(layers=layers) - + return ActivationData( inputs=inputs, outputs=outputs, weights=weights, layer_names=layers ) - + def capture_batch_aggregated( self, dataloader, @@ -129,46 +129,46 @@ def capture_batch_aggregated( ) -> ActivationData: """ Capture activations across multiple batches with aggregation. - + Args: dataloader: DataLoader providing batches layers: Layers to capture max_batches: Maximum number of batches to process aggregation: How to aggregate ('concatenate', 'mean', 'none') - + Returns: Aggregated ActivationData """ layers = layers or self.model_wrapper.tracked_layers - + all_inputs = {layer: [] for layer in layers} all_outputs = {layer: [] for layer in layers} - + for batch_idx, (inputs, _) in enumerate(dataloader): if max_batches and batch_idx >= max_batches: break - + # Move to model device inputs = inputs.to(next(self.model_wrapper.model.parameters()).device) - + # Capture data = self.capture( - inputs, - layers=layers, + inputs, + layers=layers, include_weights=(batch_idx == 0) # Only get weights once ) - + # Accumulate for layer in layers: if layer in data.inputs: all_inputs[layer].append(data.inputs[layer]) if layer in data.outputs: all_outputs[layer].append(data.outputs[layer]) - + # Aggregate if aggregation == 'concatenate': aggregated_inputs = { - layer: torch.cat(tensors, dim=0) + layer: torch.cat(tensors, dim=0) for layer, tensors in all_inputs.items() if tensors } aggregated_outputs = { @@ -187,17 +187,17 @@ def capture_batch_aggregated( else: # 'none' aggregated_inputs = all_inputs aggregated_outputs = all_outputs - + # Get weights from first batch weights = data.weights if 'data' in locals() else {} - + return ActivationData( inputs=aggregated_inputs, outputs=aggregated_outputs, weights=weights, layer_names=layers ) - + def _preprocess_activations( self, activations: Dict[str, torch.Tensor], @@ -205,17 +205,17 @@ def _preprocess_activations( ) -> Dict[str, torch.Tensor]: """ Preprocess activations based on mode. - + Args: activations: Raw activations mode: Preprocessing mode - + Returns: Preprocessed activations """ if mode == 'none': return activations - + processed = {} for name, tensor in activations.items(): if mode == 'flatten': @@ -224,11 +224,11 @@ def _preprocess_activations( processed[name] = tensor.reshape(tensor.shape[0], -1) else: processed[name] = tensor - + elif mode == 'preserve_spatial': # Keep original shape processed[name] = tensor - + elif mode == 'patchwise': # For Conv: [B, C, H, W] -> [B, C, H*W] if tensor.ndim == 4: # Conv2d @@ -238,17 +238,17 @@ def _preprocess_activations( processed[name] = tensor else: processed[name] = tensor - + else: logger.warning(f"Unknown preprocessing mode: {mode}, using 'flatten'") processed[name] = tensor.reshape(tensor.shape[0], -1) if tensor.ndim > 2 else tensor - + return processed - + def __enter__(self): """Support context manager usage.""" return self - + def __exit__(self, exc_type, exc_val, exc_tb): """Cleanup on exit.""" if hasattr(self.model_wrapper, 'hook_manager'): @@ -259,11 +259,11 @@ def __exit__(self, exc_type, exc_val, exc_tb): def create_capture_service(model_wrapper, **config) -> ActivationCaptureService: """ Factory function for creating ActivationCaptureService. - + Args: model_wrapper: Model wrapper instance **config: Configuration options - + Returns: Configured ActivationCaptureService """ diff --git a/src/alignment/services/mask_ops.py b/src/alignment/services/mask_ops.py index a53061c3..c5df4260 100644 --- a/src/alignment/services/mask_ops.py +++ b/src/alignment/services/mask_ops.py @@ -5,9 +5,10 @@ and unstructured pruning. """ -from typing import Tuple, Optional, Union -import torch import logging +from typing import Tuple + +import torch logger = logging.getLogger(__name__) @@ -15,14 +16,14 @@ class MaskOperations: """ Utilities for creating and manipulating pruning masks. - + Provides methods for: - Creating structured (neuron/channel) masks - Creating unstructured (weight-level) masks - Expanding masks to match weight shapes - Applying masks to modules """ - + @staticmethod def create_structured_mask( scores: torch.Tensor, @@ -32,49 +33,49 @@ def create_structured_mask( ) -> torch.Tensor: """ Create structured mask (neuron/channel level) from importance scores. - + Args: scores: Importance scores [num_neurons] amount: Fraction to prune (0 to 1) mode: 'low' (prune low scores), 'high' (prune high scores), 'random' min_keep: Minimum number of neurons to keep - + Returns: Binary mask [num_neurons] where 1=keep, 0=prune """ num_neurons = scores.shape[0] num_to_prune = max(0, int(num_neurons * amount)) num_to_keep = max(min_keep, num_neurons - num_to_prune) - + mask = torch.ones_like(scores, dtype=torch.bool) - + if num_to_prune == 0: return mask - + if mode == 'low': # Prune lowest scores _, indices = torch.topk(scores, num_to_keep, largest=True) mask = torch.zeros_like(scores, dtype=torch.bool) mask[indices] = True - + elif mode == 'high': # Prune highest scores _, indices = torch.topk(scores, num_to_keep, largest=False) mask = torch.zeros_like(scores, dtype=torch.bool) mask[indices] = True - + elif mode == 'random': # Random pruning indices = torch.randperm(num_neurons)[:num_to_keep] mask = torch.zeros_like(scores, dtype=torch.bool) mask[indices] = True - + else: raise ValueError(f"Unknown pruning mode: {mode}") - + logger.debug(f"Created structured mask: {mask.sum()}/{num_neurons} neurons kept") return mask - + @staticmethod def create_unstructured_mask( scores: torch.Tensor, @@ -83,25 +84,25 @@ def create_unstructured_mask( ) -> torch.Tensor: """ Create unstructured mask (weight level) from importance scores. - + Args: scores: Importance scores (any shape) amount: Fraction to prune mode: 'low', 'high', or 'random' - + Returns: Binary mask same shape as scores """ num_weights = scores.numel() num_to_prune = int(num_weights * amount) num_to_keep = num_weights - num_to_prune - + if num_to_prune == 0: return torch.ones_like(scores, dtype=torch.bool) - + # Flatten for processing scores_flat = scores.flatten() - + if mode == 'low': _, indices = torch.topk(scores_flat, num_to_keep, largest=True) elif mode == 'high': @@ -110,17 +111,17 @@ def create_unstructured_mask( indices = torch.randperm(num_weights)[:num_to_keep] else: raise ValueError(f"Unknown mode: {mode}") - + # Create flat mask mask_flat = torch.zeros_like(scores_flat, dtype=torch.bool) mask_flat[indices] = True - + # Reshape to original shape mask = mask_flat.reshape(scores.shape) - + logger.debug(f"Created unstructured mask: {mask.sum()}/{num_weights} weights kept") return mask - + @staticmethod def expand_neuron_mask_to_weights( neuron_mask: torch.Tensor, @@ -129,18 +130,18 @@ def expand_neuron_mask_to_weights( ) -> torch.Tensor: """ Expand neuron/channel mask to full weight tensor. - + Args: neuron_mask: Binary mask [num_neurons] weight_shape: Target weight shape, e.g., [out, in] or [out, in, k, k] dim: Dimension corresponding to neurons (usually 0 for output neurons) - + Returns: Expanded mask matching weight_shape """ # Create base mask mask = torch.ones(weight_shape, dtype=torch.bool, device=neuron_mask.device) - + # Apply neuron mask along specified dimension if dim == 0: # Mask output neurons/channels @@ -152,7 +153,7 @@ def expand_neuron_mask_to_weights( mask[i, :, :, :] = False elif len(weight_shape) == 3: # Conv1d: [out, in, k] mask[i, :, :] = False - + elif dim == 1: # Mask input neurons/channels (less common) for i, keep in enumerate(neuron_mask): @@ -163,9 +164,9 @@ def expand_neuron_mask_to_weights( mask[:, i, :, :] = False elif len(weight_shape) == 3: mask[:, i, :] = False - + return mask - + @staticmethod def apply_mask_to_weights( weights: torch.Tensor, @@ -174,12 +175,12 @@ def apply_mask_to_weights( ) -> torch.Tensor: """ Apply mask to weights. - + Args: weights: Weight tensor mask: Binary mask (same shape as weights) mode: 'multiply' or 'zero' - + Returns: Masked weights """ @@ -187,7 +188,7 @@ def apply_mask_to_weights( raise ValueError( f"Mask shape {mask.shape} doesn't match weights shape {weights.shape}" ) - + if mode == 'multiply': return weights * mask.float() elif mode == 'zero': @@ -196,22 +197,22 @@ def apply_mask_to_weights( return masked_weights else: raise ValueError(f"Unknown mode: {mode}") - + @staticmethod def get_mask_statistics(mask: torch.Tensor) -> dict: """ Compute statistics about a mask. - + Args: mask: Binary mask - + Returns: Dictionary with statistics """ total = mask.numel() kept = mask.sum().item() pruned = total - kept - + return { 'total_elements': total, 'kept_elements': kept, @@ -219,7 +220,7 @@ def get_mask_statistics(mask: torch.Tensor) -> dict: 'sparsity': pruned / total if total > 0 else 0.0, 'density': kept / total if total > 0 else 0.0 } - + @staticmethod def combine_masks( masks: list, @@ -227,32 +228,32 @@ def combine_masks( ) -> torch.Tensor: """ Combine multiple masks. - + Args: masks: List of binary masks (same shape) operation: 'and' (intersection) or 'or' (union) - + Returns: Combined mask """ if not masks: raise ValueError("No masks provided") - + result = masks[0].clone() - + for mask in masks[1:]: if mask.shape != result.shape: raise ValueError("All masks must have the same shape") - + if operation == 'and': result = result & mask elif operation == 'or': result = result | mask else: raise ValueError(f"Unknown operation: {operation}") - + return result - + @staticmethod def global_threshold_mask( layer_scores: dict, @@ -261,29 +262,29 @@ def global_threshold_mask( ) -> dict: """ Create masks using a global threshold across all layers. - + Args: layer_scores: Dict mapping layer names to score tensors global_amount: Global fraction to prune mode: Pruning mode - + Returns: Dict mapping layer names to masks """ # Concatenate all scores all_scores = [] layer_info = [] - + for layer_name, scores in layer_scores.items(): all_scores.append(scores.flatten()) layer_info.append((layer_name, scores.shape, len(scores.flatten()))) - + all_scores_cat = torch.cat(all_scores) - + # Compute global threshold num_total = len(all_scores_cat) num_to_keep = int(num_total * (1 - global_amount)) - + if mode == 'low': threshold = torch.topk(all_scores_cat, num_to_keep, largest=True)[0][-1] threshold_fn = lambda s: s >= threshold @@ -292,15 +293,15 @@ def global_threshold_mask( threshold_fn = lambda s: s <= threshold else: raise ValueError(f"Global thresholding not supported for mode: {mode}") - + # Create per-layer masks masks = {} for layer_name, shape, _ in layer_info: scores = layer_scores[layer_name] mask = threshold_fn(scores) masks[layer_name] = mask - + logger.info(f"Global thresholding: {num_to_keep}/{num_total} elements kept") - + return masks diff --git a/src/alignment/services/scoring.py b/src/alignment/services/scoring.py index 430fc5ce..30f89d0d 100644 --- a/src/alignment/services/scoring.py +++ b/src/alignment/services/scoring.py @@ -5,10 +5,11 @@ into composite neuron importance scores for pruning and analysis. """ -from typing import Dict, List, Optional, Tuple, Any -import torch import logging from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +import torch logger = logging.getLogger(__name__) @@ -27,16 +28,16 @@ class CompositeScores: class NodeScoringService: """ Service for computing composite neuron importance scores. - + Combines multiple metrics: - Rayleigh Quotient (RQ): Alignment with input covariance - Mutual Information (MI): Information about target - Redundancy: Overlap with other neurons - Synergy: Complementary information with others - + Composite score: Score = α·MI + β·Synergy - γ·Redundancy + δ·log(RQ) - + Example: >>> scorer = NodeScoringService(metrics={'rq': rq_metric, 'mi': mi_metric}) >>> scores = scorer.compute_composite_scores( @@ -46,7 +47,7 @@ class NodeScoringService: ... ) >>> print(scores.composite) # Final importance scores """ - + def __init__( self, metrics: Dict[str, Any], @@ -57,7 +58,7 @@ def __init__( ): """ Initialize node scoring service. - + Args: metrics: Dictionary of initialized metrics Expected keys: 'rq', 'mi', 'redundancy', 'synergy' @@ -71,7 +72,7 @@ def __init__( self.beta_synergy = beta_synergy self.gamma_redundancy = gamma_redundancy self.delta_rq = delta_rq - + # Normalize weights total = alpha_mi + beta_synergy + gamma_redundancy + delta_rq if total > 0: @@ -79,7 +80,7 @@ def __init__( self.beta_synergy /= total self.gamma_redundancy /= total self.delta_rq /= total - + def compute_composite_scores( self, inputs: torch.Tensor, @@ -93,7 +94,7 @@ def compute_composite_scores( ) -> CompositeScores: """ Compute composite importance scores for all neurons in a layer. - + Args: inputs: Input activations [batch, features] weights: Layer weights [num_neurons, features] @@ -103,15 +104,15 @@ def compute_composite_scores( include_synergy: Whether to compute synergy (requires targets) layer_name: Optional layer name for logging **metric_kwargs: Additional arguments for metrics - + Returns: CompositeScores with individual and composite scores """ num_neurons = weights.shape[0] device = weights.device - + scores = CompositeScores(layer_name=layer_name) - + # 1. Compute RQ (always available) if 'rq' in self.metrics: try: @@ -125,7 +126,7 @@ def compute_composite_scores( except Exception as e: logger.warning(f"Failed to compute RQ: {e}") scores.rq = torch.zeros(num_neurons, device=device) - + # 2. Compute MI (if targets available) if 'mi' in self.metrics and targets is not None: try: @@ -140,7 +141,7 @@ def compute_composite_scores( except Exception as e: logger.warning(f"Failed to compute MI: {e}") scores.mi = torch.zeros(num_neurons, device=device) - + # 3. Compute Redundancy (if requested and metric available) if include_redundancy and 'redundancy' in self.metrics: try: @@ -153,7 +154,7 @@ def compute_composite_scores( except Exception as e: logger.warning(f"Failed to compute redundancy: {e}") scores.redundancy = torch.zeros(num_neurons, device=device) - + # 4. Compute Synergy (if requested, targets available, and metric available) if include_synergy and targets is not None and 'synergy' in self.metrics: try: @@ -168,12 +169,12 @@ def compute_composite_scores( except Exception as e: logger.warning(f"Failed to compute synergy: {e}") scores.synergy = torch.zeros(num_neurons, device=device) - + # 5. Compute Composite Score scores.composite = self._compute_composite(scores, num_neurons, device) - + return scores - + def _compute_composite( self, scores: CompositeScores, @@ -182,64 +183,64 @@ def _compute_composite( ) -> torch.Tensor: """ Compute weighted combination of individual scores. - + Args: scores: Individual metric scores num_neurons: Number of neurons device: Target device - + Returns: Composite scores [num_neurons] """ composite = torch.zeros(num_neurons, device=device) - + # Add MI component if scores.mi is not None and self.alpha_mi > 0: # Normalize MI to [0, 1] range for combining mi_normalized = self._normalize_scores(scores.mi) composite += self.alpha_mi * mi_normalized - + # Add Synergy component (positive contribution) if scores.synergy is not None and self.beta_synergy > 0: synergy_normalized = self._normalize_scores(scores.synergy) composite += self.beta_synergy * synergy_normalized - + # Subtract Redundancy component (negative contribution) if scores.redundancy is not None and self.gamma_redundancy > 0: redundancy_normalized = self._normalize_scores(scores.redundancy) composite -= self.gamma_redundancy * redundancy_normalized - + # Add RQ component (log scale) if scores.rq is not None and self.delta_rq > 0: # Use log(RQ + eps) to handle scale rq_log = torch.log(scores.rq + 1e-8) rq_log_normalized = self._normalize_scores(rq_log) composite += self.delta_rq * rq_log_normalized - + return composite - + @staticmethod def _normalize_scores(scores: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: """ Normalize scores to [0, 1] range. - + Args: scores: Raw scores eps: Small value for numerical stability - + Returns: Normalized scores """ scores_min = scores.min() scores_max = scores.max() - + if (scores_max - scores_min) < eps: # All scores are the same return torch.ones_like(scores) * 0.5 - + normalized = (scores - scores_min) / (scores_max - scores_min + eps) return normalized - + def compute_layerwise_scores( self, activation_data, # ActivationData from capture service @@ -249,25 +250,25 @@ def compute_layerwise_scores( ) -> Dict[str, CompositeScores]: """ Compute composite scores for multiple layers. - + Args: activation_data: ActivationData with inputs/weights targets: Target labels layers: Layers to score (None = all) **kwargs: Additional arguments for scoring - + Returns: Dictionary mapping layer names to CompositeScores """ layers = layers or activation_data.layer_names - + layerwise_scores = {} - + for layer in layers: if layer not in activation_data.inputs or layer not in activation_data.weights: logger.warning(f"Skipping layer {layer}: missing inputs or weights") continue - + try: scores = self.compute_composite_scores( inputs=activation_data.inputs[layer], @@ -278,17 +279,17 @@ def compute_layerwise_scores( **kwargs ) layerwise_scores[layer] = scores - + logger.info( f"Layer {layer}: composite score range " f"[{scores.composite.min():.4f}, {scores.composite.max():.4f}]" ) - + except Exception as e: logger.error(f"Failed to score layer {layer}: {e}") - + return layerwise_scores - + def rank_neurons_globally( self, layerwise_scores: Dict[str, CompositeScores], @@ -296,28 +297,28 @@ def rank_neurons_globally( ) -> Tuple[List[Tuple[str, int, float]], Optional[Dict]]: """ Rank all neurons across all layers by composite score. - + Args: layerwise_scores: Scores for each layer return_indices: Whether to return dictionary of per-layer indices - + Returns: Tuple of (ranked_list, optional_indices_dict) ranked_list: List of (layer_name, neuron_idx, score) tuples, sorted indices_dict: Per-layer sorted indices (if requested) """ all_neurons = [] - + for layer_name, scores in layerwise_scores.items(): if scores.composite is None: continue - + for neuron_idx, score in enumerate(scores.composite): all_neurons.append((layer_name, neuron_idx, score.item())) - + # Sort by score (descending) all_neurons.sort(key=lambda x: x[2], reverse=True) - + # Create per-layer indices if requested indices_dict = None if return_indices: @@ -326,7 +327,7 @@ def rank_neurons_globally( if scores.composite is not None: sorted_indices = torch.argsort(scores.composite, descending=True) indices_dict[layer_name] = sorted_indices - + return all_neurons, indices_dict @@ -336,11 +337,11 @@ def create_scoring_service( ) -> NodeScoringService: """ Factory function for creating NodeScoringService. - + Args: metrics: Dictionary of initialized metrics **weights: Weights for composite score (alpha_mi, beta_synergy, etc.) - + Returns: Configured NodeScoringService """ diff --git a/src/alignment/training/__init__.py b/src/alignment/training/__init__.py index 548d3a9d..59ce38c9 100644 --- a/src/alignment/training/__init__.py +++ b/src/alignment/training/__init__.py @@ -3,8 +3,8 @@ """ from .base import BaseTrainer, TrainingConfig -from .multi_network import train_networks_fully_tensorized, TensorizedNetworkWrapper from .experiment_trainer import ExperimentTrainer, ExperimentTrainingConfig +from .multi_network import TensorizedNetworkWrapper, train_networks_fully_tensorized __all__ = [ 'BaseTrainer', @@ -13,4 +13,4 @@ 'TensorizedNetworkWrapper', 'ExperimentTrainer', 'ExperimentTrainingConfig', -] \ No newline at end of file +] diff --git a/src/alignment/training/base.py b/src/alignment/training/base.py index eb89372f..81b0f27a 100644 --- a/src/alignment/training/base.py +++ b/src/alignment/training/base.py @@ -2,14 +2,15 @@ Base training utilities for neural networks. """ -from typing import Dict, List, Optional, Callable, Any, Tuple, Union -import torch -import torch.nn as nn -import torch.optim as optim import logging -from pathlib import Path import time from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +import torch +import torch.nn as nn +import torch.optim as optim logger = logging.getLogger(__name__) @@ -35,11 +36,11 @@ class TrainingConfig: class BaseTrainer: """ Base trainer class for neural network training. - + This class provides standard training functionality that can be extended for specific training strategies. """ - + def __init__( self, model: nn.Module, @@ -49,7 +50,7 @@ def __init__( ): """ Initialize trainer. - + Args: model: Model to train config: Training configuration @@ -60,20 +61,20 @@ def __init__( self.config = config or TrainingConfig() self.loss_fn = loss_fn or nn.CrossEntropyLoss() self.callbacks = callbacks or [] - + self.device = torch.device(self.config.device) self.model.to(self.device) - + # Setup optimizer self.optimizer = self._create_optimizer() self.scheduler = self._create_scheduler() - + # Training state self.current_epoch = 0 self.global_step = 0 self.best_val_metric = float('inf') self.patience_counter = 0 - + # History self.history = { 'train_loss': [], @@ -83,7 +84,7 @@ def __init__( 'epoch_times': [], 'learning_rates': [] } - + def train( self, train_loader: torch.utils.data.DataLoader, @@ -92,45 +93,45 @@ def train( ) -> Dict[str, List[float]]: """ Train the model. - + Args: train_loader: Training data loader val_loader: Validation data loader metric_fn: Optional metric function - + Returns: Training history dictionary """ logger.info(f"Starting training for {self.config.epochs} epochs") - + for epoch in range(self.config.epochs): self.current_epoch = epoch epoch_start = time.time() - + # Training phase train_loss, train_metrics = self._train_epoch( train_loader, metric_fn ) - + # Validation phase val_loss, val_metrics = 0.0, {} if val_loader and (epoch + 1) % self.config.eval_interval == 0: val_loss, val_metrics = self._validate( val_loader, metric_fn ) - + # Early stopping check if self._should_stop_early(val_loss): logger.info(f"Early stopping triggered at epoch {epoch + 1}") break - + # Update scheduler if self.scheduler: if isinstance(self.scheduler, optim.lr_scheduler.ReduceLROnPlateau): self.scheduler.step(val_loss) else: self.scheduler.step() - + # Record history epoch_time = time.time() - epoch_start self.history['train_loss'].append(train_loss) @@ -141,22 +142,22 @@ def train( self.history['learning_rates'].append( self.optimizer.param_groups[0]['lr'] ) - + # Log progress self._log_epoch_progress( epoch, train_loss, train_metrics, val_loss, val_metrics, epoch_time ) - + # Save checkpoint if self.config.checkpoint_dir and (epoch + 1) % self.config.eval_interval == 0: self._save_checkpoint(epoch) - + # Run callbacks for callback in self.callbacks: callback(self, epoch) - + return self.history - + def _train_epoch( self, train_loader: torch.utils.data.DataLoader, @@ -166,47 +167,47 @@ def _train_epoch( self.model.train() total_loss = 0.0 all_metrics = [] - + for batch_idx, (inputs, targets) in enumerate(train_loader): inputs, targets = inputs.to(self.device), targets.to(self.device) - + # Forward pass self.optimizer.zero_grad() outputs = self.model(inputs) loss = self.loss_fn(outputs, targets) - + # Backward pass loss.backward() - + # Gradient clipping if self.config.gradient_clip_val: torch.nn.utils.clip_grad_norm_( self.model.parameters(), self.config.gradient_clip_val ) - + self.optimizer.step() - + # Track metrics total_loss += loss.item() if metric_fn: metrics = metric_fn(outputs, targets) all_metrics.append(metrics) - + # Logging if batch_idx % self.config.log_interval == 0: logger.debug( f"Epoch {self.current_epoch} [{batch_idx}/{len(train_loader)}] " f"Loss: {loss.item():.4f}" ) - + self.global_step += 1 - + avg_loss = total_loss / len(train_loader) avg_metrics = self._average_metrics(all_metrics) if all_metrics else {} - + return avg_loss, avg_metrics - + def _validate( self, val_loader: torch.utils.data.DataLoader, @@ -216,28 +217,28 @@ def _validate( self.model.eval() total_loss = 0.0 all_metrics = [] - + with torch.no_grad(): for inputs, targets in val_loader: inputs, targets = inputs.to(self.device), targets.to(self.device) - + outputs = self.model(inputs) loss = self.loss_fn(outputs, targets) - + total_loss += loss.item() if metric_fn: metrics = metric_fn(outputs, targets) all_metrics.append(metrics) - + avg_loss = total_loss / len(val_loader) avg_metrics = self._average_metrics(all_metrics) if all_metrics else {} - + return avg_loss, avg_metrics - + def _create_optimizer(self) -> optim.Optimizer: """Create optimizer based on config.""" optimizer_kwargs = self.config.optimizer_kwargs or {} - + if self.config.optimizer.lower() == "adam": return optim.Adam( self.model.parameters(), @@ -258,14 +259,14 @@ def _create_optimizer(self) -> optim.Optimizer: ) else: raise ValueError(f"Unknown optimizer: {self.config.optimizer}") - + def _create_scheduler(self) -> Optional[optim.lr_scheduler._LRScheduler]: """Create learning rate scheduler based on config.""" if not self.config.scheduler: return None - + scheduler_kwargs = self.config.scheduler_kwargs or {} - + if self.config.scheduler.lower() == "cosine": return optim.lr_scheduler.CosineAnnealingLR( self.optimizer, @@ -284,32 +285,32 @@ def _create_scheduler(self) -> Optional[optim.lr_scheduler._LRScheduler]: ) else: raise ValueError(f"Unknown scheduler: {self.config.scheduler}") - + def _should_stop_early(self, val_loss: float) -> bool: """Check if early stopping should be triggered.""" if not self.config.early_stopping_patience: return False - + if val_loss < self.best_val_metric: self.best_val_metric = val_loss self.patience_counter = 0 else: self.patience_counter += 1 - + return self.patience_counter >= self.config.early_stopping_patience - + def _average_metrics(self, metrics_list: List[Dict[str, float]]) -> Dict[str, float]: """Average metrics across batches.""" if not metrics_list: return {} - + avg_metrics = {} for key in metrics_list[0].keys(): values = [m[key] for m in metrics_list] avg_metrics[key] = sum(values) / len(values) - + return avg_metrics - + def _log_epoch_progress( self, epoch: int, @@ -322,25 +323,25 @@ def _log_epoch_progress( """Log training progress for an epoch.""" log_msg = f"Epoch {epoch + 1}/{self.config.epochs} - " log_msg += f"Train Loss: {train_loss:.4f}" - + for key, value in train_metrics.items(): log_msg += f", Train {key}: {value:.4f}" - + if val_loss > 0: log_msg += f", Val Loss: {val_loss:.4f}" for key, value in val_metrics.items(): log_msg += f", Val {key}: {value:.4f}" - + log_msg += f", Time: {epoch_time:.2f}s" log_msg += f", LR: {self.optimizer.param_groups[0]['lr']:.6f}" - + logger.info(log_msg) - + def _save_checkpoint(self, epoch: int): """Save training checkpoint.""" checkpoint_dir = Path(self.config.checkpoint_dir) checkpoint_dir.mkdir(parents=True, exist_ok=True) - + checkpoint = { 'epoch': epoch, 'model_state_dict': self.model.state_dict(), @@ -350,7 +351,7 @@ def _save_checkpoint(self, epoch: int): 'config': self.config, 'best_val_metric': self.best_val_metric } - + checkpoint_path = checkpoint_dir / f"checkpoint_epoch_{epoch}.pt" torch.save(checkpoint, checkpoint_path) - logger.debug(f"Saved checkpoint to {checkpoint_path}") \ No newline at end of file + logger.debug(f"Saved checkpoint to {checkpoint_path}") diff --git a/src/alignment/training/callbacks/__init__.py b/src/alignment/training/callbacks/__init__.py index 5b82ec66..32106c2b 100644 --- a/src/alignment/training/callbacks/__init__.py +++ b/src/alignment/training/callbacks/__init__.py @@ -2,10 +2,7 @@ Training callbacks for the alignment framework. """ -from .alignment_callback import ( - AlignmentMetricsCallback, - create_alignment_callback -) +from .alignment_callback import AlignmentMetricsCallback, create_alignment_callback __all__ = [ 'AlignmentMetricsCallback', diff --git a/src/alignment/training/callbacks/alignment_callback.py b/src/alignment/training/callbacks/alignment_callback.py index 325a9c5a..cf0ca7f4 100644 --- a/src/alignment/training/callbacks/alignment_callback.py +++ b/src/alignment/training/callbacks/alignment_callback.py @@ -4,11 +4,11 @@ Enables computing alignment metrics during training with minimal overhead. """ -from typing import Dict, List, Optional, Any -import torch -import torch.nn as nn import logging from pathlib import Path +from typing import Any, Dict, List, Optional + +import torch logger = logging.getLogger(__name__) @@ -16,16 +16,16 @@ class AlignmentMetricsCallback: """ Lightweight callback to compute alignment metrics during training. - + Piggybacks on the training forward pass with no extra computation. Computes metrics every N steps using cached activations. - + Key features: - Zero extra forward passes - Minimal overhead (~5-10ms per frequency) - Automatic activation capture via HookManager - Supports any tracker (WandB, TensorBoard, etc.) - + Example: >>> from alignment.training.callbacks import AlignmentMetricsCallback >>> callback = AlignmentMetricsCallback( @@ -33,17 +33,17 @@ class AlignmentMetricsCallback: ... layers=['conv1', 'fc1'], ... frequency=100 ... ) - >>> + >>> >>> # In training loop: >>> for batch_idx, (inputs, targets) in enumerate(train_loader): ... outputs = model(inputs) ... loss.backward() ... optimizer.step() - ... + ... ... # Compute metrics (minimal overhead) ... callback.on_batch_end(wrapper, inputs, targets, global_step) """ - + def __init__( self, metrics: Dict[str, Any], @@ -56,7 +56,7 @@ def __init__( ): """ Initialize alignment metrics callback. - + Args: metrics: Dict of initialized metrics (e.g., {'rq': RayleighQuotient()}) layers: Layer names to track @@ -73,7 +73,7 @@ def __init__( self.aggregation = aggregation self.tracker = tracker self.save_history = save_history - + # Initialize history storage if save_history: self.history = { @@ -81,9 +81,9 @@ def __init__( for layer in layers } self.step_history = [] - + self.step = 0 - + def on_batch_end( self, model_wrapper, @@ -94,7 +94,7 @@ def on_batch_end( ): """ Compute metrics at the end of a training batch. - + Args: model_wrapper: Model wrapper with HookManager inputs: Input batch (same as used for forward) @@ -107,11 +107,11 @@ def on_batch_end( self.step = step else: self.step += 1 - + # Only compute at specified frequency if self.step % self.frequency != 0: return - + # Sample subset for efficiency (if large batch) if self.sample_size is not None and inputs.size(0) > self.sample_size: indices = torch.randperm(inputs.size(0))[:self.sample_size] @@ -120,7 +120,7 @@ def on_batch_end( else: inputs_sampled = inputs targets_sampled = targets - + # Capture activations (reuses existing forward, no extra pass) # Use no_grad to avoid interfering with training with torch.no_grad(): @@ -128,7 +128,7 @@ def on_batch_end( # Use HookManager for safe capture outputs, activations = model_wrapper.forward_with_activations(inputs_sampled) weights = model_wrapper.get_layer_weights(self.layers) - + # Compute metrics for each layer for layer in self.layers: self._compute_layer_metrics( @@ -138,10 +138,10 @@ def on_batch_end( targets_sampled, **kwargs ) - + except Exception as e: logger.warning(f"Failed to compute alignment metrics at step {self.step}: {e}") - + def _compute_layer_metrics( self, layer: str, @@ -155,11 +155,11 @@ def _compute_layer_metrics( layer_input = activations.get(f'{layer}_input') layer_output = activations.get(f'{layer}_output') layer_weights = weights.get(layer) - + if layer_weights is None: logger.warning(f"No weights found for layer {layer}") return - + # Compute each metric for metric_name, metric in self.metrics.items(): try: @@ -173,10 +173,10 @@ def _compute_layer_metrics( metric_kwargs['outputs'] = layer_output if targets is not None: metric_kwargs['targets'] = targets - + # Compute metric scores = metric.compute(**metric_kwargs, **kwargs) - + # Aggregate to scalar if self.aggregation == 'mean': value = scores.mean().item() @@ -186,14 +186,14 @@ def _compute_layer_metrics( value = {'mean': scores.mean().item(), 'std': scores.std().item()} else: value = scores.mean().item() - + # Store history if self.save_history: if self.aggregation == 'both': self.history[layer][metric_name].append(value) else: self.history[layer][metric_name].append(value) - + # Log to tracker if self.tracker: if self.aggregation == 'both': @@ -205,23 +205,23 @@ def _compute_layer_metrics( self.tracker.log({ f'{layer}/{metric_name}': value }, step=self.step) - + logger.debug(f"Step {self.step}, {layer}/{metric_name}: {value}") - + except Exception as e: logger.warning(f"Failed to compute {metric_name} for {layer}: {e}") - + def get_history(self) -> Dict: """Get complete metrics history.""" if not self.save_history: logger.warning("History not saved (save_history=False)") return {} - + return { 'history': self.history, 'steps': self.step_history if hasattr(self, 'step_history') else list(range(0, self.step + 1, self.frequency)) } - + def reset(self): """Reset history and step counter.""" self.step = 0 @@ -231,17 +231,17 @@ def reset(self): for layer in self.layers } self.step_history = [] - + def save_history(self, path: str): """Save history to file.""" if not self.save_history: logger.warning("No history to save") return - + import json path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - + # Convert to JSON-serializable format history_data = { 'history': self.history, @@ -250,10 +250,10 @@ def save_history(self, path: str): 'metrics': list(self.metrics.keys()), 'frequency': self.frequency } - + with open(path, 'w') as f: json.dump(history_data, f, indent=2) - + logger.info(f"Saved alignment history to {path}") @@ -264,12 +264,12 @@ def create_alignment_callback( ) -> AlignmentMetricsCallback: """ Factory function for creating alignment callbacks. - + Args: metrics: Dictionary of metrics layers: Layers to track **config: Additional configuration - + Returns: Configured AlignmentMetricsCallback """ diff --git a/src/alignment/training/experiment_trainer.py b/src/alignment/training/experiment_trainer.py index 52b15993..09e5ea25 100644 --- a/src/alignment/training/experiment_trainer.py +++ b/src/alignment/training/experiment_trainer.py @@ -5,14 +5,15 @@ alignment experiments, with support for multi-network training. """ -from typing import Dict, List, Optional, Callable, Any, Tuple, Union +import logging +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + import torch import torch.nn as nn import torch.optim as optim -import logging -from pathlib import Path -import time -from dataclasses import dataclass, field from .base import BaseTrainer, TrainingConfig @@ -31,13 +32,13 @@ class ExperimentTrainingConfig(TrainingConfig): class ExperimentTrainer(BaseTrainer): """ Trainer for alignment experiments with multi-network support. - + This trainer extends BaseTrainer with: - Support for training multiple networks simultaneously - Tensorized training for efficiency - Experiment-specific metrics and logging """ - + def __init__( self, model: Union[nn.Module, List[nn.Module]], @@ -47,7 +48,7 @@ def __init__( ): """ Initialize experiment trainer. - + Args: model: Single model or list of models for multi-network training config: Training configuration @@ -64,12 +65,12 @@ def __init__( self.models = [model] self.num_networks = 1 super().__init__(model, config, loss_fn, callbacks) - + # Override config if needed if config and hasattr(config, 'num_networks'): assert config.num_networks == self.num_networks, \ f"Config num_networks ({config.num_networks}) != actual ({self.num_networks})" - + # Setup optimizers for all models if self.num_networks > 1: self.optimizers = [self._create_optimizer_for_model(m) for m in self.models] @@ -77,11 +78,11 @@ def __init__( else: self.optimizers = [self.optimizer] self.schedulers = [self.scheduler] if self.scheduler else [None] - + # Move all models to device for model in self.models: model.to(self.device) - + def train( self, train_loader: torch.utils.data.DataLoader, @@ -90,12 +91,12 @@ def train( ) -> Dict[str, Any]: """ Train the model(s). - + Returns: Training history with support for multiple networks """ logger.info(f"Starting training for {self.config.epochs} epochs with {self.num_networks} network(s)") - + # Initialize per-network history if multiple networks if self.num_networks > 1: self.history['per_network'] = { @@ -107,11 +108,11 @@ def train( } for i in range(self.num_networks) } - + for epoch in range(self.config.epochs): self.current_epoch = epoch epoch_start = time.time() - + # Training phase if self.num_networks > 1 and self.config.tensorized and self.num_networks <= 8: # Use tensorized training for efficiency @@ -123,11 +124,11 @@ def train( train_losses, train_metrics = self._train_epoch_multi( train_loader, metric_fn ) - + # Aggregate metrics avg_train_loss = self._aggregate_metric(train_losses) avg_train_metrics = self._aggregate_metrics_dict(train_metrics) - + # Validation phase val_losses, val_metrics = [0.0] * self.num_networks, [{} for _ in range(self.num_networks)] if val_loader and (epoch + 1) % self.config.eval_interval == 0: @@ -139,13 +140,13 @@ def train( val_losses, val_metrics = self._validate_multi( val_loader, metric_fn ) - + # Early stopping check on average validation loss avg_val_loss = self._aggregate_metric(val_losses) if self._should_stop_early(avg_val_loss): logger.info(f"Early stopping triggered at epoch {epoch + 1}") break - + # Update schedulers for i, scheduler in enumerate(self.schedulers): if scheduler: @@ -153,12 +154,12 @@ def train( scheduler.step(val_losses[i] if val_losses[i] > 0 else train_losses[i]) else: scheduler.step() - + # Record history epoch_time = time.time() - epoch_start avg_val_loss = self._aggregate_metric(val_losses) avg_val_metrics = self._aggregate_metrics_dict(val_metrics) - + self.history['train_loss'].append(avg_train_loss) self.history['train_metrics'].append(avg_train_metrics) self.history['val_loss'].append(avg_val_loss) @@ -167,7 +168,7 @@ def train( self.history['learning_rates'].append( self.optimizers[0].param_groups[0]['lr'] ) - + # Record per-network history if self.num_networks > 1: for i in range(self.num_networks): @@ -175,23 +176,23 @@ def train( self.history['per_network'][i]['train_metrics'].append(train_metrics[i]) self.history['per_network'][i]['val_loss'].append(val_losses[i]) self.history['per_network'][i]['val_metrics'].append(val_metrics[i]) - + # Log progress self._log_epoch_progress( - epoch, avg_train_loss, avg_train_metrics, + epoch, avg_train_loss, avg_train_metrics, avg_val_loss, avg_val_metrics, epoch_time ) - + # Save checkpoint if self.config.checkpoint_dir and (epoch + 1) % self.config.eval_interval == 0: self._save_checkpoint_multi(epoch) - + # Run callbacks for callback in self.callbacks: callback(self, epoch) - + return self.history - + def _train_epoch_multi( self, train_loader: torch.utils.data.DataLoader, @@ -200,41 +201,41 @@ def _train_epoch_multi( """Train multiple networks for one epoch.""" all_losses = [] all_metrics = [] - + for i, (model, optimizer) in enumerate(zip(self.models, self.optimizers)): model.train() total_loss = 0.0 metrics_list = [] - + for batch_idx, (inputs, targets) in enumerate(train_loader): inputs, targets = inputs.to(self.device), targets.to(self.device) - + optimizer.zero_grad() outputs = model(inputs) loss = self.loss_fn(outputs, targets) loss.backward() - + if self.config.gradient_clip_val: torch.nn.utils.clip_grad_norm_( model.parameters(), self.config.gradient_clip_val ) - + optimizer.step() - + total_loss += loss.item() if metric_fn: metrics = metric_fn(outputs, targets) metrics_list.append(metrics) - + avg_loss = total_loss / len(train_loader) avg_metrics = self._average_metrics(metrics_list) if metrics_list else {} - + all_losses.append(avg_loss) all_metrics.append(avg_metrics) - + return all_losses, all_metrics - + def _train_epoch_tensorized( self, train_loader: torch.utils.data.DataLoader, @@ -244,43 +245,43 @@ def _train_epoch_tensorized( # Set all models to train mode for model in self.models: model.train() - + total_losses = [0.0] * self.num_networks all_metrics = [[] for _ in range(self.num_networks)] - + for batch_idx, (inputs, targets) in enumerate(train_loader): inputs = inputs.to(self.device) targets = targets.to(self.device) - + # Process each network separately (simpler and more robust) for i, (model, optimizer) in enumerate(zip(self.models, self.optimizers)): optimizer.zero_grad() outputs = model(inputs) loss = self.loss_fn(outputs, targets) loss.backward() - + if self.config.gradient_clip_val: torch.nn.utils.clip_grad_norm_( model.parameters(), self.config.gradient_clip_val ) - + optimizer.step() - + total_losses[i] += loss.item() if metric_fn: metrics = metric_fn(outputs, targets) all_metrics[i].append(metrics) - + # Average losses and metrics avg_losses = [total / len(train_loader) for total in total_losses] avg_metrics = [ self._average_metrics(metrics) if metrics else {} for metrics in all_metrics ] - + return avg_losses, avg_metrics - + def _validate_multi( self, val_loader: torch.utils.data.DataLoader, @@ -289,32 +290,32 @@ def _validate_multi( """Validate multiple networks.""" all_losses = [] all_metrics = [] - + for model in self.models: model.eval() total_loss = 0.0 metrics_list = [] - + with torch.no_grad(): for inputs, targets in val_loader: inputs, targets = inputs.to(self.device), targets.to(self.device) - + outputs = model(inputs) loss = self.loss_fn(outputs, targets) - + total_loss += loss.item() if metric_fn: metrics = metric_fn(outputs, targets) metrics_list.append(metrics) - + avg_loss = total_loss / len(val_loader) avg_metrics = self._average_metrics(metrics_list) if metrics_list else {} - + all_losses.append(avg_loss) all_metrics.append(avg_metrics) - + return all_losses, all_metrics - + def _validate_tensorized( self, val_loader: torch.utils.data.DataLoader, @@ -323,34 +324,34 @@ def _validate_tensorized( """Validate multiple networks using tensorized operations.""" for model in self.models: model.eval() - + total_losses = [0.0] * self.num_networks all_metrics = [[] for _ in range(self.num_networks)] - + with torch.no_grad(): for inputs, targets in val_loader: inputs = inputs.to(self.device) targets = targets.to(self.device) - batch_size = inputs.shape[0] - + inputs.shape[0] + # Process each network for i, model in enumerate(self.models): outputs = model(inputs) loss = self.loss_fn(outputs, targets) - + total_losses[i] += loss.item() if metric_fn: metrics = metric_fn(outputs, targets) all_metrics[i].append(metrics) - + avg_losses = [total / len(val_loader) for total in total_losses] avg_metrics = [ self._average_metrics(metrics) if metrics else {} for metrics in all_metrics ] - + return avg_losses, avg_metrics - + def _aggregate_metric(self, values: List[float]) -> float: """Aggregate a metric across networks.""" if self.config.metric_aggregation == "mean": @@ -361,26 +362,26 @@ def _aggregate_metric(self, values: List[float]) -> float: return max(values) else: raise ValueError(f"Unknown aggregation: {self.config.metric_aggregation}") - + def _aggregate_metrics_dict( - self, + self, metrics_list: List[Dict[str, float]] ) -> Dict[str, float]: """Aggregate dictionary of metrics across networks.""" if not metrics_list or not metrics_list[0]: return {} - + aggregated = {} for key in metrics_list[0].keys(): values = [m[key] for m in metrics_list] aggregated[key] = self._aggregate_metric(values) - + return aggregated - + def _create_optimizer_for_model(self, model: nn.Module) -> optim.Optimizer: """Create optimizer for a specific model.""" optimizer_kwargs = self.config.optimizer_kwargs or {} - + if self.config.optimizer.lower() == "adam": return optim.Adam( model.parameters(), @@ -401,17 +402,17 @@ def _create_optimizer_for_model(self, model: nn.Module) -> optim.Optimizer: ) else: raise ValueError(f"Unknown optimizer: {self.config.optimizer}") - + def _create_scheduler_for_optimizer( - self, + self, optimizer: optim.Optimizer ) -> Optional[optim.lr_scheduler._LRScheduler]: """Create scheduler for a specific optimizer.""" if not self.config.scheduler: return None - + scheduler_kwargs = self.config.scheduler_kwargs or {} - + if self.config.scheduler.lower() == "cosine": return optim.lr_scheduler.CosineAnnealingLR( optimizer, @@ -430,12 +431,12 @@ def _create_scheduler_for_optimizer( ) else: raise ValueError(f"Unknown scheduler: {self.config.scheduler}") - + def _save_checkpoint_multi(self, epoch: int): """Save checkpoint for multiple networks.""" checkpoint_dir = Path(self.config.checkpoint_dir) checkpoint_dir.mkdir(parents=True, exist_ok=True) - + if self.config.save_all_networks and self.num_networks > 1: # Save each network separately for i, (model, optimizer, scheduler) in enumerate( @@ -450,7 +451,7 @@ def _save_checkpoint_multi(self, epoch: int): 'history': self.history['per_network'][i], 'config': self.config, } - + checkpoint_path = checkpoint_dir / f"checkpoint_network_{i}_epoch_{epoch}.pt" torch.save(checkpoint, checkpoint_path) else: @@ -465,8 +466,8 @@ def _save_checkpoint_multi(self, epoch: int): 'best_val_metric': self.best_val_metric, 'num_networks': self.num_networks } - + checkpoint_path = checkpoint_dir / f"checkpoint_epoch_{epoch}.pt" torch.save(checkpoint, checkpoint_path) - - logger.debug(f"Saved checkpoint(s) for epoch {epoch}") \ No newline at end of file + + logger.debug(f"Saved checkpoint(s) for epoch {epoch}") diff --git a/src/alignment/training/multi_network.py b/src/alignment/training/multi_network.py index f73b0893..12cf55e5 100644 --- a/src/alignment/training/multi_network.py +++ b/src/alignment/training/multi_network.py @@ -5,13 +5,14 @@ by batching their computations together. """ -from typing import List, Dict, Optional, Callable, Any, Tuple +import logging +import time +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + import torch import torch.nn as nn import torch.optim as optim -import logging -from pathlib import Path -import time logger = logging.getLogger(__name__) @@ -32,10 +33,10 @@ def train_networks_fully_tensorized( ) -> Tuple[List[nn.Module], Dict[str, List[float]]]: """ Train multiple networks simultaneously using tensorized operations. - + This function efficiently trains multiple networks with the same architecture by batching their forward/backward passes together. - + Args: networks: List of networks to train (must have same architecture) train_loader: Training data loader @@ -49,13 +50,13 @@ def train_networks_fully_tensorized( log_interval: Log progress every N batches eval_interval: Evaluate every N epochs callbacks: List of callback functions - + Returns: Tuple of (trained networks, training history) """ if not networks: raise ValueError("No networks provided") - + if len(networks) == 1: logger.warning("Only one network provided, falling back to standard training") return _train_single_network( @@ -63,27 +64,27 @@ def train_networks_fully_tensorized( optimizer_kwargs, loss_fn, device, checkpoint_dir, log_interval, eval_interval, callbacks ) - + # Verify all networks have the same architecture if not _verify_same_architecture(networks): raise ValueError("All networks must have the same architecture for tensorized training") - + # Setup num_networks = len(networks) device = torch.device(device) loss_fn = loss_fn or nn.CrossEntropyLoss() optimizer_kwargs = optimizer_kwargs or {} - + # Move networks to device for net in networks: net.to(device) - + # Create tensorized network wrapper tensorized_net = TensorizedNetworkWrapper(networks) - + # Create optimizer for tensorized parameters optimizer = optimizer_class(tensorized_net.parameters(), **optimizer_kwargs) - + # Training history history = { 'train_loss': [], @@ -92,26 +93,26 @@ def train_networks_fully_tensorized( 'val_acc': [], 'epoch_times': [] } - + # Training loop logger.info(f"Starting tensorized training of {num_networks} networks for {epochs} epochs") - + for epoch in range(epochs): epoch_start = time.time() - + # Training phase train_loss, train_acc = _tensorized_train_epoch( - tensorized_net, train_loader, optimizer, loss_fn, device, + tensorized_net, train_loader, optimizer, loss_fn, device, epoch, log_interval, callbacks ) - + # Validation phase val_loss, val_acc = 0.0, 0.0 if val_loader and (epoch + 1) % eval_interval == 0: val_loss, val_acc = _tensorized_evaluate( tensorized_net, val_loader, loss_fn, device ) - + # Record history epoch_time = time.time() - epoch_start history['train_loss'].append(train_loss) @@ -119,7 +120,7 @@ def train_networks_fully_tensorized( history['val_loss'].append(val_loss) history['val_acc'].append(val_acc) history['epoch_times'].append(epoch_time) - + # Log progress logger.info( f"Epoch {epoch+1}/{epochs} - " @@ -127,54 +128,54 @@ def train_networks_fully_tensorized( f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.2f}%, " f"Time: {epoch_time:.2f}s" ) - + # Save checkpoint if checkpoint_dir and (epoch + 1) % eval_interval == 0: _save_tensorized_checkpoint( tensorized_net, optimizer, epoch, history, checkpoint_dir ) - + # Extract individual networks trained_networks = tensorized_net.extract_networks() - + return trained_networks, history class TensorizedNetworkWrapper(nn.Module): """ Wrapper that combines multiple networks for tensorized training. - + This wrapper manages multiple networks and runs them in parallel by calling each network's forward method individually. """ - + def __init__(self, networks: List[nn.Module]): """ Initialize tensorized wrapper. - + Args: networks: List of networks with same architecture """ super().__init__() self.networks = nn.ModuleList(networks) self.num_networks = len(networks) - + def forward(self, x: torch.Tensor) -> torch.Tensor: """ Forward pass through all networks. - + Args: x: Input tensor [batch_size, ...] - + Returns: Output tensor [num_networks, batch_size, ...] """ outputs = [] for network in self.networks: outputs.append(network(x)) - + return torch.stack(outputs, dim=0) - + def extract_networks(self) -> List[nn.Module]: """Extract individual networks (they are already separate).""" return list(self.networks) @@ -184,19 +185,19 @@ def _verify_same_architecture(networks: List[nn.Module]) -> bool: """Verify all networks have the same architecture.""" if len(networks) < 2: return True - + base_arch = str(networks[0]) for net in networks[1:]: if str(net) != base_arch: return False - + # Also check parameter shapes base_params = {name: param.shape for name, param in networks[0].named_parameters()} for net in networks[1:]: net_params = {name: param.shape for name, param in net.named_parameters()} if net_params != base_params: return False - + return True @@ -215,54 +216,54 @@ def _tensorized_train_epoch( total_loss = 0.0 correct = 0 total = 0 - + for batch_idx, (inputs, targets) in enumerate(train_loader): inputs, targets = inputs.to(device), targets.to(device) - + # Expand targets for all networks - targets_expanded = targets.unsqueeze(0).expand(model.num_networks, -1) - + targets.unsqueeze(0).expand(model.num_networks, -1) + # Forward pass optimizer.zero_grad() outputs = model(inputs) # [num_networks, batch_size, num_classes] - + # Compute loss for each network losses = [] for net_idx in range(model.num_networks): loss = loss_fn(outputs[net_idx], targets) losses.append(loss) - + # Average loss across networks loss = torch.stack(losses).mean() - + # Backward pass loss.backward() optimizer.step() - + # Statistics total_loss += loss.item() - + # Compute accuracy (average across networks) for net_idx in range(model.num_networks): _, predicted = outputs[net_idx].max(1) correct += predicted.eq(targets).sum().item() total += targets.size(0) * model.num_networks - + # Logging if batch_idx % log_interval == 0: logger.debug( f"Epoch {epoch} [{batch_idx}/{len(train_loader)}] " f"Loss: {loss.item():.4f}" ) - + # Callbacks if callbacks: for callback in callbacks: callback(model, epoch, batch_idx) - + avg_loss = total_loss / len(train_loader) accuracy = 100.0 * correct / total - + return avg_loss, accuracy @@ -277,27 +278,27 @@ def _tensorized_evaluate( total_loss = 0.0 correct = 0 total = 0 - + with torch.no_grad(): for inputs, targets in val_loader: inputs, targets = inputs.to(device), targets.to(device) - + # Forward pass outputs = model(inputs) - + # Compute loss and accuracy for each network for net_idx in range(model.num_networks): loss = loss_fn(outputs[net_idx], targets) total_loss += loss.item() - + _, predicted = outputs[net_idx].max(1) correct += predicted.eq(targets).sum().item() - + total += targets.size(0) * model.num_networks - + avg_loss = total_loss / (len(val_loader) * model.num_networks) accuracy = 100.0 * correct / total - + return avg_loss, accuracy @@ -318,11 +319,11 @@ def _train_single_network( """Fallback to standard single network training.""" device = torch.device(device) network = network.to(device) - + loss_fn = loss_fn or nn.CrossEntropyLoss() optimizer_kwargs = optimizer_kwargs or {} optimizer = optimizer_class(network.parameters(), **optimizer_kwargs) - + history = { 'train_loss': [], 'train_acc': [], @@ -330,38 +331,38 @@ def _train_single_network( 'val_acc': [], 'epoch_times': [] } - + logger.info("Using standard single network training") - + for epoch in range(epochs): epoch_start = time.time() - + # Training phase network.train() total_loss = 0.0 correct = 0 total = 0 - + for batch_idx, (inputs, targets) in enumerate(train_loader): inputs, targets = inputs.to(device), targets.to(device) - + optimizer.zero_grad() outputs = network(inputs) loss = loss_fn(outputs, targets) loss.backward() optimizer.step() - + total_loss += loss.item() _, predicted = outputs.max(1) correct += predicted.eq(targets).sum().item() total += targets.size(0) - + if batch_idx % log_interval == 0: logger.debug(f"Epoch {epoch} [{batch_idx}/{len(train_loader)}] Loss: {loss.item():.4f}") - + avg_train_loss = total_loss / len(train_loader) train_acc = 100.0 * correct / total - + # Validation phase val_loss, val_acc = 0.0, 0.0 if val_loader and (epoch + 1) % eval_interval == 0: @@ -369,21 +370,21 @@ def _train_single_network( total_loss = 0.0 correct = 0 total = 0 - + with torch.no_grad(): for inputs, targets in val_loader: inputs, targets = inputs.to(device), targets.to(device) outputs = network(inputs) loss = loss_fn(outputs, targets) - + total_loss += loss.item() _, predicted = outputs.max(1) correct += predicted.eq(targets).sum().item() total += targets.size(0) - + val_loss = total_loss / len(val_loader) val_acc = 100.0 * correct / total - + # Record history epoch_time = time.time() - epoch_start history['train_loss'].append(avg_train_loss) @@ -391,7 +392,7 @@ def _train_single_network( history['val_loss'].append(val_loss) history['val_acc'].append(val_acc) history['epoch_times'].append(epoch_time) - + # Log progress logger.info( f"Epoch {epoch+1}/{epochs} - " @@ -399,7 +400,7 @@ def _train_single_network( f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.2f}%, " f"Time: {epoch_time:.2f}s" ) - + # Save checkpoint if checkpoint_dir and (epoch + 1) % eval_interval == 0: checkpoint_dir = Path(checkpoint_dir) @@ -412,12 +413,12 @@ def _train_single_network( } checkpoint_path = checkpoint_dir / f"single_epoch_{epoch}.pt" torch.save(checkpoint, checkpoint_path) - + # Callbacks if callbacks: for callback in callbacks: callback(network, epoch, history) - + return [network], history @@ -431,7 +432,7 @@ def _save_tensorized_checkpoint( """Save checkpoint for tensorized training.""" checkpoint_dir = Path(checkpoint_dir) checkpoint_dir.mkdir(parents=True, exist_ok=True) - + checkpoint = { 'epoch': epoch, 'model_state_dict': model.state_dict(), @@ -439,7 +440,7 @@ def _save_tensorized_checkpoint( 'history': history, 'num_networks': model.num_networks } - + checkpoint_path = checkpoint_dir / f"tensorized_epoch_{epoch}.pt" torch.save(checkpoint, checkpoint_path) - logger.debug(f"Saved checkpoint to {checkpoint_path}") \ No newline at end of file + logger.debug(f"Saved checkpoint to {checkpoint_path}") From 9401051632c5356d272d1658488557ce86c26883 Mon Sep 17 00:00:00 2001 From: Houman Safaai Date: Mon, 6 Oct 2025 12:50:01 -0400 Subject: [PATCH 12/18] Fix undefined names and comparison issues --- examples/08_llama_ffn_pruning.py | 1 + src/alignment/analysis/unified_reporter.py | 3 +-- src/alignment/external/BROJA_2PID/BROJA_2PID.py | 6 +++--- src/alignment/metrics/gradient_based.py | 2 +- src/alignment/pruning/parallel_optimizer.py | 2 +- src/alignment/services/mask_ops.py | 6 ++++-- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/08_llama_ffn_pruning.py b/examples/08_llama_ffn_pruning.py index 91ed3055..05ae45e2 100644 --- a/examples/08_llama_ffn_pruning.py +++ b/examples/08_llama_ffn_pruning.py @@ -16,6 +16,7 @@ Forward: down_proj(SiLU(gate_proj(x)) * up_proj(x)) """ +from typing import List import torch import torch.nn as nn diff --git a/src/alignment/analysis/unified_reporter.py b/src/alignment/analysis/unified_reporter.py index 3a6e0769..2ca9a480 100644 --- a/src/alignment/analysis/unified_reporter.py +++ b/src/alignment/analysis/unified_reporter.py @@ -245,8 +245,7 @@ def _generate_html_header(self, include_toc: bool) -> str:

{self.title}