Spaces:
Running
Running
| """Feature selection strategies for automated SAE analysis reports.""" | |
| from __future__ import annotations | |
| from abc import ABC, abstractmethod | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Tuple | |
| import numpy as np | |
| import pandas as pd | |
| from analysis.features.feature_indexing import FeatureMatrix | |
| from analysis.metrics.correlations import compute_distortion_correlations | |
| from analysis.metrics.mutual_information import compute_distortion_mutual_information | |
| from analysis.metrics.paired_deltas import build_paired_delta_tables | |
| from analysis.metrics.roc_auc import compute_distortion_roc_auc | |
| from log_config import get_logger | |
| logger = get_logger(__name__) | |
| Category = Literal['type', 'group'] | |
| DeltaMode = Literal['abs', 'signed', 'relative'] | |
| Level = Literal['patch', 'image'] | |
| def _metric_table_key(metric: str, category: Category) -> str: | |
| return f'{metric}_{category}_df' | |
| def _paired_delta_table_key(delta_mode: DeltaMode, category: Category) -> str: | |
| return f'paired_{delta_mode}_dist_{category}_df' | |
| def _group_col(category: Category) -> str: | |
| return 'dist_group' if category == 'group' else 'dist_type' | |
| class SelectorResult: | |
| """Output bundle produced by selector execution.""" | |
| selected_features: pd.DataFrame | |
| metric_tables: Dict[str, pd.DataFrame] | |
| selection_stats: Dict[str, object] | |
| selector_name: str | |
| selector_description: str | |
| relation_label: str | |
| class SelectorInputs: | |
| """Typed selector inputs with required core data and optional pristine data.""" | |
| meta_df: pd.DataFrame | |
| features: FeatureMatrix | |
| dataset: str | |
| selector_top_k: int = 20 | |
| selector_params: Mapping[str, object] = field(default_factory=dict) | |
| cache_paths: Any | None = None | |
| pristine_meta_df: pd.DataFrame | None = None | |
| pristine_features: FeatureMatrix | None = None | |
| feature_weight_norms: Mapping[int, float] | None = None | |
| def _match_metric_df_columns(corr_df: pd.DataFrame, feature_ids: Sequence[int]) -> List[object]: | |
| id_set = {int(x) for x in feature_ids} | |
| matched_columns: List[object] = [] | |
| for col in corr_df.columns: | |
| try: | |
| col_id = int(col) | |
| except (TypeError, ValueError): | |
| continue | |
| if col_id in id_set: | |
| matched_columns.append(col) | |
| return matched_columns | |
| def _build_feature_importance_table( | |
| importance_series: pd.Series, | |
| top_k: int, | |
| metric_name: str, | |
| ) -> pd.DataFrame: | |
| top_series = importance_series.nlargest(top_k) | |
| result = pd.DataFrame({ | |
| 'feature_id': [int(fid) for fid in top_series.index.tolist()], | |
| 'importance_score': [float(x) for x in top_series.values.tolist()], | |
| }) | |
| result['importance_rank'] = range(1, len(result) + 1) | |
| result['importance_metric'] = metric_name | |
| return result[['feature_id', 'importance_score', 'importance_rank', 'importance_metric']] | |
| def _collect_table_stats( | |
| stats: Dict[str, object], | |
| table_key: str, | |
| table_df: pd.DataFrame, | |
| feature_ids: Sequence[int], | |
| ) -> None: | |
| matched_cols = _match_metric_df_columns(table_df, feature_ids) | |
| if not matched_cols: | |
| return | |
| table_values = table_df.loc[:, matched_cols] | |
| stats[f'{table_key}_matched_cols'] = len(matched_cols) | |
| stats[f'{table_key}_mean_abs'] = float(table_values.abs().values.mean()) | |
| stats[f'{table_key}_max_abs'] = float(table_values.abs().values.max()) | |
| class BaseFeatureSelector(ABC): | |
| """Common interface for feature selectors.""" | |
| name: str | |
| description: str | |
| relation_label: str = 'feature relevance' | |
| def compute_metric_tables( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Dict[str, pd.DataFrame]: | |
| """Compute metric tables required by the selector.""" | |
| def compute_importance( | |
| self, | |
| metric_tables: Dict[str, pd.DataFrame], | |
| inputs: SelectorInputs | None = None, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Tuple[pd.Series, str]: | |
| """Return per-feature importance scores and metric name.""" | |
| def execute( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> SelectorResult: | |
| """Run full selector pipeline and return selected features + computed measures.""" | |
| metric_tables = self.compute_metric_tables(inputs, feature_ids=feature_ids) | |
| importance_series, metric_name = self.compute_importance( | |
| metric_tables=metric_tables, | |
| inputs=inputs, | |
| feature_ids=feature_ids, | |
| ) | |
| top_k = int(inputs.selector_top_k) | |
| selected_features = _build_feature_importance_table( | |
| importance_series=importance_series, | |
| top_k=top_k, | |
| metric_name=metric_name, | |
| ) | |
| selected_features = selected_features.copy() | |
| selected_features['feature_id'] = selected_features['feature_id'].astype(int) | |
| selected_features['importance_score'] = selected_features['importance_score'].astype(float) | |
| selected_features['importance_rank'] = selected_features['importance_rank'].astype(int) | |
| selected_features = selected_features.drop_duplicates( | |
| subset=['feature_id'], keep='first').reset_index(drop=True) | |
| if selected_features.empty: | |
| raise ValueError('Selector returned empty feature list') | |
| selected_features['importance_rank'] = range(1, len(selected_features) + 1) | |
| stats = self.selection_stats(selected_features, metric_tables) | |
| return SelectorResult( | |
| selected_features=selected_features, | |
| metric_tables=metric_tables, | |
| selection_stats=stats, | |
| selector_name=self.name, | |
| selector_description=self.description, | |
| relation_label=self.relation_label, | |
| ) | |
| def select_features( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> pd.DataFrame: | |
| """Template method: normalize and return top-k with importance stats.""" | |
| metric_tables = self.compute_metric_tables(inputs, feature_ids=feature_ids) | |
| importance_series, metric_name = self.compute_importance( | |
| metric_tables=metric_tables, | |
| inputs=inputs, | |
| feature_ids=feature_ids, | |
| ) | |
| top_k = int(inputs.selector_top_k) | |
| selected_features = _build_feature_importance_table( | |
| importance_series=importance_series, | |
| top_k=top_k, | |
| metric_name=metric_name, | |
| ) | |
| selected_features = selected_features.copy() | |
| selected_features['feature_id'] = selected_features['feature_id'].astype(int) | |
| selected_features['importance_score'] = selected_features['importance_score'].astype(float) | |
| selected_features['importance_rank'] = selected_features['importance_rank'].astype(int) | |
| selected_features = selected_features.drop_duplicates( | |
| subset=['feature_id'], keep='first').reset_index(drop=True) | |
| if selected_features.empty: | |
| raise ValueError('Selector returned empty feature list') | |
| selected_features['importance_rank'] = range(1, len(selected_features) + 1) | |
| return selected_features | |
| def selection_stats( | |
| self, | |
| selected_features: pd.DataFrame, | |
| metric_tables: Mapping[str, pd.DataFrame], | |
| ) -> Dict[str, object]: | |
| feature_ids = selected_features['feature_id'].tolist() | |
| stats: Dict[str, object] = { | |
| 'selector_name': self.name, | |
| 'selector_description': self.description, | |
| 'n_selected': len(feature_ids), | |
| } | |
| if not selected_features.empty: | |
| stats['mean_importance_score'] = float(selected_features['importance_score'].mean()) | |
| stats['max_importance_score'] = float(selected_features['importance_score'].max()) | |
| stats['min_importance_score'] = float(selected_features['importance_score'].min()) | |
| if feature_ids: | |
| numeric_ids = [int(x) for x in feature_ids] | |
| stats['min_feature_id'] = min(numeric_ids) | |
| stats['max_feature_id'] = max(numeric_ids) | |
| for corr_key in ('corr_type_df', 'corr_group_df'): | |
| corr_df = metric_tables.get(corr_key) | |
| if isinstance(corr_df, pd.DataFrame) and len(feature_ids) > 0: | |
| matched_cols = _match_metric_df_columns(corr_df, feature_ids) | |
| if matched_cols: | |
| abs_corr = corr_df.loc[:, matched_cols].abs() | |
| stats[f'{corr_key}_matched_cols'] = len(matched_cols) | |
| stats[f'{corr_key}_mean_abs_corr'] = float(abs_corr.values.mean()) | |
| stats[f'{corr_key}_max_abs_corr'] = float(abs_corr.values.max()) | |
| for mi_key in ('mi_type_df', 'mi_group_df'): | |
| mi_df = metric_tables.get(mi_key) | |
| if isinstance(mi_df, pd.DataFrame) and len(feature_ids) > 0: | |
| matched_cols = _match_metric_df_columns(mi_df, feature_ids) | |
| if matched_cols: | |
| mi_values = mi_df.loc[:, matched_cols] | |
| stats[f'{mi_key}_matched_cols'] = len(matched_cols) | |
| stats[f'{mi_key}_mean'] = float(mi_values.values.mean()) | |
| stats[f'{mi_key}_max'] = float(mi_values.values.max()) | |
| for auc_key in ('auc_type_df', 'auc_group_df'): | |
| auc_df = metric_tables.get(auc_key) | |
| if isinstance(auc_df, pd.DataFrame) and len(feature_ids) > 0: | |
| matched_cols = _match_metric_df_columns(auc_df, feature_ids) | |
| if matched_cols: | |
| auc_values = auc_df.loc[:, matched_cols] | |
| stats[f'{auc_key}_matched_cols'] = len(matched_cols) | |
| stats[f'{auc_key}_mean'] = float(auc_values.values.mean()) | |
| stats[f'{auc_key}_max'] = float(auc_values.values.max()) | |
| stats[f'{auc_key}_min'] = float(auc_values.values.min()) | |
| for key, value in metric_tables.items(): | |
| if not (isinstance(key, str) and key.startswith('paired_') and key.endswith('_df')): | |
| continue | |
| if isinstance(value, pd.DataFrame) and len(feature_ids) > 0: | |
| _collect_table_stats(stats, key, value, feature_ids) | |
| return stats | |
| class TopKAbsCorrSelector(BaseFeatureSelector): | |
| """Top-k by max absolute correlation across category rows.""" | |
| name: str | |
| description: str | |
| category: Category = 'type' | |
| relation_label: str = 'high correlation' | |
| def corr_key(self) -> str: | |
| return _metric_table_key('corr', self.category) | |
| def compute_metric_tables( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Dict[str, pd.DataFrame]: | |
| selector_params = dict(inputs.selector_params) | |
| level = str(selector_params.get('level', 'patch')) | |
| binarize = bool(selector_params.get('binarize', False)) | |
| group_col = _group_col(self.category) | |
| cache_path = selector_params.get('cache_path') | |
| if cache_path is None: | |
| cache_paths = inputs.cache_paths | |
| if cache_paths is not None: | |
| attr_map = { | |
| ('corr_type_df', 'patch'): 'corr_type_patch_cache_path', | |
| ('corr_group_df', 'patch'): 'corr_group_patch_cache_path', | |
| ('corr_type_df', 'image'): 'corr_type_cache_path', | |
| ('corr_group_df', 'image'): 'corr_group_cache_path', | |
| } | |
| cache_attr = attr_map.get((self.corr_key, level)) | |
| if cache_attr is not None and hasattr(cache_paths, cache_attr): | |
| cache_path = getattr(cache_paths, cache_attr) | |
| corr_df = compute_distortion_correlations( | |
| meta=inputs.meta_df, | |
| features=inputs.features, | |
| group_col=group_col, | |
| global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None, | |
| cache_path=cache_path, | |
| ) | |
| return {self.corr_key: corr_df} | |
| def compute_importance( | |
| self, | |
| metric_tables: Dict[str, pd.DataFrame], | |
| inputs: SelectorInputs | None = None, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Tuple[pd.Series, str]: | |
| corr_df = metric_tables[self.corr_key] | |
| if feature_ids is not None: | |
| corr_df = corr_df[[int(fid) for fid in feature_ids]] | |
| importance_series = corr_df.abs().max(axis=0) | |
| return importance_series, f'max_abs_corr:{self.corr_key}' | |
| class TopKMutualInfoSelector(BaseFeatureSelector): | |
| """Top-k by max mutual information across category rows.""" | |
| name: str | |
| description: str | |
| category: Category = 'type' | |
| relation_label: str = 'High mutual information' | |
| def mi_key(self) -> str: | |
| return _metric_table_key('mi', self.category) | |
| def compute_metric_tables( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Dict[str, pd.DataFrame]: | |
| selector_params = dict(inputs.selector_params) | |
| level = str(selector_params.get('level', 'patch')) | |
| binarize = bool(selector_params.get('binarize', False)) | |
| n_bins = int(selector_params.get('n_bins', 16)) | |
| feature_chunk_size = int(selector_params.get('feature_chunk_size', 512)) | |
| group_col = _group_col(self.category) | |
| cache_path = selector_params.get('cache_path') | |
| if cache_path is None: | |
| cache_paths = inputs.cache_paths | |
| if cache_paths is not None: | |
| attr_map = { | |
| ('mi_type_df', 'patch'): 'mi_type_patch_cache_path', | |
| ('mi_group_df', 'patch'): 'mi_group_patch_cache_path', | |
| ('mi_type_df', 'image'): 'mi_type_cache_path', | |
| ('mi_group_df', 'image'): 'mi_group_cache_path', | |
| } | |
| cache_attr = attr_map.get((self.mi_key, level)) | |
| if cache_attr is not None and hasattr(cache_paths, cache_attr): | |
| cache_path = getattr(cache_paths, cache_attr) | |
| mi_df = compute_distortion_mutual_information( | |
| meta=inputs.meta_df, | |
| features=inputs.features, | |
| group_col=group_col, | |
| global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None, | |
| binarize=binarize, | |
| level=level, | |
| n_bins=n_bins, | |
| feature_chunk_size=feature_chunk_size, | |
| cache_path=cache_path, | |
| ) | |
| return {self.mi_key: mi_df} | |
| def compute_importance( | |
| self, | |
| metric_tables: Dict[str, pd.DataFrame], | |
| inputs: SelectorInputs | None = None, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Tuple[pd.Series, str]: | |
| mi_df = metric_tables[self.mi_key] | |
| if feature_ids is not None: | |
| mi_df = mi_df[[int(fid) for fid in feature_ids]] | |
| importance_series = mi_df.max(axis=0) | |
| return importance_series, f'max_mi:{self.mi_key}' | |
| class TopKRocAucSelector(BaseFeatureSelector): | |
| """Top-k by one-vs-rest ROC-AUC across distortion categories.""" | |
| name: str | |
| description: str | |
| category: Category = 'type' | |
| level: Level = 'patch' | |
| relation_label: str = 'High ROC-AUC distinguishability' | |
| def auc_key(self) -> str: | |
| return _metric_table_key('auc', self.category) | |
| def compute_metric_tables( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Dict[str, pd.DataFrame]: | |
| selector_params = dict(inputs.selector_params) | |
| level = str(self.level) | |
| feature_chunk_size = int(selector_params.get('feature_chunk_size', 512)) | |
| group_col = _group_col(self.category) | |
| cache_path = selector_params.get('cache_path') | |
| if cache_path is None: | |
| cache_paths = inputs.cache_paths | |
| if cache_paths is not None: | |
| attr_map = { | |
| ('auc_type_df', 'patch'): 'auc_type_patch_cache_path', | |
| ('auc_group_df', 'patch'): 'auc_group_patch_cache_path', | |
| ('auc_type_df', 'image'): 'auc_type_cache_path', | |
| ('auc_group_df', 'image'): 'auc_group_cache_path', | |
| } | |
| cache_attr = attr_map.get((self.auc_key, level)) | |
| if cache_attr is not None and hasattr(cache_paths, cache_attr): | |
| cache_path = getattr(cache_paths, cache_attr) | |
| auc_df = compute_distortion_roc_auc( | |
| meta=inputs.meta_df, | |
| features=inputs.features, | |
| group_col=group_col, | |
| global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None, | |
| level=level, | |
| feature_chunk_size=feature_chunk_size, | |
| cache_path=cache_path, | |
| ) | |
| return {self.auc_key: auc_df} | |
| def compute_importance( | |
| self, | |
| metric_tables: Dict[str, pd.DataFrame], | |
| inputs: SelectorInputs | None = None, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Tuple[pd.Series, str]: | |
| auc_df = metric_tables[self.auc_key] | |
| if feature_ids is not None: | |
| auc_df = auc_df[[int(fid) for fid in feature_ids]] | |
| # Symmetric separability score: high both for direct and inverse predictors. | |
| direct_auc = auc_df.max(axis=0) | |
| inverse_auc = 1.0 - auc_df.min(axis=0) | |
| importance_series = pd.concat([direct_auc, inverse_auc], axis=1).max(axis=1) | |
| return importance_series, f'symmetric_auc:{self.auc_key}' | |
| class TopKPairedDeltaSelector(BaseFeatureSelector): | |
| """Top-k by original-vs-distorted paired activation deltas.""" | |
| name: str | |
| description: str | |
| delta_mode: DeltaMode = 'relative' | |
| category: Category = 'type' | |
| relation_label: str = 'высокая парная дельта активаций' | |
| def delta_key(self) -> str: | |
| return _paired_delta_table_key(self.delta_mode, self.category) | |
| def compute_metric_tables( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Dict[str, pd.DataFrame]: | |
| if inputs.pristine_meta_df is None or inputs.pristine_features is None: | |
| raise ValueError( | |
| f"Selector '{self.name}' requires 'pristine_meta_df' and 'pristine_features' for paired deltas." | |
| ) | |
| selector_params = dict(inputs.selector_params) | |
| level = str(selector_params.get('level', 'patch')) | |
| ranking_mode = str(selector_params.get('ranking_mode', 'default')).strip().lower() | |
| if ranking_mode not in {'default', 'weighted_average'}: | |
| raise ValueError( | |
| f"Selector '{self.name}': unsupported ranking_mode={ranking_mode!r}. " | |
| "Allowed: {'default', 'weighted_average'}." | |
| ) | |
| delta_mode = self.delta_mode | |
| group_col = _group_col(self.category) | |
| cache_prefix = selector_params.get('cache_prefix') | |
| if cache_prefix is None: | |
| cache_paths = inputs.cache_paths | |
| dataset_cache_dir = getattr(cache_paths, 'dataset_cache_dir', None) if cache_paths is not None else None | |
| if dataset_cache_dir is not None: | |
| cache_prefix = str(Path(dataset_cache_dir) / f'paired_delta_{level}') | |
| tables = build_paired_delta_tables( | |
| distorted_meta=inputs.meta_df, | |
| distorted=inputs.features, | |
| original_meta=inputs.pristine_meta_df, | |
| original=inputs.pristine_features, | |
| group_cols=(group_col,), | |
| global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None, | |
| delta_mode=delta_mode, | |
| level=level, | |
| cache_prefix=cache_prefix, | |
| ) | |
| if self.delta_key not in tables: | |
| available = ', '.join(sorted(tables.keys())) | |
| raise ValueError( | |
| f"Selector '{self.name}' expected table '{self.delta_key}', available: {available}" | |
| ) | |
| metric_table = tables[self.delta_key] | |
| if ranking_mode == 'weighted_average': | |
| if not inputs.feature_weight_norms: | |
| raise ValueError( | |
| f"Selector '{self.name}' (ranking_mode='weighted_average') requires per-feature decoder weight norms." | |
| ) | |
| norm_by_feature_id: Dict[int, float] = {} | |
| for raw_feature_id, raw_norm in inputs.feature_weight_norms.items(): | |
| try: | |
| feature_id = int(raw_feature_id) | |
| norm_by_feature_id[feature_id] = float(raw_norm) | |
| except (TypeError, ValueError): | |
| continue | |
| if not norm_by_feature_id: | |
| raise ValueError( | |
| f"Selector '{self.name}' (ranking_mode='weighted_average') received empty/invalid decoder weight norms." | |
| ) | |
| weight_series = pd.Series(index=metric_table.columns, dtype='float64') | |
| missing_columns: List[object] = [] | |
| for column in metric_table.columns: | |
| try: | |
| weight_series.loc[column] = norm_by_feature_id[int(column)] | |
| except (TypeError, ValueError, KeyError): | |
| missing_columns.append(column) | |
| if missing_columns: | |
| missing_preview = ', '.join(str(x) for x in missing_columns[:8]) | |
| raise ValueError( | |
| f"Selector '{self.name}' has no decoder norms for {len(missing_columns)} features " | |
| f"(first: {missing_preview})." | |
| ) | |
| weighted_abs = metric_table.abs().mul(weight_series, axis=1) | |
| col_min = weighted_abs.min(axis=0) | |
| col_max = weighted_abs.max(axis=0) | |
| denom = col_max - col_min | |
| normalized = weighted_abs.subtract(col_min, axis=1) | |
| nonzero_denom = denom > 0 | |
| if nonzero_denom.any(): | |
| normalized.loc[:, nonzero_denom] = normalized.loc[:, nonzero_denom].div(denom[nonzero_denom], axis=1) | |
| if (~nonzero_denom).any(): | |
| normalized.loc[:, ~nonzero_denom] = 0.0 | |
| metric_table = normalized.astype('float32', copy=False) | |
| return {self.delta_key: metric_table} | |
| def compute_importance( | |
| self, | |
| metric_tables: Dict[str, pd.DataFrame], | |
| inputs: SelectorInputs | None = None, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Tuple[pd.Series, str]: | |
| delta_df = metric_tables[self.delta_key] | |
| if feature_ids is not None: | |
| delta_df = delta_df[[int(fid) for fid in feature_ids]] | |
| ranking_mode = 'default' | |
| if inputs is not None: | |
| ranking_mode = str(inputs.selector_params.get('ranking_mode', 'default')).strip().lower() | |
| if ranking_mode == 'weighted_average': | |
| importance_series = delta_df.mean(axis=0) | |
| return importance_series, f'mean_weighted_delta_norm01:{self.delta_key}' | |
| delta_abs_mean = delta_df.abs().mean(axis=0) | |
| return delta_abs_mean, f'mean_abs_delta:{self.delta_key}' | |
| class TopKIoUSelector(BaseFeatureSelector): | |
| """Top-k by median IoU with distortion masks.""" | |
| name: str | |
| description: str | |
| category: Category = 'type' | |
| relation_label: str = 'высокая пространственная локализация' | |
| def iou_key(self) -> str: | |
| return _metric_table_key('iou', self.category) | |
| def compute_metric_tables( | |
| self, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Dict[str, pd.DataFrame]: | |
| from analysis.metrics.iou_utils import compute_iou_per_distortion_type_and_feature | |
| selector_params = dict(inputs.selector_params) | |
| iou_key = self.iou_key | |
| group_col = _group_col(self.category) | |
| cache_path = selector_params.get('cache_path') | |
| if cache_path is None: | |
| cache_paths = inputs.cache_paths | |
| if cache_paths is not None: | |
| attr_map = { | |
| ('iou_type_df',): 'iou_type_cache_path', | |
| ('iou_group_df',): 'iou_group_cache_path', | |
| } | |
| cache_attr = attr_map.get((iou_key,)) | |
| if cache_attr is not None and hasattr(cache_paths, cache_attr): | |
| cache_path = getattr(cache_paths, cache_attr) | |
| # Infer spatial shape from patch counts | |
| # Assume square grid: n_patches = height * width | |
| n_samples_total = inputs.features.codes.shape[0] | |
| n_unique_images = inputs.meta_df['image_idx'].nunique() | |
| patches_per_image = n_samples_total // n_unique_images if n_unique_images > 0 else 49 | |
| spatial_dim = int(np.sqrt(patches_per_image)) | |
| # Allow override via selector_params | |
| if 'spatial_shape' in selector_params: | |
| spatial_shape = tuple(selector_params['spatial_shape']) | |
| else: | |
| spatial_shape = (spatial_dim, spatial_dim) | |
| feature_batch_size = int(selector_params.get('feature_batch_size', 1024)) | |
| iou_df = compute_iou_per_distortion_type_and_feature( | |
| features=inputs.features, | |
| meta_df=inputs.meta_df, | |
| spatial_shape=spatial_shape, | |
| group_col=group_col, | |
| global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None, | |
| feature_batch_size=feature_batch_size, | |
| cache_path=cache_path, | |
| dataset=inputs.dataset, | |
| ) | |
| return {iou_key: iou_df} | |
| def compute_importance( | |
| self, | |
| metric_tables: Dict[str, pd.DataFrame], | |
| inputs: SelectorInputs | None = None, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> Tuple[pd.Series, str]: | |
| iou_key = self.iou_key | |
| iou_df = metric_tables[iou_key] | |
| if iou_df.empty: | |
| # Return empty series if no IoU data | |
| if feature_ids is not None: | |
| feature_ids_int = [int(fid) for fid in feature_ids] | |
| importance_series = pd.Series(0.0, index=feature_ids_int) | |
| else: | |
| importance_series = pd.Series(dtype=float) | |
| return importance_series, f'max_median_iou:{iou_key}' | |
| if feature_ids is not None: | |
| iou_df = iou_df[[int(fid) for fid in feature_ids]] | |
| # Take max IoU across all distortion types (each row is a distortion type) | |
| importance_series = iou_df.max(axis=0) | |
| return importance_series, f'max_median_iou:{iou_key}' | |
| def _parse_category(params: Mapping[str, object], *, default: Category = 'type') -> Category: | |
| value = str(params.get('category', default)).strip().lower() | |
| if value not in ('type', 'group'): | |
| raise ValueError(f"Invalid category {value!r}; expected 'type' or 'group'") | |
| return value # type: ignore[return-value] | |
| def _parse_level(params: Mapping[str, object], *, default: Level = 'patch') -> Level: | |
| value = str(params.get('level', default)).strip().lower() | |
| if value not in ('patch', 'image'): | |
| raise ValueError(f"Invalid level {value!r}; expected 'patch' or 'image'") | |
| return value # type: ignore[return-value] | |
| def _parse_delta_mode(params: Mapping[str, object], *, default: DeltaMode = 'relative') -> DeltaMode: | |
| value = str(params.get('delta_mode', default)).strip().lower() | |
| if value not in ('abs', 'signed', 'relative'): | |
| raise ValueError(f"Invalid delta_mode {value!r}; expected 'abs', 'signed', or 'relative'") | |
| return value # type: ignore[return-value] | |
| def canonical_selector_run_key(name: str, params: Mapping[str, object]) -> str: | |
| """Stable run/cache key derived from base selector name + params.""" | |
| params = dict(params or {}) | |
| base = str(name).strip() | |
| if base == 'topk_abs_corr': | |
| return base if _parse_category(params) == 'type' else f'{base}_group' | |
| if base == 'topk_mi': | |
| return base if _parse_category(params) == 'type' else f'{base}_group' | |
| if base == 'topk_auc': | |
| return f"{base}_{_parse_category(params)}_{_parse_level(params)}" | |
| if base == 'paired_delta': | |
| return f"{base}_{_parse_delta_mode(params)}_{_parse_category(params)}" | |
| if base == 'topk_iou': | |
| return f'{base}_type' if _parse_category(params) == 'type' else f'{base}_group' | |
| return base | |
| def instantiate_selector(name: str, params: Mapping[str, object] | None = None) -> BaseFeatureSelector: | |
| """Build a selector instance from registry name and config params.""" | |
| params = dict(params or {}) | |
| base = str(name).strip() | |
| run_name = canonical_selector_run_key(base, params) | |
| if base == 'topk_abs_corr': | |
| category = _parse_category(params) | |
| scope = 'dist_type' if category == 'type' else 'dist_group' | |
| return TopKAbsCorrSelector( | |
| name=run_name, | |
| description=f'Top-k features by max absolute {scope} correlation.', | |
| category=category, | |
| ) | |
| if base == 'topk_mi': | |
| category = _parse_category(params) | |
| scope = 'dist_type' if category == 'type' else 'dist_group' | |
| return TopKMutualInfoSelector( | |
| name=run_name, | |
| description=f'Top-k features by max {scope} mutual information.', | |
| category=category, | |
| ) | |
| if base == 'topk_auc': | |
| category = _parse_category(params) | |
| level = _parse_level(params) | |
| scope = 'dist_type' if category == 'type' else 'dist_group' | |
| return TopKRocAucSelector( | |
| name=run_name, | |
| description=f'Top-k features by ROC-AUC at {scope} level ({level}).', | |
| category=category, | |
| level=level, | |
| ) | |
| if base == 'paired_delta': | |
| category = _parse_category(params) | |
| delta_mode = _parse_delta_mode(params) | |
| scope = 'dist_type' if category == 'type' else 'dist_group' | |
| return TopKPairedDeltaSelector( | |
| name=run_name, | |
| description=f'Top-k features by {delta_mode} paired delta at {scope} level.', | |
| delta_mode=delta_mode, | |
| category=category, | |
| ) | |
| if base == 'topk_iou': | |
| category = _parse_category(params) | |
| scope = 'dist_type' if category == 'type' else 'dist_group' | |
| return TopKIoUSelector( | |
| name=run_name, | |
| description=f'Top-k features by median IoU with distortion regions at {scope} level.', | |
| category=category, | |
| ) | |
| available = ', '.join(sorted(SELECTOR_REGISTRY)) | |
| raise ValueError(f"Unknown selector '{base}'. Available: {available}") | |
| SELECTOR_REGISTRY: Tuple[str, ...] = ( | |
| 'topk_abs_corr', | |
| 'topk_mi', | |
| 'topk_auc', | |
| 'paired_delta', | |
| 'topk_iou', | |
| ) | |
| def get_selector_registry() -> Dict[str, BaseFeatureSelector]: | |
| """Return default instances for each base selector (default params).""" | |
| return {name: instantiate_selector(name, {}) for name in SELECTOR_REGISTRY} | |
| def load_selector(selector_name: str, params: Mapping[str, object] | None = None) -> BaseFeatureSelector: | |
| """Load built-in selector by name and optional params.""" | |
| if not str(selector_name).strip(): | |
| raise ValueError('selector_name is required') | |
| return instantiate_selector(selector_name, params) | |
| def run_selector( | |
| selector: BaseFeatureSelector, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> pd.DataFrame: | |
| """Compatibility wrapper around selector.select_features.""" | |
| selected_features = selector.select_features(inputs, feature_ids=feature_ids) | |
| required_cols = {'feature_id', 'importance_score', 'importance_rank', 'importance_metric'} | |
| missing_cols = required_cols.difference(selected_features.columns) | |
| if missing_cols: | |
| missing = ', '.join(sorted(missing_cols)) | |
| raise ValueError(f'Selector result is missing required columns: {missing}') | |
| return selected_features | |
| def run_selector_with_metrics( | |
| selector: BaseFeatureSelector, | |
| inputs: SelectorInputs, | |
| feature_ids: Sequence[int] | None = None, | |
| ) -> SelectorResult: | |
| """Run selector and return selected features plus computed metric tables.""" | |
| result = selector.execute(inputs, feature_ids=feature_ids) | |
| required_cols = {'feature_id', 'importance_score', 'importance_rank', 'importance_metric'} | |
| missing_cols = required_cols.difference(result.selected_features.columns) | |
| if missing_cols: | |
| missing = ', '.join(sorted(missing_cols)) | |
| raise ValueError(f'Selector result is missing required columns: {missing}') | |
| return result | |
| def selected_feature_ids(selected_features: pd.DataFrame) -> List[int]: | |
| """Convenience helper to get ordered feature ids from selector output.""" | |
| return [int(x) for x in selected_features['feature_id'].tolist()] | |
| def _normalize_selector_configs(selector_configs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | |
| if not selector_configs: | |
| raise ValueError("selector_configs must contain at least one selector config") | |
| normalized: List[Dict[str, Any]] = [] | |
| for selector_cfg in selector_configs: | |
| if not isinstance(selector_cfg, dict): | |
| raise ValueError("Each selector config must be a dict: {'name': str, 'params': dict, 'top_k': int}") | |
| selector_name = str(selector_cfg.get('name', '')).strip() | |
| if not selector_name: | |
| raise ValueError("Each selector config must include non-empty 'name'") | |
| if selector_name not in SELECTOR_REGISTRY: | |
| available = ', '.join(sorted(SELECTOR_REGISTRY)) | |
| raise ValueError(f"Unknown selector '{selector_name}'. Available: {available}") | |
| if 'params' not in selector_cfg: | |
| raise ValueError(f"Selector '{selector_name}': 'params' is required and must be a dict") | |
| selector_params = selector_cfg['params'] | |
| if selector_params is None: | |
| selector_params = {} | |
| if not isinstance(selector_params, dict): | |
| raise ValueError(f"Selector '{selector_name}': 'params' must be a dict") | |
| instantiate_selector(selector_name, selector_params) | |
| if 'top_k' not in selector_cfg: | |
| raise ValueError(f"Selector '{selector_name}': 'top_k' is required and must be positive") | |
| selector_top_k = int(selector_cfg['top_k']) | |
| if selector_top_k <= 0: | |
| raise ValueError(f"Selector '{selector_name}': 'top_k' must be positive") | |
| selector_feature_ids = selector_cfg.get('feature_ids') | |
| if selector_feature_ids is not None: | |
| if not isinstance(selector_feature_ids, (list, tuple)): | |
| raise ValueError(f"Selector '{selector_name}': 'feature_ids' must be list/tuple or null") | |
| selector_feature_ids = [int(fid) for fid in selector_feature_ids] | |
| normalized.append({ | |
| 'name': selector_name, | |
| 'params': selector_params, | |
| 'top_k': selector_top_k, | |
| 'feature_ids': selector_feature_ids, | |
| }) | |
| return normalized | |
| def run_selectors_from_configs( | |
| *, | |
| selector_configs: List[Dict[str, Any]], | |
| meta_df: Any, | |
| features: FeatureMatrix, | |
| pristine_meta_df: Any, | |
| pristine_features: FeatureMatrix | None, | |
| dataset: str, | |
| sae_checkpoint_path: str, | |
| cache_dir: str, | |
| cache_paths: Any = None, | |
| selector_registry: Optional[Dict[str, BaseFeatureSelector]] = None, | |
| feature_weight_norms: Optional[Mapping[int, float]] = None, | |
| ) -> Dict[str, SelectorResult]: | |
| """Execute configured selectors and return full per-run selector outputs.""" | |
| from analysis.models import extract_decoder_weight_norms | |
| normalized_configs = _normalize_selector_configs(selector_configs) | |
| needs_pristine_inputs = any( | |
| selector_cfg['name'] == 'paired_delta' | |
| for selector_cfg in normalized_configs | |
| ) | |
| if not needs_pristine_inputs: | |
| pristine_meta_df = None | |
| pristine_features = None | |
| selector_entries = [] | |
| for selector_cfg in normalized_configs: | |
| base_name = selector_cfg['name'] | |
| selector_params = dict(selector_cfg.get('params', {})) | |
| if selector_registry is not None and base_name in selector_registry: | |
| selector = selector_registry[base_name] | |
| else: | |
| selector = instantiate_selector(base_name, selector_params) | |
| run_key = canonical_selector_run_key(base_name, selector_params) | |
| selector_entries.append((run_key, selector, selector_cfg)) | |
| needs_weighted_delta = False | |
| for selector_cfg in normalized_configs: | |
| selector_params = selector_cfg.get('params', {}) | |
| if selector_params and str(selector_params.get('ranking_mode', 'default')) == 'weighted_average': | |
| needs_weighted_delta = True | |
| break | |
| resolved_weight_norms: Optional[Mapping[int, float]] = feature_weight_norms | |
| if needs_weighted_delta and not resolved_weight_norms: | |
| logger.info('[run] Loading SAE decoder norms for weighted_average ranking mode...') | |
| resolved_weight_norms = extract_decoder_weight_norms( | |
| checkpoint_path=sae_checkpoint_path, | |
| cache_dir=cache_dir, | |
| ) | |
| logger.info('[run] Loaded decoder norms for %s SAE features.', len(resolved_weight_norms)) | |
| selector_run_key_counts: Dict[str, int] = {} | |
| for run_key, _, _ in selector_entries: | |
| selector_run_key_counts[run_key] = selector_run_key_counts.get(run_key, 0) + 1 | |
| selector_results: Dict[str, SelectorResult] = {} | |
| for selector_idx, (run_key, selector, selector_cfg) in enumerate(selector_entries, start=1): | |
| selector_run_key = run_key | |
| if selector_run_key_counts[run_key] > 1: | |
| selector_run_key = f'{run_key}__{selector_idx}' | |
| selector_inputs = SelectorInputs( | |
| meta_df=meta_df, | |
| features=features, | |
| dataset=dataset, | |
| pristine_meta_df=pristine_meta_df, | |
| pristine_features=pristine_features, | |
| feature_weight_norms=resolved_weight_norms, | |
| cache_paths=cache_paths, | |
| selector_top_k=int(selector_cfg['top_k']), | |
| selector_params=dict(selector_cfg.get('params', {})), | |
| ) | |
| selector_feature_ids = selector_cfg.get('feature_ids') | |
| selector_results[selector_run_key] = selector.execute( | |
| selector_inputs, | |
| feature_ids=selector_feature_ids, | |
| ) | |
| return selector_results | |