IQA-Interpretation / analysis /features /feature_selectors.py
dvarfe's picture
sync with github version
0705c62
Raw
History Blame Contribute Delete
38.8 kB
"""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'
@dataclass(frozen=True)
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
@dataclass(frozen=True)
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'
@abstractmethod
def compute_metric_tables(
self,
inputs: SelectorInputs,
feature_ids: Sequence[int] | None = None,
) -> Dict[str, pd.DataFrame]:
"""Compute metric tables required by the selector."""
@abstractmethod
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
@dataclass(frozen=True)
class TopKAbsCorrSelector(BaseFeatureSelector):
"""Top-k by max absolute correlation across category rows."""
name: str
description: str
category: Category = 'type'
relation_label: str = 'high correlation'
@property
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}'
@dataclass(frozen=True)
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'
@property
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}'
@dataclass(frozen=True)
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'
@property
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}'
@dataclass(frozen=True)
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 = 'высокая парная дельта активаций'
@property
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}'
@dataclass(frozen=True)
class TopKIoUSelector(BaseFeatureSelector):
"""Top-k by median IoU with distortion masks."""
name: str
description: str
category: Category = 'type'
relation_label: str = 'высокая пространственная локализация'
@property
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