Spaces:
Sleeping
Sleeping
init repo
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +2 -0
- Dockerfile +24 -0
- analysis/__init__.py +0 -0
- analysis/cache_utils.py +787 -0
- analysis/config.py +332 -0
- analysis/datasets.py +1148 -0
- analysis/features/__init__.py +0 -0
- analysis/features/__pycache__/__init__.cpython-310.pyc +0 -0
- analysis/features/__pycache__/feature_filters.cpython-310.pyc +0 -0
- analysis/features/__pycache__/feature_indexing.cpython-310.pyc +0 -0
- analysis/features/__pycache__/feature_matrix.cpython-310.pyc +0 -0
- analysis/features/__pycache__/feature_selectors.cpython-310.pyc +0 -0
- analysis/features/feature_filters.py +437 -0
- analysis/features/feature_indexing.py +116 -0
- analysis/features/feature_matrix.py +100 -0
- analysis/features/feature_selectors.py +918 -0
- analysis/features/feature_stats.py +111 -0
- analysis/metrics/__init__.py +0 -0
- analysis/metrics/__pycache__/__init__.cpython-310.pyc +0 -0
- analysis/metrics/__pycache__/correlations.cpython-310.pyc +0 -0
- analysis/metrics/__pycache__/iou_utils.cpython-310.pyc +0 -0
- analysis/metrics/__pycache__/mutual_information.cpython-310.pyc +0 -0
- analysis/metrics/__pycache__/paired_deltas.cpython-310.pyc +0 -0
- analysis/metrics/__pycache__/precision_recall.cpython-310.pyc +0 -0
- analysis/metrics/__pycache__/roc_auc.cpython-310.pyc +0 -0
- analysis/metrics/correlations.py +121 -0
- analysis/metrics/iou_utils.py +341 -0
- analysis/metrics/mutual_information.py +115 -0
- analysis/metrics/paired_deltas.py +210 -0
- analysis/metrics/precision_recall.py +154 -0
- analysis/metrics/roc_auc.py +86 -0
- analysis/models.py +298 -0
- analysis/qalign_utils.py +86 -0
- analysis/utils.py +120 -0
- analysis/viz/__init__.py +0 -0
- analysis/viz/__pycache__/__init__.cpython-310.pyc +0 -0
- analysis/viz/__pycache__/umap_plot.cpython-310.pyc +0 -0
- analysis/viz/__pycache__/umap_utils.cpython-310.pyc +0 -0
- analysis/viz/__pycache__/vis_correlations.cpython-310.pyc +0 -0
- analysis/viz/__pycache__/vis_heatmaps.cpython-310.pyc +0 -0
- analysis/viz/__pycache__/vis_metrics.cpython-310.pyc +0 -0
- analysis/viz/__pycache__/vis_scatter.cpython-310.pyc +0 -0
- analysis/viz/umap_plot.py +197 -0
- analysis/viz/umap_utils.py +214 -0
- analysis/viz/vis_correlations.py +98 -0
- analysis/viz/vis_heatmaps.py +770 -0
- analysis/viz/vis_metrics.py +147 -0
- analysis/viz/vis_scatter.py +484 -0
- assets/examples +1 -0
- assets/style.css +1137 -0
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
data/
|
| 2 |
+
tests/
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y \
|
| 4 |
+
build-essential \
|
| 5 |
+
libgl1 \
|
| 6 |
+
libglx-mesa0 \
|
| 7 |
+
libglib2.0-0 \
|
| 8 |
+
libxcb1 \
|
| 9 |
+
libxkbcommon-x11-0 \
|
| 10 |
+
libxrender1 \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
RUN useradd -m -u 1001 dvarfe
|
| 15 |
+
USER dvarfe
|
| 16 |
+
ENV PATH="/home/dvarfe/.local/bin:$PATH"
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
COPY --chown=dvarfe ./requirements.txt requirements.txt
|
| 21 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 22 |
+
|
| 23 |
+
COPY --chown=dvarfe . /app
|
| 24 |
+
CMD ["python", "dashboard_app.py"]
|
analysis/__init__.py
ADDED
|
File without changes
|
analysis/cache_utils.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Утилиты для кэширования и загрузки активаций SAE.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import scipy.sparse as sp
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
from torch.utils.data import DataLoader
|
| 14 |
+
from tqdm.auto import tqdm
|
| 15 |
+
|
| 16 |
+
from analysis.models import _iqa_activations
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _df_mb(df: pd.DataFrame) -> float:
|
| 20 |
+
"""Объём памяти DataFrame в МБ."""
|
| 21 |
+
return df.memory_usage(deep=True).sum() / 1024 ** 2
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _sparse_mb(mat: sp.csr_matrix) -> float:
|
| 25 |
+
"""Объём памяти CSR-матрицы (данные + индексы) в МБ."""
|
| 26 |
+
return (mat.data.nbytes + mat.indices.nbytes + mat.indptr.nbytes) / 1024 ** 2
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _cache_paths(base_path: str) -> Tuple[str, str, str]:
|
| 30 |
+
"""
|
| 31 |
+
Возвращает пути к трём файлам кэша из базового пути.
|
| 32 |
+
|
| 33 |
+
Пример:
|
| 34 |
+
'cache/kadid_acts.feather'
|
| 35 |
+
→ ('cache/kadid_acts_meta.feather', 'cache/kadid_acts_codes.npz',
|
| 36 |
+
'cache/kadid_acts_steps.npz')
|
| 37 |
+
"""
|
| 38 |
+
p = Path(base_path)
|
| 39 |
+
stem = p.stem.removesuffix('.feather')
|
| 40 |
+
return (
|
| 41 |
+
str(p.parent / f'{stem}_meta.feather'),
|
| 42 |
+
str(p.parent / f'{stem}_codes.npz'),
|
| 43 |
+
str(p.parent / f'{stem}_steps.npz'),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _pristine_cache_paths(base_path: str) -> Tuple[str, str, str]:
|
| 48 |
+
"""Return paths for pristine cache files derived from base cache path."""
|
| 49 |
+
meta_path, codes_path, steps_path = _cache_paths(base_path)
|
| 50 |
+
return (
|
| 51 |
+
meta_path.replace('.feather', '_pristine.feather'),
|
| 52 |
+
codes_path.replace('.npz', '_pristine.npz'),
|
| 53 |
+
steps_path.replace('.npz', '_pristine.npz'),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_parquet_cache(cache_path: Optional[str], *, label: str = 'cache') -> Optional[pd.DataFrame]:
|
| 58 |
+
"""Load cached parquet table if present."""
|
| 59 |
+
if cache_path is None:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
cache = Path(cache_path)
|
| 63 |
+
if not cache.exists():
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
print(f'[cache] Loading {label} from {cache}')
|
| 67 |
+
return pd.read_parquet(cache)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def save_parquet_cache(df: pd.DataFrame, cache_path: Optional[str], *, label: str = 'cache') -> None:
|
| 71 |
+
"""Persist a dataframe to parquet cache if path is provided."""
|
| 72 |
+
if cache_path is None:
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
cache = Path(cache_path)
|
| 76 |
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
df.to_parquet(cache)
|
| 78 |
+
print(f'[cache] Saved {label} to {cache}')
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
_PATCH_LABEL_META_KEYS = frozenset({'dist_type', 'dist_group'})
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _patch_labels_to_dist_meta(
|
| 85 |
+
patch_labels: np.ndarray,
|
| 86 |
+
*,
|
| 87 |
+
label_to_dist_type: Dict[int, str],
|
| 88 |
+
label_to_dist_group: Dict[str, str],
|
| 89 |
+
) -> Tuple[List[str], List[str]]:
|
| 90 |
+
flat_labels = patch_labels.reshape(-1)
|
| 91 |
+
dist_types = [label_to_dist_type.get(int(label_id), 'background') for label_id in flat_labels]
|
| 92 |
+
dist_groups = [label_to_dist_group.get(dist_type, dist_type) for dist_type in dist_types]
|
| 93 |
+
return dist_types, dist_groups
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _process_dataloader(
|
| 97 |
+
dataloader,
|
| 98 |
+
iqa,
|
| 99 |
+
sae,
|
| 100 |
+
layer_name,
|
| 101 |
+
scaling_factor,
|
| 102 |
+
device,
|
| 103 |
+
patches_per_image,
|
| 104 |
+
patch_grid_shape,
|
| 105 |
+
meta_keys,
|
| 106 |
+
max_batches,
|
| 107 |
+
max_memory_gb,
|
| 108 |
+
add_patch_mask_stats,
|
| 109 |
+
show_progress_bars: bool = True,
|
| 110 |
+
*,
|
| 111 |
+
label_to_dist_type: Optional[Dict[int, str]] = None,
|
| 112 |
+
label_to_dist_group: Optional[Dict[str, str]] = None,
|
| 113 |
+
):
|
| 114 |
+
all_sparse_codes = []
|
| 115 |
+
all_meta = []
|
| 116 |
+
all_sparse_steps = []
|
| 117 |
+
|
| 118 |
+
n_patches_known = patches_per_image
|
| 119 |
+
patch_grid_known = patch_grid_shape
|
| 120 |
+
image_offset = 0
|
| 121 |
+
|
| 122 |
+
for batch_i, batch in enumerate(tqdm(dataloader, desc='Caching activations', disable=not show_progress_bars)):
|
| 123 |
+
if max_batches is not None and batch_i >= max_batches:
|
| 124 |
+
break
|
| 125 |
+
|
| 126 |
+
imgs = batch['images'].to(device)
|
| 127 |
+
B = imgs.shape[0]
|
| 128 |
+
|
| 129 |
+
with torch.no_grad():
|
| 130 |
+
iqa(imgs)
|
| 131 |
+
acts = _iqa_activations[layer_name].to(device)
|
| 132 |
+
acts = acts * scaling_factor
|
| 133 |
+
enc_out = sae.get_acts(acts)
|
| 134 |
+
|
| 135 |
+
if isinstance(enc_out, tuple):
|
| 136 |
+
codes, activation_steps = enc_out
|
| 137 |
+
else:
|
| 138 |
+
codes, activation_steps = enc_out, None
|
| 139 |
+
|
| 140 |
+
codes_np = codes.cpu().float().numpy()
|
| 141 |
+
|
| 142 |
+
if activation_steps is None:
|
| 143 |
+
steps_np = np.zeros_like(codes_np, dtype=np.int32)
|
| 144 |
+
else:
|
| 145 |
+
steps_np = activation_steps.cpu().numpy().astype(np.int32)
|
| 146 |
+
|
| 147 |
+
if n_patches_known is None:
|
| 148 |
+
n_patches_known = codes_np.shape[0] // B
|
| 149 |
+
print(f' Detected {n_patches_known} patches per image')
|
| 150 |
+
|
| 151 |
+
P = n_patches_known
|
| 152 |
+
|
| 153 |
+
use_patch_label_meta = (
|
| 154 |
+
label_to_dist_type is not None
|
| 155 |
+
and label_to_dist_group is not None
|
| 156 |
+
)
|
| 157 |
+
meta = {}
|
| 158 |
+
for k in meta_keys:
|
| 159 |
+
if k in batch:
|
| 160 |
+
if use_patch_label_meta and k in _PATCH_LABEL_META_KEYS:
|
| 161 |
+
continue
|
| 162 |
+
vals = batch[k]
|
| 163 |
+
meta[k] = [v for v in vals for _ in range(P)]
|
| 164 |
+
|
| 165 |
+
meta['patch_idx'] = list(range(P)) * B
|
| 166 |
+
meta['image_idx'] = [image_offset + i for i in range(B) for _ in range(P)]
|
| 167 |
+
|
| 168 |
+
if add_patch_mask_stats and 'masks' in batch:
|
| 169 |
+
masks = batch['masks'].to(device=device, dtype=torch.float32)
|
| 170 |
+
if patch_grid_known is not None:
|
| 171 |
+
grid_h, grid_w = patch_grid_known
|
| 172 |
+
if grid_h * grid_w == P:
|
| 173 |
+
mask_labels = masks.to(dtype=torch.int64)
|
| 174 |
+
max_label = int(mask_labels.max().item())
|
| 175 |
+
max_cov = None
|
| 176 |
+
|
| 177 |
+
if max_label <= 0:
|
| 178 |
+
patch_labels = torch.zeros((B, grid_h, grid_w), device=device, dtype=torch.int64)
|
| 179 |
+
else:
|
| 180 |
+
class_coverages = []
|
| 181 |
+
for label_id in range(1, max_label + 1):
|
| 182 |
+
label_cov = F.adaptive_avg_pool2d(
|
| 183 |
+
(mask_labels == label_id).to(dtype=torch.float32),
|
| 184 |
+
(grid_h, grid_w),
|
| 185 |
+
)
|
| 186 |
+
class_coverages.append(label_cov)
|
| 187 |
+
|
| 188 |
+
coverages = torch.cat(class_coverages, dim=1) # (B, classes, H, W)
|
| 189 |
+
max_cov, max_idx = coverages.max(dim=1)
|
| 190 |
+
patch_labels = torch.where(
|
| 191 |
+
max_cov > 0,
|
| 192 |
+
max_idx.to(dtype=torch.int64) + 1,
|
| 193 |
+
torch.zeros_like(max_idx, dtype=torch.int64),
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
patch_labels_np = patch_labels.reshape(B, P).cpu().numpy().astype(np.int16)
|
| 197 |
+
patch_is_dist = (patch_labels_np > 0).astype(np.int8)
|
| 198 |
+
patch_coverage_np = max_cov.reshape(B, P).cpu().numpy() if max_label > 0 else patch_is_dist.astype(np.float32)
|
| 199 |
+
|
| 200 |
+
meta['patch_mask_label'] = patch_labels_np.reshape(-1).tolist()
|
| 201 |
+
meta['patch_mask_coverage'] = patch_coverage_np.reshape(-1).tolist()
|
| 202 |
+
meta['patch_is_distorted'] = patch_is_dist.reshape(-1).tolist()
|
| 203 |
+
|
| 204 |
+
if use_patch_label_meta:
|
| 205 |
+
dist_types, dist_groups = _patch_labels_to_dist_meta(
|
| 206 |
+
patch_labels_np,
|
| 207 |
+
label_to_dist_type=label_to_dist_type,
|
| 208 |
+
label_to_dist_group=label_to_dist_group,
|
| 209 |
+
)
|
| 210 |
+
if 'dist_type' in meta_keys:
|
| 211 |
+
meta['dist_type'] = dist_types
|
| 212 |
+
if 'dist_group' in meta_keys:
|
| 213 |
+
meta['dist_group'] = dist_groups
|
| 214 |
+
|
| 215 |
+
all_meta.append(pd.DataFrame(meta))
|
| 216 |
+
all_sparse_codes.append(sp.csr_matrix(codes_np))
|
| 217 |
+
all_sparse_steps.append(sp.csr_matrix(steps_np))
|
| 218 |
+
|
| 219 |
+
image_offset += B
|
| 220 |
+
|
| 221 |
+
meta_df = pd.concat(all_meta, ignore_index=True)
|
| 222 |
+
codes_csr = sp.vstack(all_sparse_codes, format='csr')
|
| 223 |
+
steps_csr = sp.vstack(all_sparse_steps, format='csr')
|
| 224 |
+
|
| 225 |
+
return meta_df, codes_csr, steps_csr
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def collect_and_cache(
|
| 229 |
+
dataloader: DataLoader,
|
| 230 |
+
iqa: torch.nn.Module,
|
| 231 |
+
sae,
|
| 232 |
+
layer_name: str,
|
| 233 |
+
output_path: str,
|
| 234 |
+
scaling_factor: float = 1.0,
|
| 235 |
+
patches_per_image: Optional[int] = None,
|
| 236 |
+
patch_grid_shape: Optional[Tuple[int, int]] = None,
|
| 237 |
+
meta_keys: Sequence[str] = (
|
| 238 |
+
'dist_type',
|
| 239 |
+
'dist_group',
|
| 240 |
+
'dist_level',
|
| 241 |
+
'mos',
|
| 242 |
+
'distorted_img_path',
|
| 243 |
+
'original_img_path',
|
| 244 |
+
'sample_id',
|
| 245 |
+
),
|
| 246 |
+
device: str = 'cuda',
|
| 247 |
+
max_batches: Optional[int] = None,
|
| 248 |
+
max_memory_gb: Optional[float] = None,
|
| 249 |
+
add_patch_mask_stats: bool = True,
|
| 250 |
+
pristine_dataloader: Optional[DataLoader] = None,
|
| 251 |
+
show_progress_bars: bool = True,
|
| 252 |
+
label_to_dist_type: Optional[Dict[int, str]] = None,
|
| 253 |
+
label_to_dist_group: Optional[Dict[str, str]] = None,
|
| 254 |
+
) -> Tuple[pd.DataFrame, sp.csr_matrix]:
|
| 255 |
+
|
| 256 |
+
meta_df, codes_csr, steps_csr = _process_dataloader(
|
| 257 |
+
dataloader=dataloader,
|
| 258 |
+
iqa=iqa,
|
| 259 |
+
sae=sae,
|
| 260 |
+
layer_name=layer_name,
|
| 261 |
+
scaling_factor=scaling_factor,
|
| 262 |
+
device=device,
|
| 263 |
+
patches_per_image=patches_per_image,
|
| 264 |
+
patch_grid_shape=patch_grid_shape,
|
| 265 |
+
meta_keys=meta_keys,
|
| 266 |
+
max_batches=max_batches,
|
| 267 |
+
max_memory_gb=max_memory_gb,
|
| 268 |
+
add_patch_mask_stats=add_patch_mask_stats,
|
| 269 |
+
show_progress_bars=show_progress_bars,
|
| 270 |
+
label_to_dist_type=label_to_dist_type,
|
| 271 |
+
label_to_dist_group=label_to_dist_group,
|
| 272 |
+
)
|
| 273 |
+
sparse_mb = _sparse_mb(codes_csr)
|
| 274 |
+
steps_mb = _sparse_mb(steps_csr)
|
| 275 |
+
|
| 276 |
+
print(f'Activations: shape={codes_csr.shape}, {sparse_mb:.1f} МБ (sparse)')
|
| 277 |
+
print(f'Activation steps: shape={steps_csr.shape}, {steps_mb:.1f} МБ (sparse)')
|
| 278 |
+
|
| 279 |
+
meta_path, codes_path, steps_path = _cache_paths(output_path)
|
| 280 |
+
|
| 281 |
+
meta_df.to_feather(meta_path)
|
| 282 |
+
sp.save_npz(codes_path, codes_csr)
|
| 283 |
+
sp.save_npz(steps_path, steps_csr)
|
| 284 |
+
|
| 285 |
+
print(f'Saved metadata ({len(meta_df)} rows) -> {meta_path}')
|
| 286 |
+
print(f'Saved activations -> {codes_path}')
|
| 287 |
+
print(f'Saved activation steps -> {steps_path}')
|
| 288 |
+
print(f' Metadata: {_df_mb(meta_df):.1f} МБ')
|
| 289 |
+
print(f' Activations: {sparse_mb:.1f} МБ')
|
| 290 |
+
print(f' Steps: {steps_mb:.1f} МБ')
|
| 291 |
+
|
| 292 |
+
if pristine_dataloader is not None:
|
| 293 |
+
print('\nProcessing pristine dataset...')
|
| 294 |
+
|
| 295 |
+
pristine_meta, pristine_codes, pristine_steps = _process_dataloader(
|
| 296 |
+
dataloader=pristine_dataloader,
|
| 297 |
+
iqa=iqa,
|
| 298 |
+
sae=sae,
|
| 299 |
+
layer_name=layer_name,
|
| 300 |
+
scaling_factor=scaling_factor,
|
| 301 |
+
device=device,
|
| 302 |
+
patches_per_image=patches_per_image,
|
| 303 |
+
patch_grid_shape=patch_grid_shape,
|
| 304 |
+
meta_keys=meta_keys,
|
| 305 |
+
max_batches=max_batches,
|
| 306 |
+
max_memory_gb=max_memory_gb,
|
| 307 |
+
add_patch_mask_stats=False,
|
| 308 |
+
show_progress_bars=show_progress_bars,
|
| 309 |
+
)
|
| 310 |
+
pristine_sparse_mb = _sparse_mb(pristine_codes)
|
| 311 |
+
pristine_steps_mb = _sparse_mb(pristine_steps)
|
| 312 |
+
|
| 313 |
+
pristine_meta_path = meta_path.replace(".feather", "_pristine.feather")
|
| 314 |
+
pristine_codes_path = codes_path.replace(".npz", "_pristine.npz")
|
| 315 |
+
pristine_steps_path = steps_path.replace(".npz", "_pristine.npz")
|
| 316 |
+
|
| 317 |
+
pristine_meta.to_feather(pristine_meta_path)
|
| 318 |
+
sp.save_npz(pristine_codes_path, pristine_codes)
|
| 319 |
+
sp.save_npz(pristine_steps_path, pristine_steps)
|
| 320 |
+
|
| 321 |
+
print(f'Pristine activations: shape={pristine_codes.shape}, {pristine_sparse_mb:.1f} МБ')
|
| 322 |
+
print(f'Pristine steps: shape={pristine_steps.shape}, {pristine_steps_mb:.1f} МБ')
|
| 323 |
+
|
| 324 |
+
print(f'Saved pristine metadata ({len(pristine_meta)} rows) -> {pristine_meta_path}')
|
| 325 |
+
print(f'Saved pristine activations -> {pristine_codes_path}')
|
| 326 |
+
print(f'Saved pristine activation steps -> {pristine_steps_path}')
|
| 327 |
+
|
| 328 |
+
return meta_df, codes_csr
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def build_activation_cache(
|
| 332 |
+
*,
|
| 333 |
+
dataset: str,
|
| 334 |
+
cache_path: str,
|
| 335 |
+
checkpoint_path: str,
|
| 336 |
+
kadid_path: str,
|
| 337 |
+
layer_num: int,
|
| 338 |
+
iqa_metric: str,
|
| 339 |
+
swin_num: int,
|
| 340 |
+
device: str,
|
| 341 |
+
batch_size: int,
|
| 342 |
+
num_workers: int,
|
| 343 |
+
crop_size: int,
|
| 344 |
+
scaling_factor: float = 1.0,
|
| 345 |
+
min_distortion_level: Optional[int] = None,
|
| 346 |
+
max_batches: Optional[int] = None,
|
| 347 |
+
max_memory_gb: Optional[float] = None,
|
| 348 |
+
add_patch_mask_stats: bool = True,
|
| 349 |
+
include_pristine: bool = True,
|
| 350 |
+
show_progress_bars: bool = True,
|
| 351 |
+
) -> Dict[str, Any]:
|
| 352 |
+
"""Build activation cache end-to-end for KADID/local-KADID datasets."""
|
| 353 |
+
from .datasets import (
|
| 354 |
+
Kadid10kDataset,
|
| 355 |
+
KadidPristineDataset,
|
| 356 |
+
LocalKadidPresavedDataset,
|
| 357 |
+
LocalKadidPristineDataset,
|
| 358 |
+
QGroundDataset,
|
| 359 |
+
SRGroundSmallDataset,
|
| 360 |
+
available_distortions_qground,
|
| 361 |
+
available_distortions_srground,
|
| 362 |
+
distortion_types_mapping_qground,
|
| 363 |
+
distortion_types_mapping_srground,
|
| 364 |
+
kadid_collate_fn,
|
| 365 |
+
kadid_pristine_collate_fn,
|
| 366 |
+
local_kadid_collate_fn,
|
| 367 |
+
local_kadid_pristine_collate_fn,
|
| 368 |
+
qground_collate_fn,
|
| 369 |
+
srground_collate_fn,
|
| 370 |
+
)
|
| 371 |
+
from .models import _iqa_activation_grids, load_iqa_model, load_sae, read_sae_config
|
| 372 |
+
|
| 373 |
+
if min_distortion_level is not None and not (1 <= min_distortion_level <= 5):
|
| 374 |
+
raise ValueError('min_distortion_level must be in [1, 5]')
|
| 375 |
+
|
| 376 |
+
label_to_dist_type = None
|
| 377 |
+
label_to_dist_group = None
|
| 378 |
+
|
| 379 |
+
if dataset == 'local_kadid':
|
| 380 |
+
data = LocalKadidPresavedDataset(root=kadid_path, crop_size=crop_size)
|
| 381 |
+
collate_fn = local_kadid_collate_fn
|
| 382 |
+
meta_keys = [
|
| 383 |
+
'dist_type',
|
| 384 |
+
'dist_group',
|
| 385 |
+
'dist_level',
|
| 386 |
+
'mos',
|
| 387 |
+
'local_dist_type',
|
| 388 |
+
'local_dist_level',
|
| 389 |
+
'mask_shape',
|
| 390 |
+
'mask_coverage',
|
| 391 |
+
'sample_id',
|
| 392 |
+
'distorted_img_path',
|
| 393 |
+
'original_img_path',
|
| 394 |
+
]
|
| 395 |
+
pristine_data = LocalKadidPristineDataset(root=kadid_path, crop_size=crop_size) if include_pristine else None
|
| 396 |
+
pristine_collate = local_kadid_pristine_collate_fn
|
| 397 |
+
elif dataset in {'kadid10k', 'kadid'}:
|
| 398 |
+
data = Kadid10kDataset(
|
| 399 |
+
root=kadid_path,
|
| 400 |
+
crop_size=crop_size,
|
| 401 |
+
min_distortion_level=min_distortion_level or 1,
|
| 402 |
+
)
|
| 403 |
+
collate_fn = kadid_collate_fn
|
| 404 |
+
meta_keys = [
|
| 405 |
+
'dist_type',
|
| 406 |
+
'dist_group',
|
| 407 |
+
'dist_level',
|
| 408 |
+
'mos',
|
| 409 |
+
'distorted_img_path',
|
| 410 |
+
'original_img_path',
|
| 411 |
+
]
|
| 412 |
+
pristine_data = KadidPristineDataset(root=kadid_path, crop_size=crop_size) if include_pristine else None
|
| 413 |
+
pristine_collate = kadid_pristine_collate_fn
|
| 414 |
+
elif dataset == 'QGround':
|
| 415 |
+
data = QGroundDataset(
|
| 416 |
+
root=kadid_path,
|
| 417 |
+
split='test',
|
| 418 |
+
crop_size=crop_size,
|
| 419 |
+
)
|
| 420 |
+
collate_fn = qground_collate_fn
|
| 421 |
+
meta_keys = [
|
| 422 |
+
'dist_type',
|
| 423 |
+
'dist_group',
|
| 424 |
+
'dist_level',
|
| 425 |
+
'mos',
|
| 426 |
+
'mask_coverage',
|
| 427 |
+
'qground_ann_id',
|
| 428 |
+
'sample_id',
|
| 429 |
+
'distorted_img_path',
|
| 430 |
+
'original_img_path',
|
| 431 |
+
'image_path',
|
| 432 |
+
'mask_path',
|
| 433 |
+
'split',
|
| 434 |
+
]
|
| 435 |
+
label_to_dist_type = distortion_types_mapping_qground
|
| 436 |
+
label_to_dist_group = available_distortions_qground
|
| 437 |
+
pristine_data = None
|
| 438 |
+
pristine_collate = None
|
| 439 |
+
elif dataset == 'SRGround':
|
| 440 |
+
data = SRGroundSmallDataset(
|
| 441 |
+
root=kadid_path,
|
| 442 |
+
json_path='srground_train.json',
|
| 443 |
+
crop_size=crop_size,
|
| 444 |
+
)
|
| 445 |
+
collate_fn = srground_collate_fn
|
| 446 |
+
meta_keys = [
|
| 447 |
+
'dist_type',
|
| 448 |
+
'dist_group',
|
| 449 |
+
'dist_level',
|
| 450 |
+
'mos',
|
| 451 |
+
'mask_coverage',
|
| 452 |
+
'sample_id',
|
| 453 |
+
'distorted_img_path',
|
| 454 |
+
'image_path',
|
| 455 |
+
'real_distortions_ann_path',
|
| 456 |
+
'sr_artifacts_ann_path',
|
| 457 |
+
]
|
| 458 |
+
label_to_dist_type = distortion_types_mapping_srground
|
| 459 |
+
label_to_dist_group = available_distortions_srground
|
| 460 |
+
pristine_data = None
|
| 461 |
+
pristine_collate = None
|
| 462 |
+
else:
|
| 463 |
+
raise ValueError(f'Unsupported dataset: {dataset}')
|
| 464 |
+
|
| 465 |
+
loader = DataLoader(
|
| 466 |
+
data,
|
| 467 |
+
batch_size=batch_size,
|
| 468 |
+
shuffle=False,
|
| 469 |
+
num_workers=num_workers,
|
| 470 |
+
collate_fn=collate_fn,
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
pristine_loader = None
|
| 474 |
+
if pristine_data is not None:
|
| 475 |
+
pristine_loader = DataLoader(
|
| 476 |
+
pristine_data,
|
| 477 |
+
batch_size=batch_size,
|
| 478 |
+
shuffle=False,
|
| 479 |
+
num_workers=num_workers,
|
| 480 |
+
collate_fn=pristine_collate,
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
iqa_model, layer_name = load_iqa_model(
|
| 484 |
+
layer_num=layer_num,
|
| 485 |
+
device=device,
|
| 486 |
+
iqa_metric=iqa_metric,
|
| 487 |
+
swin_num=swin_num,
|
| 488 |
+
)
|
| 489 |
+
dtype = torch.float16 if iqa_metric == 'qalign' else torch.float32
|
| 490 |
+
sae_cfg = read_sae_config(checkpoint_path)
|
| 491 |
+
sae_model = load_sae(checkpoint_path, device=device, dtype=dtype, sae_config=sae_cfg)
|
| 492 |
+
|
| 493 |
+
with torch.no_grad():
|
| 494 |
+
dummy = torch.rand(1, 3, crop_size, crop_size, device=device).clamp(0, 1)
|
| 495 |
+
iqa_model(dummy)
|
| 496 |
+
|
| 497 |
+
if layer_name not in _iqa_activations or layer_name not in _iqa_activation_grids:
|
| 498 |
+
raise RuntimeError(f'Cannot infer activation grid for layer {layer_name}')
|
| 499 |
+
|
| 500 |
+
patch_grid_shape = _iqa_activation_grids[layer_name]
|
| 501 |
+
patches_per_image = patch_grid_shape[0] * patch_grid_shape[1]
|
| 502 |
+
|
| 503 |
+
Path(cache_path).parent.mkdir(parents=True, exist_ok=True)
|
| 504 |
+
collect_and_cache(
|
| 505 |
+
dataloader=loader,
|
| 506 |
+
iqa=iqa_model,
|
| 507 |
+
sae=sae_model,
|
| 508 |
+
layer_name=layer_name,
|
| 509 |
+
output_path=cache_path,
|
| 510 |
+
scaling_factor=scaling_factor,
|
| 511 |
+
patches_per_image=patches_per_image,
|
| 512 |
+
patch_grid_shape=patch_grid_shape,
|
| 513 |
+
meta_keys=meta_keys,
|
| 514 |
+
device=device,
|
| 515 |
+
max_batches=max_batches,
|
| 516 |
+
max_memory_gb=max_memory_gb,
|
| 517 |
+
add_patch_mask_stats=add_patch_mask_stats,
|
| 518 |
+
pristine_dataloader=pristine_loader,
|
| 519 |
+
show_progress_bars=show_progress_bars,
|
| 520 |
+
label_to_dist_type=label_to_dist_type,
|
| 521 |
+
label_to_dist_group=label_to_dist_group,
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
return {
|
| 525 |
+
'layer_name': layer_name,
|
| 526 |
+
'patch_grid_shape': patch_grid_shape,
|
| 527 |
+
'patches_per_image': patches_per_image,
|
| 528 |
+
'sae_config': sae_cfg,
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def load_cache(
|
| 533 |
+
path: str,
|
| 534 |
+
return_activation_steps: bool = False,
|
| 535 |
+
min_distortion_level: Optional[int] = None,
|
| 536 |
+
max_distortion_level: Optional[int] = None,
|
| 537 |
+
) -> Union[
|
| 538 |
+
Tuple[pd.DataFrame, sp.csr_matrix],
|
| 539 |
+
Tuple[pd.DataFrame, sp.csr_matrix, sp.csr_matrix],
|
| 540 |
+
]:
|
| 541 |
+
"""Загружает кэш активаций SAE из раздельных файлов.
|
| 542 |
+
|
| 543 |
+
Если ``return_activation_steps=True``, дополнительно возвращает CSR-матрицу
|
| 544 |
+
порядка активаций, где значение ``0`` означает отсутствие активации,
|
| 545 |
+
а ``k>0`` соответствует шагу ``k`` в pursuit.
|
| 546 |
+
"""
|
| 547 |
+
meta_path, codes_path, steps_path = _cache_paths(path)
|
| 548 |
+
meta = pd.read_feather(meta_path)
|
| 549 |
+
codes = sp.load_npz(codes_path)
|
| 550 |
+
|
| 551 |
+
print(f'Loaded from {meta_path}: {meta.shape[0]} rows × {meta.shape[1]} cols')
|
| 552 |
+
print(f'Loaded from {codes_path}: shape={codes.shape}, dtype={codes.dtype}')
|
| 553 |
+
print(f' Metadata: {_df_mb(meta):.1f} МБ')
|
| 554 |
+
print(f' Activations: {_sparse_mb(codes):.1f} МБ (sparse)')
|
| 555 |
+
|
| 556 |
+
keep_idx: Optional[np.ndarray] = None
|
| 557 |
+
if min_distortion_level is not None or max_distortion_level is not None:
|
| 558 |
+
if 'dist_level' not in meta.columns:
|
| 559 |
+
raise ValueError('Cannot filter by distortion level: metadata has no "dist_level" column')
|
| 560 |
+
|
| 561 |
+
min_level = 1 if min_distortion_level is None else int(min_distortion_level)
|
| 562 |
+
max_level = 5 if max_distortion_level is None else int(max_distortion_level)
|
| 563 |
+
if min_level > max_level:
|
| 564 |
+
raise ValueError(
|
| 565 |
+
f'Invalid distortion-level range: min_distortion_level={min_level} > max_distortion_level={max_level}'
|
| 566 |
+
)
|
| 567 |
+
if 'Ground' not in path:
|
| 568 |
+
keep_mask = (meta['dist_level'] >= min_level) & (meta['dist_level'] <= max_level)
|
| 569 |
+
keep_idx = np.flatnonzero(keep_mask.to_numpy())
|
| 570 |
+
else:
|
| 571 |
+
keep_mask = (meta['dist_level'] >= -1000)
|
| 572 |
+
keep_idx = np.flatnonzero(keep_mask.to_numpy()) # Костыль -- поправить
|
| 573 |
+
|
| 574 |
+
if return_activation_steps:
|
| 575 |
+
if Path(steps_path).exists():
|
| 576 |
+
steps = sp.load_npz(steps_path)
|
| 577 |
+
if steps.shape != codes.shape:
|
| 578 |
+
raise ValueError(
|
| 579 |
+
f'Steps cache shape mismatch: expected {codes.shape}, got {steps.shape}'
|
| 580 |
+
)
|
| 581 |
+
print(f'Loaded from {steps_path}: shape={steps.shape}, dtype={steps.dtype}')
|
| 582 |
+
else:
|
| 583 |
+
print(f'No steps cache found. Using all-zero activation steps.')
|
| 584 |
+
steps = sp.csr_matrix(codes.shape, dtype=np.int32)
|
| 585 |
+
|
| 586 |
+
if keep_idx is not None:
|
| 587 |
+
meta = meta.iloc[keep_idx].reset_index(drop=True)
|
| 588 |
+
codes = codes[keep_idx]
|
| 589 |
+
steps = steps[keep_idx]
|
| 590 |
+
print(
|
| 591 |
+
f'Applied dist_level filter [{min_level}, {max_level}] -> '
|
| 592 |
+
f'{meta.shape[0]} rows kept'
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
print(f' Steps: {_sparse_mb(steps):.1f} МБ (sparse)')
|
| 596 |
+
return meta, codes, steps
|
| 597 |
+
|
| 598 |
+
if keep_idx is not None:
|
| 599 |
+
meta = meta.iloc[keep_idx].reset_index(drop=True)
|
| 600 |
+
codes = codes[keep_idx]
|
| 601 |
+
print(
|
| 602 |
+
f'Applied dist_level filter [{min_level}, {max_level}] -> '
|
| 603 |
+
f'{meta.shape[0]} rows kept'
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
return meta, codes
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def load_pristine_cache(
|
| 610 |
+
path: str,
|
| 611 |
+
return_activation_steps: bool = False,
|
| 612 |
+
) -> Union[
|
| 613 |
+
Tuple[pd.DataFrame, sp.csr_matrix],
|
| 614 |
+
Tuple[pd.DataFrame, sp.csr_matrix, sp.csr_matrix],
|
| 615 |
+
]:
|
| 616 |
+
"""Load pristine (original-image) activation cache saved by collect_and_cache."""
|
| 617 |
+
meta_path, codes_path, steps_path = _pristine_cache_paths(path)
|
| 618 |
+
meta = pd.read_feather(meta_path)
|
| 619 |
+
codes = sp.load_npz(codes_path)
|
| 620 |
+
|
| 621 |
+
print(f'Loaded pristine from {meta_path}: {meta.shape[0]} rows × {meta.shape[1]} cols')
|
| 622 |
+
print(f'Loaded pristine from {codes_path}: shape={codes.shape}, dtype={codes.dtype}')
|
| 623 |
+
print(f' Metadata: {_df_mb(meta):.1f} МБ')
|
| 624 |
+
print(f' Activations: {_sparse_mb(codes):.1f} МБ (sparse)')
|
| 625 |
+
|
| 626 |
+
if return_activation_steps:
|
| 627 |
+
if Path(steps_path).exists():
|
| 628 |
+
steps = sp.load_npz(steps_path)
|
| 629 |
+
if steps.shape != codes.shape:
|
| 630 |
+
raise ValueError(
|
| 631 |
+
f'Pristine steps cache shape mismatch: expected {codes.shape}, got {steps.shape}'
|
| 632 |
+
)
|
| 633 |
+
print(f'Loaded pristine from {steps_path}: shape={steps.shape}, dtype={steps.dtype}')
|
| 634 |
+
else:
|
| 635 |
+
print('No pristine steps cache found. Using all-zero activation steps.')
|
| 636 |
+
steps = sp.csr_matrix(codes.shape, dtype=np.int32)
|
| 637 |
+
|
| 638 |
+
print(f' Steps: {_sparse_mb(steps):.1f} МБ (sparse)')
|
| 639 |
+
return meta, codes, steps
|
| 640 |
+
|
| 641 |
+
return meta, codes
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def ensure_cache_ready(
|
| 645 |
+
cache_path: str,
|
| 646 |
+
*,
|
| 647 |
+
force_recache: bool = False,
|
| 648 |
+
build_cache_if_missing: bool = True,
|
| 649 |
+
load_cache_kwargs: Optional[Dict[str, Any]] = None,
|
| 650 |
+
build_cache_fn: Optional[Callable[[], None]] = None,
|
| 651 |
+
) -> None:
|
| 652 |
+
"""Проверяет доступность кэша и при необходимости собирает его.
|
| 653 |
+
|
| 654 |
+
Поведение:
|
| 655 |
+
- пытается загрузить кэш через ``load_cache``;
|
| 656 |
+
- если кэш отсутствует или выставлен ``force_recache=True``, запускает сборку;
|
| 657 |
+
- если сборка отключена, пробрасывает ``FileNotFoundError``.
|
| 658 |
+
"""
|
| 659 |
+
needs_rebuild = force_recache
|
| 660 |
+
if not needs_rebuild:
|
| 661 |
+
try:
|
| 662 |
+
load_cache(cache_path, **(load_cache_kwargs or {}))
|
| 663 |
+
return
|
| 664 |
+
except FileNotFoundError:
|
| 665 |
+
needs_rebuild = True
|
| 666 |
+
|
| 667 |
+
if not needs_rebuild:
|
| 668 |
+
return
|
| 669 |
+
|
| 670 |
+
if not build_cache_if_missing:
|
| 671 |
+
raise FileNotFoundError(
|
| 672 |
+
f'Activation cache not found at {cache_path}, and build is disabled. '
|
| 673 |
+
'Use --build-cache-if-missing or provide existing cache files.'
|
| 674 |
+
)
|
| 675 |
+
|
| 676 |
+
if build_cache_fn is None:
|
| 677 |
+
raise ValueError(
|
| 678 |
+
'build_cache_fn must be provided when cache rebuild is required '
|
| 679 |
+
'(missing cache or force_recache=True).'
|
| 680 |
+
)
|
| 681 |
+
|
| 682 |
+
print('[cache] Building activation cache...')
|
| 683 |
+
build_cache_fn()
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
def zero_codes_outside_activation_steps(
|
| 687 |
+
codes_csr: sp.csr_matrix,
|
| 688 |
+
activation_steps_csr: sp.csr_matrix,
|
| 689 |
+
activation_steps_to_keep: List[int],
|
| 690 |
+
) -> sp.csr_matrix:
|
| 691 |
+
"""Обнуляет активации, ��аг появления которых не входит в allow-list.
|
| 692 |
+
|
| 693 |
+
Параметры
|
| 694 |
+
----------
|
| 695 |
+
codes_csr : CSR-матрица активаций SAE.
|
| 696 |
+
activation_steps_csr : CSR-матрица шагов активаций (0 = не активирован).
|
| 697 |
+
activation_steps_to_keep : список шагов, которые нужно сохранить.
|
| 698 |
+
|
| 699 |
+
Возвращает
|
| 700 |
+
----------
|
| 701 |
+
CSR-матрицу той же формы, где вне указанных шагов значения занулены.
|
| 702 |
+
Если список шагов пуст, возвращается исходная матрица без изменений.
|
| 703 |
+
"""
|
| 704 |
+
if not activation_steps_to_keep:
|
| 705 |
+
return codes_csr
|
| 706 |
+
|
| 707 |
+
if codes_csr.shape != activation_steps_csr.shape:
|
| 708 |
+
raise ValueError(
|
| 709 |
+
f'Codes/steps shape mismatch: {codes_csr.shape} vs {activation_steps_csr.shape}'
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
keep_steps = sorted({int(step) for step in activation_steps_to_keep})
|
| 713 |
+
if any(step <= 0 for step in keep_steps):
|
| 714 |
+
raise ValueError('activation_steps_to_keep must contain only positive integers')
|
| 715 |
+
|
| 716 |
+
codes_coo = codes_csr.tocoo(copy=False)
|
| 717 |
+
steps_coo = activation_steps_csr.tocoo(copy=False)
|
| 718 |
+
|
| 719 |
+
# Steps matrix stores indices for nonzero entries of codes, so coordinates must match.
|
| 720 |
+
if (
|
| 721 |
+
codes_coo.nnz != steps_coo.nnz
|
| 722 |
+
or not np.array_equal(codes_coo.row, steps_coo.row)
|
| 723 |
+
or not np.array_equal(codes_coo.col, steps_coo.col)
|
| 724 |
+
):
|
| 725 |
+
raise ValueError('Codes and steps matrices must have the same sparsity pattern. Something weird is going on.')
|
| 726 |
+
else:
|
| 727 |
+
steps_for_codes = steps_coo.data
|
| 728 |
+
|
| 729 |
+
keep_mask = np.isin(np.asarray(steps_for_codes), np.asarray(keep_steps, dtype=np.int32))
|
| 730 |
+
|
| 731 |
+
filtered = sp.coo_matrix(
|
| 732 |
+
(codes_coo.data[keep_mask], (codes_coo.row[keep_mask], codes_coo.col[keep_mask])),
|
| 733 |
+
shape=codes_csr.shape,
|
| 734 |
+
dtype=codes_csr.dtype,
|
| 735 |
+
)
|
| 736 |
+
return filtered.tocsr()
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
def ensure_activation_cache(
|
| 740 |
+
dataset: str,
|
| 741 |
+
acts_cache_path: str,
|
| 742 |
+
kadid_images_path: str,
|
| 743 |
+
min_distortion_level: int,
|
| 744 |
+
params: dict,
|
| 745 |
+
include_pristine_cache: Optional[bool] = None,
|
| 746 |
+
) -> None:
|
| 747 |
+
"""Build distorted+pristine activation cache if missing."""
|
| 748 |
+
cache_filter_min = int(min_distortion_level) if dataset == 'kadid10k' else None
|
| 749 |
+
if include_pristine_cache is None:
|
| 750 |
+
needs_pristine_cache = dataset in {'kadid10k', 'local_kadid'}
|
| 751 |
+
else:
|
| 752 |
+
needs_pristine_cache = bool(include_pristine_cache)
|
| 753 |
+
|
| 754 |
+
try:
|
| 755 |
+
load_cache(
|
| 756 |
+
acts_cache_path,
|
| 757 |
+
return_activation_steps=True,
|
| 758 |
+
min_distortion_level=cache_filter_min,
|
| 759 |
+
max_distortion_level=params.get('KADID_MAX_DISTORTION_LEVEL') if dataset == 'kadid10k' else None,
|
| 760 |
+
)
|
| 761 |
+
if needs_pristine_cache:
|
| 762 |
+
load_pristine_cache(acts_cache_path, return_activation_steps=True)
|
| 763 |
+
return
|
| 764 |
+
except FileNotFoundError:
|
| 765 |
+
pass
|
| 766 |
+
|
| 767 |
+
print(f'[run] Activation cache not found for {acts_cache_path}. Building cache...')
|
| 768 |
+
build_activation_cache(
|
| 769 |
+
dataset=dataset,
|
| 770 |
+
cache_path=acts_cache_path,
|
| 771 |
+
checkpoint_path=params.get('SAE_CHECKPOINT_PATH'),
|
| 772 |
+
kadid_path=kadid_images_path,
|
| 773 |
+
layer_num=params.get('LAYER_NUM'),
|
| 774 |
+
iqa_metric=params.get('IQA_METRIC'),
|
| 775 |
+
swin_num=params.get('SWIN_NUM'),
|
| 776 |
+
device=params.get('DEVICE'),
|
| 777 |
+
batch_size=params.get('BATCH_SIZE'),
|
| 778 |
+
num_workers=params.get('NUM_WORKERS'),
|
| 779 |
+
crop_size=params.get('CROP_SIZE'),
|
| 780 |
+
scaling_factor=params.get('SCALING_FACTOR'),
|
| 781 |
+
min_distortion_level=min_distortion_level,
|
| 782 |
+
max_batches=None,
|
| 783 |
+
max_memory_gb=30.0,
|
| 784 |
+
add_patch_mask_stats=True,
|
| 785 |
+
include_pristine=needs_pristine_cache,
|
| 786 |
+
)
|
| 787 |
+
print('[run] Activation cache build completed.')
|
analysis/config.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Конфигурация SAE-визуализаций.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import Dict, List, Mapping, Sequence, Tuple
|
| 11 |
+
|
| 12 |
+
DEFAULT_SAE_VIS_CONFIG_PATH = os.path.join(
|
| 13 |
+
os.path.dirname(__file__), '..', 'default_configs', 'sae_vis_config.json'
|
| 14 |
+
)
|
| 15 |
+
SUPPORTED_DATASETS: Tuple[str, ...] = ('kadid10k', 'local_kadid', 'QGround', 'SRGround')
|
| 16 |
+
|
| 17 |
+
_DATASET_IMAGE_SUBDIRS = {
|
| 18 |
+
'kadid10k': 'kadid10k',
|
| 19 |
+
'local_kadid': 'local_kadid',
|
| 20 |
+
'QGround': 'QGround',
|
| 21 |
+
'SRGround': 'SRGround',
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def dataset_images_root(datasets_root: str, dataset: str) -> str:
|
| 26 |
+
"""Filesystem root for a dataset's image files (under DATASETS_ROOT)."""
|
| 27 |
+
subdir = _DATASET_IMAGE_SUBDIRS.get(str(dataset), str(dataset))
|
| 28 |
+
return os.path.join(datasets_root, subdir)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class DatasetCachePaths:
|
| 33 |
+
dataset: str
|
| 34 |
+
cache_dir: str
|
| 35 |
+
dataset_cache_dir: str
|
| 36 |
+
acts_cache_path: str
|
| 37 |
+
corr_group_cache_path: str
|
| 38 |
+
corr_type_cache_path: str
|
| 39 |
+
corr_group_patch_cache_path: str
|
| 40 |
+
corr_type_patch_cache_path: str
|
| 41 |
+
mi_group_cache_path: str
|
| 42 |
+
mi_type_cache_path: str
|
| 43 |
+
mi_group_patch_cache_path: str
|
| 44 |
+
mi_type_patch_cache_path: str
|
| 45 |
+
auc_group_cache_path: str
|
| 46 |
+
auc_type_cache_path: str
|
| 47 |
+
auc_group_patch_cache_path: str
|
| 48 |
+
auc_type_patch_cache_path: str
|
| 49 |
+
precision_group_cache_path: str
|
| 50 |
+
precision_type_cache_path: str
|
| 51 |
+
precision_group_patch_cache_path: str
|
| 52 |
+
precision_type_patch_cache_path: str
|
| 53 |
+
recall_group_cache_path: str
|
| 54 |
+
recall_type_cache_path: str
|
| 55 |
+
recall_group_patch_cache_path: str
|
| 56 |
+
recall_type_patch_cache_path: str
|
| 57 |
+
iou_group_cache_path: str
|
| 58 |
+
iou_type_cache_path: str
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class SaeVisConfig:
|
| 63 |
+
"""Runtime configuration loaded from SAE vis JSON (+ optional sae_config.json)."""
|
| 64 |
+
|
| 65 |
+
SAE_CHECKPOINT_PATH: str
|
| 66 |
+
SAE_CONFIG_PATH: str
|
| 67 |
+
DATASETS_ROOT: str
|
| 68 |
+
IN_DIM: int
|
| 69 |
+
INNER_DIM: int
|
| 70 |
+
LAYER_NUM: int
|
| 71 |
+
IQA_METRIC: str
|
| 72 |
+
SWIN_NUM: int
|
| 73 |
+
CROP_SIZE: int
|
| 74 |
+
DOWNSCALE_FACTOR: int
|
| 75 |
+
KADID_MIN_DISTORTION_LEVEL: int
|
| 76 |
+
KADID_MAX_DISTORTION_LEVEL: int
|
| 77 |
+
SCALING_FACTOR: float
|
| 78 |
+
BATCH_SIZE: int
|
| 79 |
+
NUM_WORKERS: int
|
| 80 |
+
DEVICE: str
|
| 81 |
+
ACTIVATION_STEPS_TO_KEEP: List[int]
|
| 82 |
+
CORR_TOP_K: int
|
| 83 |
+
HEATMAP_CRITERION: str
|
| 84 |
+
N_TOP_FEATURES_HEATMAPS: int
|
| 85 |
+
N_IMAGES_PER_FEATURE: int
|
| 86 |
+
HEATMAP_AGGREGATIONS: List[str]
|
| 87 |
+
FEATURE_FILTERS: List[dict]
|
| 88 |
+
SELECTOR_CONFIGS: List[dict]
|
| 89 |
+
SCATTER_TOP_K_PATCHES: int
|
| 90 |
+
SCATTER_GROUP_NAME: str
|
| 91 |
+
SCATTER_BACKEND: str
|
| 92 |
+
SCATTER_METRICS: List[str]
|
| 93 |
+
SCATTER_ACTIVATION_THRESHOLD: float
|
| 94 |
+
SCATTER_METRIC_LEVEL: str
|
| 95 |
+
BUILD_CACHE_IF_MISSING: bool
|
| 96 |
+
SAVE_TABULAR_ARTIFACTS: bool
|
| 97 |
+
SHOW_PROGRESS_BARS: bool
|
| 98 |
+
CACHE_DIR: str
|
| 99 |
+
DATASET: str
|
| 100 |
+
SUPPORTED_DATASETS: Tuple[str, ...]
|
| 101 |
+
DATASET_CACHE_CONFIGS: Dict[str, DatasetCachePaths]
|
| 102 |
+
CACHE_PATHS: DatasetCachePaths
|
| 103 |
+
KADID_IMAGES_PATH: str
|
| 104 |
+
config_path: str = field(repr=False, compare=False, default='')
|
| 105 |
+
|
| 106 |
+
def as_cache_params(self) -> Dict[str, object]:
|
| 107 |
+
"""Dict for ensure_activation_cache / build_activation_cache call sites."""
|
| 108 |
+
return {
|
| 109 |
+
'SAE_CHECKPOINT_PATH': self.SAE_CHECKPOINT_PATH,
|
| 110 |
+
'LAYER_NUM': self.LAYER_NUM,
|
| 111 |
+
'IQA_METRIC': self.IQA_METRIC,
|
| 112 |
+
'SWIN_NUM': self.SWIN_NUM,
|
| 113 |
+
'DEVICE': self.DEVICE,
|
| 114 |
+
'BATCH_SIZE': self.BATCH_SIZE,
|
| 115 |
+
'NUM_WORKERS': self.NUM_WORKERS,
|
| 116 |
+
'CROP_SIZE': self.CROP_SIZE,
|
| 117 |
+
'SCALING_FACTOR': self.SCALING_FACTOR,
|
| 118 |
+
'KADID_MAX_DISTORTION_LEVEL': self.KADID_MAX_DISTORTION_LEVEL,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _read_json_object(path: str) -> Dict[str, object]:
|
| 123 |
+
if not os.path.exists(path):
|
| 124 |
+
raise FileNotFoundError(
|
| 125 |
+
f'SAE visualization config not found: {path}. '
|
| 126 |
+
f'Please create JSON config or set SAE_VIS_CONFIG_PATH.'
|
| 127 |
+
)
|
| 128 |
+
with open(path, 'r', encoding='utf-8') as f:
|
| 129 |
+
payload = json.load(f)
|
| 130 |
+
if not isinstance(payload, dict):
|
| 131 |
+
raise ValueError(f'SAE visualization config must be a JSON object: {path}')
|
| 132 |
+
return payload
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _cfg_get(cfg: Mapping[str, object], key: str, default: object = None) -> object:
|
| 136 |
+
return cfg.get(key, default)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def resolve_sae_config_path(checkpoint_path: str) -> str:
|
| 140 |
+
if os.path.isdir(checkpoint_path):
|
| 141 |
+
return os.path.join(os.path.dirname(checkpoint_path), 'sae_config.json')
|
| 142 |
+
return os.path.join(os.path.dirname(os.path.dirname(checkpoint_path)), 'sae_config.json')
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def build_dataset_cache_paths(
|
| 146 |
+
cache_dir: str,
|
| 147 |
+
datasets: Sequence[str],
|
| 148 |
+
) -> Dict[str, DatasetCachePaths]:
|
| 149 |
+
dataset_cache_configs: Dict[str, DatasetCachePaths] = {}
|
| 150 |
+
acts_filenames = {
|
| 151 |
+
'kadid10k': 'kadid_acts.feather',
|
| 152 |
+
'local_kadid': 'local_kadid_acts.feather',
|
| 153 |
+
'QGround': 'QGround_acts.feather',
|
| 154 |
+
'SRGround': 'SRGround_acts.feather',
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
common_cache_filenames = {
|
| 158 |
+
'corr_group': 'corr_group.parquet',
|
| 159 |
+
'corr_type': 'corr_type.parquet',
|
| 160 |
+
'corr_group_patch': 'corr_group_patch.parquet',
|
| 161 |
+
'corr_type_patch': 'corr_type_patch.parquet',
|
| 162 |
+
'mi_group': 'mi_group.parquet',
|
| 163 |
+
'mi_type': 'mi_type.parquet',
|
| 164 |
+
'mi_group_patch': 'mi_group_patch.parquet',
|
| 165 |
+
'mi_type_patch': 'mi_type_patch.parquet',
|
| 166 |
+
'auc_group': 'auc_group.parquet',
|
| 167 |
+
'auc_type': 'auc_type.parquet',
|
| 168 |
+
'auc_group_patch': 'auc_group_patch.parquet',
|
| 169 |
+
'auc_type_patch': 'auc_type_patch.parquet',
|
| 170 |
+
'precision_group': 'precision_group.parquet',
|
| 171 |
+
'precision_type': 'precision_type.parquet',
|
| 172 |
+
'precision_group_patch': 'precision_group_patch.parquet',
|
| 173 |
+
'precision_type_patch': 'precision_type_patch.parquet',
|
| 174 |
+
'recall_group': 'recall_group.parquet',
|
| 175 |
+
'recall_type': 'recall_type.parquet',
|
| 176 |
+
'recall_group_patch': 'recall_group_patch.parquet',
|
| 177 |
+
'recall_type_patch': 'recall_type_patch.parquet',
|
| 178 |
+
'iou_group': 'iou_group.parquet',
|
| 179 |
+
'iou_type': 'iou_type.parquet',
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
for dataset in datasets:
|
| 183 |
+
dataset_cache_dir = os.path.join(cache_dir, dataset)
|
| 184 |
+
acts_filename = acts_filenames.get(dataset, f'{dataset}_acts.feather')
|
| 185 |
+
|
| 186 |
+
dataset_paths: Dict[str, str] = {
|
| 187 |
+
'acts': os.path.join(dataset_cache_dir, acts_filename),
|
| 188 |
+
}
|
| 189 |
+
for key, filename in common_cache_filenames.items():
|
| 190 |
+
dataset_paths[key] = os.path.join(dataset_cache_dir, filename)
|
| 191 |
+
|
| 192 |
+
dataset_cache_configs[dataset] = DatasetCachePaths(
|
| 193 |
+
dataset=dataset,
|
| 194 |
+
cache_dir=cache_dir,
|
| 195 |
+
dataset_cache_dir=dataset_cache_dir,
|
| 196 |
+
acts_cache_path=dataset_paths['acts'],
|
| 197 |
+
corr_group_cache_path=dataset_paths['corr_group'],
|
| 198 |
+
corr_type_cache_path=dataset_paths['corr_type'],
|
| 199 |
+
corr_group_patch_cache_path=dataset_paths['corr_group_patch'],
|
| 200 |
+
corr_type_patch_cache_path=dataset_paths['corr_type_patch'],
|
| 201 |
+
mi_group_cache_path=dataset_paths['mi_group'],
|
| 202 |
+
mi_type_cache_path=dataset_paths['mi_type'],
|
| 203 |
+
mi_group_patch_cache_path=dataset_paths['mi_group_patch'],
|
| 204 |
+
mi_type_patch_cache_path=dataset_paths['mi_type_patch'],
|
| 205 |
+
auc_group_cache_path=dataset_paths['auc_group'],
|
| 206 |
+
auc_type_cache_path=dataset_paths['auc_type'],
|
| 207 |
+
auc_group_patch_cache_path=dataset_paths['auc_group_patch'],
|
| 208 |
+
auc_type_patch_cache_path=dataset_paths['auc_type_patch'],
|
| 209 |
+
precision_group_cache_path=dataset_paths['precision_group'],
|
| 210 |
+
precision_type_cache_path=dataset_paths['precision_type'],
|
| 211 |
+
precision_group_patch_cache_path=dataset_paths['precision_group_patch'],
|
| 212 |
+
precision_type_patch_cache_path=dataset_paths['precision_type_patch'],
|
| 213 |
+
recall_group_cache_path=dataset_paths['recall_group'],
|
| 214 |
+
recall_type_cache_path=dataset_paths['recall_type'],
|
| 215 |
+
recall_group_patch_cache_path=dataset_paths['recall_group_patch'],
|
| 216 |
+
recall_type_patch_cache_path=dataset_paths['recall_type_patch'],
|
| 217 |
+
iou_group_cache_path=dataset_paths['iou_group'],
|
| 218 |
+
iou_type_cache_path=dataset_paths['iou_type'],
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
return dataset_cache_configs
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def load_sae_vis_config(path: str | None = None) -> SaeVisConfig:
|
| 225 |
+
"""Load and validate SAE visualization config from JSON."""
|
| 226 |
+
config_path = path or os.environ.get('SAE_VIS_CONFIG_PATH', DEFAULT_SAE_VIS_CONFIG_PATH)
|
| 227 |
+
# config_path = "/home/28m_gov@lab.graphicon.ru/SAE/xIQA/logs/arniqa_logs/ARNIQA_layer5_lambda5e-4_scale1_exp37/vis/vis_srground/config.json"
|
| 228 |
+
vis_cfg = _read_json_object(config_path)
|
| 229 |
+
|
| 230 |
+
sae_checkpoint_path = str(_cfg_get(vis_cfg, 'SAE_CHECKPOINT_PATH', '')).strip()
|
| 231 |
+
if not sae_checkpoint_path:
|
| 232 |
+
raise ValueError('SAE_CHECKPOINT_PATH must be set in SAE vis config JSON')
|
| 233 |
+
|
| 234 |
+
sae_config_path = resolve_sae_config_path(sae_checkpoint_path)
|
| 235 |
+
sae_cfg: Dict[str, object] = _read_json_object(sae_config_path) if os.path.exists(sae_config_path) else {}
|
| 236 |
+
|
| 237 |
+
datasets_root = str(_cfg_get(vis_cfg, 'DATASETS_ROOT', '')).strip()
|
| 238 |
+
if not datasets_root:
|
| 239 |
+
legacy_kadid_images_path = str(_cfg_get(vis_cfg, 'KADID_IMAGES_PATH', '')).strip()
|
| 240 |
+
if legacy_kadid_images_path:
|
| 241 |
+
datasets_root = os.path.dirname(legacy_kadid_images_path)
|
| 242 |
+
if not datasets_root:
|
| 243 |
+
raise ValueError('DATASETS_ROOT must be set in SAE vis config JSON')
|
| 244 |
+
|
| 245 |
+
kadid_min_distortion_level = int(_cfg_get(vis_cfg, 'KADID_MIN_DISTORTION_LEVEL', 1))
|
| 246 |
+
kadid_max_distortion_level = int(_cfg_get(vis_cfg, 'KADID_MAX_DISTORTION_LEVEL', 5))
|
| 247 |
+
if not (1 <= kadid_min_distortion_level <= 5):
|
| 248 |
+
raise ValueError('KADID_MIN_DISTORTION_LEVEL must be in [1, 5]')
|
| 249 |
+
if not (1 <= kadid_max_distortion_level <= 5):
|
| 250 |
+
raise ValueError('KADID_MAX_DISTORTION_LEVEL must be in [1, 5]')
|
| 251 |
+
if kadid_min_distortion_level > kadid_max_distortion_level:
|
| 252 |
+
raise ValueError('KADID_MIN_DISTORTION_LEVEL must be <= KADID_MAX_DISTORTION_LEVEL')
|
| 253 |
+
|
| 254 |
+
activation_steps_to_keep = [int(step) for step in _cfg_get(vis_cfg, 'ACTIVATION_STEPS_TO_KEEP', [])]
|
| 255 |
+
if any(step <= 0 for step in activation_steps_to_keep):
|
| 256 |
+
raise ValueError('ACTIVATION_STEPS_TO_KEEP must contain only positive integers')
|
| 257 |
+
|
| 258 |
+
dataset = str(_cfg_get(vis_cfg, 'DATASET', 'kadid10k')).strip()
|
| 259 |
+
supported_raw = _cfg_get(vis_cfg, 'SUPPORTED_DATASETS', list(SUPPORTED_DATASETS))
|
| 260 |
+
supported_datasets = tuple(str(value) for value in supported_raw)
|
| 261 |
+
if dataset not in supported_datasets:
|
| 262 |
+
raise ValueError(f'Unsupported DATASET={dataset!r}; expected one of {supported_datasets}')
|
| 263 |
+
|
| 264 |
+
cache_dir = str(
|
| 265 |
+
_cfg_get(
|
| 266 |
+
vis_cfg,
|
| 267 |
+
'CACHE_DIR',
|
| 268 |
+
os.path.join(os.path.dirname(os.path.dirname(sae_checkpoint_path)), 'cache/'),
|
| 269 |
+
)
|
| 270 |
+
)
|
| 271 |
+
dataset_cache_configs = build_dataset_cache_paths(cache_dir, supported_datasets)
|
| 272 |
+
|
| 273 |
+
raw_selector_configs = _cfg_get(vis_cfg, 'SELECTOR_CONFIGS', [])
|
| 274 |
+
if raw_selector_configs is None:
|
| 275 |
+
raw_selector_configs = []
|
| 276 |
+
|
| 277 |
+
feature_filters = _cfg_get(
|
| 278 |
+
vis_cfg,
|
| 279 |
+
'FEATURE_FILTERS',
|
| 280 |
+
[
|
| 281 |
+
{'name': 'nonzero_max', 'params': {}},
|
| 282 |
+
{
|
| 283 |
+
'name': 'kruskal_wallis',
|
| 284 |
+
'params': {'alpha': 0.05, 'group_col': 'dist_type', 'min_group_size': 3},
|
| 285 |
+
},
|
| 286 |
+
],
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
dataset_images_subdir = _DATASET_IMAGE_SUBDIRS[dataset]
|
| 290 |
+
|
| 291 |
+
return SaeVisConfig(
|
| 292 |
+
SAE_CHECKPOINT_PATH=sae_checkpoint_path,
|
| 293 |
+
SAE_CONFIG_PATH=sae_config_path,
|
| 294 |
+
DATASETS_ROOT=datasets_root,
|
| 295 |
+
IN_DIM=int(_cfg_get(sae_cfg, 'sae_input_dim', 64)),
|
| 296 |
+
INNER_DIM=int(_cfg_get(sae_cfg, 'inner_dim', 6400)),
|
| 297 |
+
LAYER_NUM=int(_cfg_get(sae_cfg, 'layer_num', 3)),
|
| 298 |
+
IQA_METRIC=str(_cfg_get(sae_cfg, 'iqa_metric', 'arniqa-kadid')),
|
| 299 |
+
SWIN_NUM=int(_cfg_get(sae_cfg, 'swin_num', 2)),
|
| 300 |
+
CROP_SIZE=int(_cfg_get(vis_cfg, 'CROP_SIZE', 224)),
|
| 301 |
+
DOWNSCALE_FACTOR=int(_cfg_get(vis_cfg, 'DOWNSCALE_FACTOR', 2)),
|
| 302 |
+
KADID_MIN_DISTORTION_LEVEL=kadid_min_distortion_level,
|
| 303 |
+
KADID_MAX_DISTORTION_LEVEL=kadid_max_distortion_level,
|
| 304 |
+
SCALING_FACTOR=float(_cfg_get(sae_cfg, 'scaling_factor', 1.0)),
|
| 305 |
+
BATCH_SIZE=int(_cfg_get(vis_cfg, 'BATCH_SIZE', 32)),
|
| 306 |
+
NUM_WORKERS=int(_cfg_get(vis_cfg, 'NUM_WORKERS', 4)),
|
| 307 |
+
DEVICE=str(_cfg_get(vis_cfg, 'DEVICE', 'cuda')),
|
| 308 |
+
ACTIVATION_STEPS_TO_KEEP=activation_steps_to_keep,
|
| 309 |
+
CORR_TOP_K=int(_cfg_get(vis_cfg, 'CORR_TOP_K', 30)),
|
| 310 |
+
HEATMAP_CRITERION=str(_cfg_get(vis_cfg, 'HEATMAP_CRITERION', 'max')),
|
| 311 |
+
N_TOP_FEATURES_HEATMAPS=int(_cfg_get(vis_cfg, 'N_TOP_FEATURES_HEATMAPS', 3)),
|
| 312 |
+
N_IMAGES_PER_FEATURE=int(_cfg_get(vis_cfg, 'N_IMAGES_PER_FEATURE', 5)),
|
| 313 |
+
HEATMAP_AGGREGATIONS=[str(value) for value in _cfg_get(vis_cfg, 'HEATMAP_AGGREGATIONS', ['max', 'mean_acts', 'sum'])],
|
| 314 |
+
FEATURE_FILTERS=list(feature_filters),
|
| 315 |
+
SELECTOR_CONFIGS=list(raw_selector_configs),
|
| 316 |
+
SCATTER_TOP_K_PATCHES=int(_cfg_get(vis_cfg, 'SCATTER_TOP_K_PATCHES', 1000)),
|
| 317 |
+
SCATTER_GROUP_NAME=str(_cfg_get(vis_cfg, 'SCATTER_GROUP_NAME', 'blur')),
|
| 318 |
+
SCATTER_BACKEND=str(_cfg_get(vis_cfg, 'SCATTER_BACKEND', 'matplotlib')),
|
| 319 |
+
SCATTER_METRICS=[str(value) for value in _cfg_get(vis_cfg, 'SCATTER_METRICS', ['entropy', 'iou', 'roc_auc', 'precision', 'recall'])],
|
| 320 |
+
SCATTER_ACTIVATION_THRESHOLD=float(_cfg_get(vis_cfg, 'SCATTER_ACTIVATION_THRESHOLD', 0.0)),
|
| 321 |
+
SCATTER_METRIC_LEVEL=str(_cfg_get(vis_cfg, 'SCATTER_METRIC_LEVEL', 'patch')),
|
| 322 |
+
BUILD_CACHE_IF_MISSING=bool(_cfg_get(vis_cfg, 'BUILD_CACHE_IF_MISSING', True)),
|
| 323 |
+
SAVE_TABULAR_ARTIFACTS=bool(_cfg_get(vis_cfg, 'SAVE_TABULAR_ARTIFACTS', False)),
|
| 324 |
+
SHOW_PROGRESS_BARS=bool(_cfg_get(vis_cfg, 'SHOW_PROGRESS_BARS', True)),
|
| 325 |
+
CACHE_DIR=cache_dir,
|
| 326 |
+
DATASET=dataset,
|
| 327 |
+
SUPPORTED_DATASETS=supported_datasets,
|
| 328 |
+
DATASET_CACHE_CONFIGS=dataset_cache_configs,
|
| 329 |
+
CACHE_PATHS=dataset_cache_configs[dataset],
|
| 330 |
+
KADID_IMAGES_PATH=os.path.join(datasets_root, dataset_images_subdir),
|
| 331 |
+
config_path=config_path,
|
| 332 |
+
)
|
analysis/datasets.py
ADDED
|
@@ -0,0 +1,1148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Датасеты и вспомогательные структуры данных для KADID-10k. Взяты из репозитория PatchSAE.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import gzip
|
| 6 |
+
import re
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List, Optional, Sequence
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import torch
|
| 16 |
+
from PIL import Image, ImageColor
|
| 17 |
+
from scipy.special import softmax
|
| 18 |
+
from torch.utils.data import Dataset
|
| 19 |
+
from torchvision import transforms
|
| 20 |
+
|
| 21 |
+
distortion_types_mapping = {
|
| 22 |
+
1: "gaussian_blur",
|
| 23 |
+
2: "lens_blur",
|
| 24 |
+
3: "motion_blur",
|
| 25 |
+
4: "color_diffusion",
|
| 26 |
+
5: "color_shift",
|
| 27 |
+
6: "color_quantization",
|
| 28 |
+
7: "color_saturation_1",
|
| 29 |
+
8: "color_saturation_2",
|
| 30 |
+
9: "jpeg2000",
|
| 31 |
+
10: "jpeg",
|
| 32 |
+
11: "white_noise",
|
| 33 |
+
12: "white_noise_color_component",
|
| 34 |
+
13: "impulse_noise",
|
| 35 |
+
14: "multiplicative_noise",
|
| 36 |
+
15: "denoise",
|
| 37 |
+
16: "brighten",
|
| 38 |
+
17: "darken",
|
| 39 |
+
18: "mean_shift",
|
| 40 |
+
19: "jitter",
|
| 41 |
+
20: "non_eccentricity_patch",
|
| 42 |
+
21: "pixelate",
|
| 43 |
+
22: "quantization",
|
| 44 |
+
23: "color_block",
|
| 45 |
+
24: "high_sharpen",
|
| 46 |
+
25: "contrast_change",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
available_distortions = {
|
| 50 |
+
"gaussian_blur": "blur",
|
| 51 |
+
"lens_blur": "blur",
|
| 52 |
+
"motion_blur": "blur",
|
| 53 |
+
"color_diffusion": "color_distortion",
|
| 54 |
+
"color_shift": "color_distortion",
|
| 55 |
+
"color_quantization": "color_distortion",
|
| 56 |
+
"color_saturation_1": "color_distortion",
|
| 57 |
+
"color_saturation_2": "color_distortion",
|
| 58 |
+
"jpeg2000": "jpeg",
|
| 59 |
+
"jpeg": "jpeg",
|
| 60 |
+
"white_noise": "noise",
|
| 61 |
+
"white_noise_color_component": "noise",
|
| 62 |
+
"impulse_noise": "noise",
|
| 63 |
+
"multiplicative_noise": "noise",
|
| 64 |
+
"denoise": "noise",
|
| 65 |
+
"brighten": "brightness_change",
|
| 66 |
+
"darken": "brightness_change",
|
| 67 |
+
"mean_shift": "brightness_change",
|
| 68 |
+
"jitter": "spatial_distortion",
|
| 69 |
+
"non_eccentricity_patch": "spatial_distortion",
|
| 70 |
+
"pixelate": "spatial_distortion",
|
| 71 |
+
"quantization": "spatial_distortion",
|
| 72 |
+
"color_block": "spatial_distortion",
|
| 73 |
+
"high_sharpen": "sharpness_contrast",
|
| 74 |
+
"contrast_change": "sharpness_contrast",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
distortion_groups = {
|
| 78 |
+
"blur": ["gaussian_blur", "lens_blur", "motion_blur"],
|
| 79 |
+
"color_distortion": ["color_diffusion", "color_shift", "color_quantization",
|
| 80 |
+
"color_saturation_1", "color_saturation_2"],
|
| 81 |
+
"jpeg": ["jpeg2000", "jpeg"],
|
| 82 |
+
"noise": ["white_noise", "white_noise_color_component", "impulse_noise",
|
| 83 |
+
"multiplicative_noise", "denoise"],
|
| 84 |
+
"brightness_change": ["brighten", "darken", "mean_shift"],
|
| 85 |
+
"spatial_distortion": ["jitter", "non_eccentricity_patch", "pixelate",
|
| 86 |
+
"quantization", "color_block"],
|
| 87 |
+
"sharpness_contrast": ["high_sharpen", "contrast_change"],
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
QGROUND_DISTORTION_TYPES = {
|
| 91 |
+
'jitter': np.array(ImageColor.getrgb('#4b54e1')),
|
| 92 |
+
'noise': np.array(ImageColor.getrgb('#93fff0')),
|
| 93 |
+
'overexposure': np.array(ImageColor.getrgb('#cde55d')),
|
| 94 |
+
'blur': np.array(ImageColor.getrgb('#e45c5c')),
|
| 95 |
+
'low light': np.array(ImageColor.getrgb('#35e344')),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
distortion_types_mapping_qground = {
|
| 99 |
+
0: 'background',
|
| 100 |
+
1: 'jitter',
|
| 101 |
+
2: 'noise',
|
| 102 |
+
3: 'overexposure',
|
| 103 |
+
4: 'blur',
|
| 104 |
+
5: 'low light',
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
available_distortions_qground = {
|
| 108 |
+
'background': 'background',
|
| 109 |
+
'jitter': 'jitter',
|
| 110 |
+
'noise': 'noise',
|
| 111 |
+
'overexposure': 'overexposure',
|
| 112 |
+
'blur': 'blur',
|
| 113 |
+
'low light': 'low light',
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
SRGROUND_CLASS_ORDER = (
|
| 117 |
+
'no_distortion',
|
| 118 |
+
'blur',
|
| 119 |
+
'jitter',
|
| 120 |
+
'lowlight',
|
| 121 |
+
'noise',
|
| 122 |
+
'overexposure',
|
| 123 |
+
'sr_artifact',
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
SRGROUND_DISTORTION_TYPES = {
|
| 127 |
+
'blur': np.array(ImageColor.getrgb('#e45c5c')),
|
| 128 |
+
'jitter': np.array(ImageColor.getrgb('#4b54e1')),
|
| 129 |
+
'lowlight': np.array(ImageColor.getrgb('#35e344')),
|
| 130 |
+
'noise': np.array(ImageColor.getrgb('#93fff0')),
|
| 131 |
+
'overexposure': np.array(ImageColor.getrgb('#cde55d')),
|
| 132 |
+
'sr_artifact': np.array(ImageColor.getrgb('#c000a0')),
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
distortion_types_mapping_srground = {
|
| 136 |
+
0: 'background',
|
| 137 |
+
1: 'blur',
|
| 138 |
+
2: 'jitter',
|
| 139 |
+
3: 'lowlight',
|
| 140 |
+
4: 'noise',
|
| 141 |
+
5: 'overexposure',
|
| 142 |
+
6: 'sr_artifact',
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
available_distortions_srground = {
|
| 146 |
+
'background': 'background',
|
| 147 |
+
'blur': 'blur',
|
| 148 |
+
'jitter': 'jitter',
|
| 149 |
+
'lowlight': 'lowlight',
|
| 150 |
+
'noise': 'noise',
|
| 151 |
+
'overexposure': 'overexposure',
|
| 152 |
+
'sr_artifact': 'sr_artifact',
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
SRGROUND_SR_ARTIFACT_THRESHOLD = 0.3
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _load_npy_gz(path: Path) -> np.ndarray:
|
| 159 |
+
with gzip.open(path, 'rb') as handle:
|
| 160 |
+
return np.load(handle)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _real_distortion_labels(
|
| 164 |
+
real_maps: np.ndarray,
|
| 165 |
+
prominences: Optional[object] = None,
|
| 166 |
+
) -> np.ndarray:
|
| 167 |
+
real_maps = np.asarray(real_maps, dtype=np.float64)
|
| 168 |
+
real_prom = np.array(prominences)[:-1, None, None]
|
| 169 |
+
real_prob = softmax(real_maps, axis=0) * real_prom
|
| 170 |
+
return np.argmax(real_prob, axis=0).astype(np.uint8)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _sr_artifact_labels(
|
| 174 |
+
sr_maps: np.ndarray,
|
| 175 |
+
prominences: Optional[object] = None,
|
| 176 |
+
threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
|
| 177 |
+
) -> np.ndarray:
|
| 178 |
+
sr_maps = np.asarray(sr_maps, dtype=np.float64)
|
| 179 |
+
if sr_maps.ndim == 3:
|
| 180 |
+
if sr_maps.shape[0] == 1:
|
| 181 |
+
sr_maps = sr_maps[0]
|
| 182 |
+
else:
|
| 183 |
+
raise ValueError(f'Expected SR artifact maps with shape (1, H, W) or (H, W), got {sr_maps.shape}')
|
| 184 |
+
|
| 185 |
+
sr_prom = prominences[-1]
|
| 186 |
+
return np.where(sr_maps * sr_prom >= threshold, 6, 0).astype(np.uint8)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def merge_srground_masks(annot_rd: np.ndarray, annot_sr: np.ndarray) -> np.ndarray:
|
| 190 |
+
return np.where(annot_sr == 6, 6, annot_rd).astype(np.uint8)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def label2rgb_srground(mask_label: np.ndarray) -> np.ndarray:
|
| 194 |
+
mask_rgb = np.zeros(mask_label.shape + (3,), dtype=np.uint8)
|
| 195 |
+
for label_id, dist_name in distortion_types_mapping_srground.items():
|
| 196 |
+
if label_id == 0:
|
| 197 |
+
continue
|
| 198 |
+
mask_rgb[mask_label == label_id] = SRGROUND_DISTORTION_TYPES[dist_name]
|
| 199 |
+
return mask_rgb
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
SRGROUND_LEGEND_LABELS = {
|
| 203 |
+
'blur': 'blur',
|
| 204 |
+
'jitter': 'jitter',
|
| 205 |
+
'lowlight': 'low light',
|
| 206 |
+
'noise': 'noise',
|
| 207 |
+
'overexposure': 'overexposure',
|
| 208 |
+
'sr_artifact': 'SR artifact',
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _parse_prominences(value: object) -> np.ndarray | None:
|
| 213 |
+
"""Convert a prominences field from JSON/CSV into a 1D float array.
|
| 214 |
+
|
| 215 |
+
Does not read any files — callers load ``prominences`` from ``srground_train.json``
|
| 216 |
+
(or another source) and pass the cell value here.
|
| 217 |
+
"""
|
| 218 |
+
if value is None:
|
| 219 |
+
return None
|
| 220 |
+
if isinstance(value, float) and np.isnan(value):
|
| 221 |
+
return None
|
| 222 |
+
if isinstance(value, str):
|
| 223 |
+
text = value.strip()
|
| 224 |
+
if not text:
|
| 225 |
+
return None
|
| 226 |
+
try:
|
| 227 |
+
value = json.loads(text)
|
| 228 |
+
except json.JSONDecodeError:
|
| 229 |
+
return None
|
| 230 |
+
try:
|
| 231 |
+
return np.asarray(value, dtype=np.float64)
|
| 232 |
+
except (TypeError, ValueError):
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _center_crop_label_map(mask_label: np.ndarray, crop_size: int, reference_size: tuple[int, int]) -> np.ndarray:
|
| 237 |
+
"""Center-crop a label map to match heatmap preprocessing (reference_size is W×H)."""
|
| 238 |
+
width, height = reference_size
|
| 239 |
+
mask_img = Image.fromarray(mask_label.astype(np.uint8), mode='L')
|
| 240 |
+
if mask_label.shape[:2] != (height, width):
|
| 241 |
+
mask_img = mask_img.resize((width, height), resample=Image.NEAREST)
|
| 242 |
+
return np.asarray(transforms.CenterCrop(int(crop_size))(mask_img), dtype=np.uint8)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@lru_cache(maxsize=8)
|
| 246 |
+
def _srground_train_dataframe(datasets_root: str) -> pd.DataFrame:
|
| 247 |
+
from analysis.config import dataset_images_root as _dataset_images_root
|
| 248 |
+
|
| 249 |
+
root = Path(_dataset_images_root(datasets_root, 'SRGround'))
|
| 250 |
+
return pd.read_json(root / 'srground_train.json')
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _resolve_datasets_root(datasets_root: str | None) -> str:
|
| 254 |
+
if datasets_root is not None:
|
| 255 |
+
return str(datasets_root)
|
| 256 |
+
from analysis.config import load_sae_vis_config
|
| 257 |
+
|
| 258 |
+
return str(load_sae_vis_config().DATASETS_ROOT)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def srground_image_key(path: str | Path, *, datasets_root: str | None = None) -> str:
|
| 262 |
+
"""Normalize a path to ``image_path`` keys used in ``srground_train.json`` (relative to SRGround root)."""
|
| 263 |
+
from analysis.config import dataset_images_root
|
| 264 |
+
|
| 265 |
+
root = _resolve_datasets_root(datasets_root)
|
| 266 |
+
path_obj = Path(path)
|
| 267 |
+
sr_root = Path(dataset_images_root(root, 'SRGround'))
|
| 268 |
+
if path_obj.is_absolute():
|
| 269 |
+
return _to_relative_dataset_path(path_obj, sr_root)
|
| 270 |
+
return path_obj.as_posix()
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@lru_cache(maxsize=8)
|
| 274 |
+
def srground_prominences_index(datasets_root: str | None = None) -> dict[str, np.ndarray]:
|
| 275 |
+
"""Cached ``image_path`` → prominences array from ``srground_train.json``."""
|
| 276 |
+
root = _resolve_datasets_root(datasets_root)
|
| 277 |
+
df = _srground_train_dataframe(root)
|
| 278 |
+
out: dict[str, np.ndarray] = {}
|
| 279 |
+
for image_path, raw_prom in zip(df['image_path'].astype(str), df['prominences']):
|
| 280 |
+
prom = _parse_prominences(raw_prom)
|
| 281 |
+
if prom is not None:
|
| 282 |
+
out[str(image_path)] = prom
|
| 283 |
+
return out
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _image_rel_from_meta_row(meta_row: pd.Series | None) -> str | None:
|
| 287 |
+
if meta_row is None:
|
| 288 |
+
return None
|
| 289 |
+
for column in ('image_path', 'distorted_img_path'):
|
| 290 |
+
if column not in meta_row:
|
| 291 |
+
continue
|
| 292 |
+
value = meta_row.get(column)
|
| 293 |
+
if value is None or (isinstance(value, float) and np.isnan(value)):
|
| 294 |
+
continue
|
| 295 |
+
text = str(value).strip()
|
| 296 |
+
if text:
|
| 297 |
+
return text
|
| 298 |
+
return None
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def srground_rows_for_image_paths(
|
| 302 |
+
image_paths: Sequence[str],
|
| 303 |
+
*,
|
| 304 |
+
datasets_root: str | None = None,
|
| 305 |
+
) -> pd.DataFrame:
|
| 306 |
+
"""Subset of ``srground_train.json`` for the given ``image_path`` keys."""
|
| 307 |
+
if not image_paths:
|
| 308 |
+
return pd.DataFrame()
|
| 309 |
+
|
| 310 |
+
root = _resolve_datasets_root(datasets_root)
|
| 311 |
+
keys = {srground_image_key(path, datasets_root=root) for path in image_paths if path}
|
| 312 |
+
if not keys:
|
| 313 |
+
return pd.DataFrame()
|
| 314 |
+
|
| 315 |
+
df = _srground_train_dataframe(root)
|
| 316 |
+
return df[df['image_path'].astype(str).isin(keys)].copy()
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def srground_prominences_by_image_paths(
|
| 320 |
+
image_paths: Sequence[str],
|
| 321 |
+
*,
|
| 322 |
+
datasets_root: str | None = None,
|
| 323 |
+
) -> dict[str, np.ndarray]:
|
| 324 |
+
"""Look up prominences for paths (absolute or SRGround-relative) via cached index."""
|
| 325 |
+
if not image_paths:
|
| 326 |
+
return {}
|
| 327 |
+
|
| 328 |
+
root = _resolve_datasets_root(datasets_root)
|
| 329 |
+
index = srground_prominences_index(root)
|
| 330 |
+
keys = {srground_image_key(path, datasets_root=root) for path in image_paths if path}
|
| 331 |
+
return {key: index[key] for key in keys if key in index}
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def srground_mask_rgb_for_meta_row(
|
| 335 |
+
meta_row: pd.Series | None,
|
| 336 |
+
*,
|
| 337 |
+
datasets_root: str | None = None,
|
| 338 |
+
include_sr_artifact: bool = True,
|
| 339 |
+
crop_size: int = 224,
|
| 340 |
+
sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
|
| 341 |
+
prominences: np.ndarray | None = None,
|
| 342 |
+
) -> np.ndarray | None:
|
| 343 |
+
"""Build an RGB annotation mask for one SRGround image (matches dashboard center crop)."""
|
| 344 |
+
if meta_row is None:
|
| 345 |
+
return None
|
| 346 |
+
|
| 347 |
+
root = _resolve_datasets_root(datasets_root)
|
| 348 |
+
|
| 349 |
+
def _row_path(column: str) -> str | None:
|
| 350 |
+
if column not in meta_row:
|
| 351 |
+
return None
|
| 352 |
+
value = meta_row.get(column)
|
| 353 |
+
if value is None or (isinstance(value, float) and np.isnan(value)):
|
| 354 |
+
return None
|
| 355 |
+
text = str(value).strip()
|
| 356 |
+
return text or None
|
| 357 |
+
|
| 358 |
+
image_rel = _image_rel_from_meta_row(meta_row)
|
| 359 |
+
real_rel = _row_path('real_distortions_ann_path')
|
| 360 |
+
sr_rel = _row_path('sr_artifacts_ann_path')
|
| 361 |
+
if not real_rel and not (include_sr_artifact and sr_rel):
|
| 362 |
+
return None
|
| 363 |
+
|
| 364 |
+
if prominences is None and image_rel:
|
| 365 |
+
prominences = srground_prominences_by_image_paths([image_rel], datasets_root=root).get(image_rel)
|
| 366 |
+
|
| 367 |
+
annot_rd = None
|
| 368 |
+
annot_sr = None
|
| 369 |
+
if real_rel:
|
| 370 |
+
real_path = resolve_dataset_image_path('SRGround', real_rel, datasets_root=root)
|
| 371 |
+
if real_path.is_file():
|
| 372 |
+
real_maps = _load_npy_gz(real_path)
|
| 373 |
+
prom = prominences
|
| 374 |
+
if prom is None:
|
| 375 |
+
prom = np.ones(int(real_maps.shape[0]) + 1, dtype=np.float64)
|
| 376 |
+
annot_rd = _real_distortion_labels(real_maps, prom)
|
| 377 |
+
|
| 378 |
+
if include_sr_artifact and sr_rel:
|
| 379 |
+
sr_path = resolve_dataset_image_path('SRGround', sr_rel, datasets_root=root)
|
| 380 |
+
if sr_path.is_file():
|
| 381 |
+
sr_maps = _load_npy_gz(sr_path)
|
| 382 |
+
prom = prominences
|
| 383 |
+
if prom is None:
|
| 384 |
+
prom = np.ones(6, dtype=np.float64)
|
| 385 |
+
annot_sr = _sr_artifact_labels(
|
| 386 |
+
sr_maps,
|
| 387 |
+
prom,
|
| 388 |
+
threshold=sr_artifact_threshold,
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
if annot_rd is None and annot_sr is None:
|
| 392 |
+
return None
|
| 393 |
+
|
| 394 |
+
if annot_rd is None:
|
| 395 |
+
annot_rd = np.zeros_like(annot_sr, dtype=np.uint8)
|
| 396 |
+
if include_sr_artifact and annot_sr is not None:
|
| 397 |
+
merged = merge_srground_masks(annot_rd, annot_sr)
|
| 398 |
+
else:
|
| 399 |
+
merged = annot_rd.astype(np.uint8, copy=False)
|
| 400 |
+
|
| 401 |
+
if image_rel:
|
| 402 |
+
image_path = resolve_dataset_image_path('SRGround', image_rel, datasets_root=root)
|
| 403 |
+
if image_path.is_file():
|
| 404 |
+
with Image.open(image_path) as image:
|
| 405 |
+
reference_size = image.size
|
| 406 |
+
merged = _center_crop_label_map(merged, crop_size, reference_size)
|
| 407 |
+
else:
|
| 408 |
+
merged = _center_crop_label_map(merged, crop_size, (merged.shape[1], merged.shape[0]))
|
| 409 |
+
else:
|
| 410 |
+
merged = _center_crop_label_map(merged, crop_size, (merged.shape[1], merged.shape[0]))
|
| 411 |
+
|
| 412 |
+
return label2rgb_srground(merged)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _rgb2label_qground(mask_rgb: np.ndarray) -> np.ndarray:
|
| 416 |
+
mask_label = np.zeros(mask_rgb.shape[:2], dtype=np.uint8)
|
| 417 |
+
for label, rgb_code in enumerate(QGROUND_DISTORTION_TYPES.values(), start=1):
|
| 418 |
+
matches = np.isclose(mask_rgb, rgb_code, rtol=0.2, atol=20).all(axis=-1)
|
| 419 |
+
mask_label[matches] = label
|
| 420 |
+
return mask_label
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _label2rgb_qground(mask_label: np.ndarray) -> np.ndarray:
|
| 424 |
+
mask_rgb = np.zeros(mask_label.shape + (3,), dtype=np.uint8)
|
| 425 |
+
for label, rgb_code in enumerate(QGROUND_DISTORTION_TYPES.values(), start=1):
|
| 426 |
+
mask_rgb[mask_label == label] = rgb_code
|
| 427 |
+
return mask_rgb
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def _infer_kadid_original_path(distorted_path: Path) -> Path | None:
|
| 431 |
+
match = re.search(r'(I\d+)_\d+_\d+\.png$', distorted_path.name)
|
| 432 |
+
if match:
|
| 433 |
+
return distorted_path.with_name(f'{match.group(1)}.png')
|
| 434 |
+
return None
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _to_relative_dataset_path(path: Path, root: Path) -> str:
|
| 438 |
+
try:
|
| 439 |
+
return path.relative_to(root).as_posix()
|
| 440 |
+
except ValueError:
|
| 441 |
+
return path.as_posix()
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
class Kadid10kDataset(Dataset):
|
| 445 |
+
"""
|
| 446 |
+
KADID-10k dataset. При семплинге применяется RandomCrop.
|
| 447 |
+
"""
|
| 448 |
+
|
| 449 |
+
def __init__(
|
| 450 |
+
self,
|
| 451 |
+
root: str,
|
| 452 |
+
crop_size: int = 224,
|
| 453 |
+
min_distortion_level: int = 1,
|
| 454 |
+
transform=None,
|
| 455 |
+
):
|
| 456 |
+
self.root = Path(root)
|
| 457 |
+
self.crop_size = crop_size
|
| 458 |
+
self.mos_range = (1, 5)
|
| 459 |
+
self.min_distortion_level = int(min_distortion_level)
|
| 460 |
+
|
| 461 |
+
if not (1 <= self.min_distortion_level <= 5):
|
| 462 |
+
raise ValueError(
|
| 463 |
+
f"min_distortion_level must be in [1, 5], got {self.min_distortion_level}"
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
if transform is None:
|
| 467 |
+
self.transform = transforms.Compose([
|
| 468 |
+
transforms.RandomCrop(self.crop_size),
|
| 469 |
+
transforms.ToTensor(),
|
| 470 |
+
])
|
| 471 |
+
else:
|
| 472 |
+
self.transform = transform
|
| 473 |
+
|
| 474 |
+
scores_csv = pd.read_csv(self.root / "dmos.csv")
|
| 475 |
+
scores_csv = scores_csv[["dist_img", "dmos"]]
|
| 476 |
+
|
| 477 |
+
self.images = np.array([
|
| 478 |
+
self.root / "images" / el
|
| 479 |
+
for el in scores_csv["dist_img"].values.tolist()
|
| 480 |
+
])
|
| 481 |
+
self.mos = np.array(scores_csv["dmos"].values.tolist())
|
| 482 |
+
|
| 483 |
+
self.distortion_types = []
|
| 484 |
+
self.distortion_groups = []
|
| 485 |
+
self.distortion_levels = []
|
| 486 |
+
|
| 487 |
+
for image in self.images:
|
| 488 |
+
match = re.search(r'I\d+_(\d+)_(\d+)\.png$', str(image))
|
| 489 |
+
dist_type = distortion_types_mapping[int(match.group(1))]
|
| 490 |
+
self.distortion_types.append(dist_type)
|
| 491 |
+
self.distortion_groups.append(available_distortions[dist_type])
|
| 492 |
+
self.distortion_levels.append(int(match.group(2)))
|
| 493 |
+
|
| 494 |
+
self.distortion_types = np.array(self.distortion_types)
|
| 495 |
+
self.distortion_groups = np.array(self.distortion_groups)
|
| 496 |
+
self.distortion_levels = np.array(self.distortion_levels)
|
| 497 |
+
|
| 498 |
+
if self.min_distortion_level > 1:
|
| 499 |
+
keep_mask = self.distortion_levels >= self.min_distortion_level
|
| 500 |
+
self.images = self.images[keep_mask]
|
| 501 |
+
self.mos = self.mos[keep_mask]
|
| 502 |
+
self.distortion_types = self.distortion_types[keep_mask]
|
| 503 |
+
self.distortion_groups = self.distortion_groups[keep_mask]
|
| 504 |
+
self.distortion_levels = self.distortion_levels[keep_mask]
|
| 505 |
+
|
| 506 |
+
def __len__(self) -> int:
|
| 507 |
+
return len(self.images)
|
| 508 |
+
|
| 509 |
+
def __getitem__(self, index: int) -> dict:
|
| 510 |
+
img = Image.open(self.images[index]).convert("RGB")
|
| 511 |
+
img = self.transform(img)
|
| 512 |
+
original_path = _infer_kadid_original_path(Path(self.images[index]))
|
| 513 |
+
return {
|
| 514 |
+
"img": img,
|
| 515 |
+
"mos": float(self.mos[index]),
|
| 516 |
+
"dist_type": self.distortion_types[index],
|
| 517 |
+
"dist_group": self.distortion_groups[index],
|
| 518 |
+
"dist_level": int(self.distortion_levels[index]),
|
| 519 |
+
"distorted_img_path": _to_relative_dataset_path(Path(self.images[index]), self.root),
|
| 520 |
+
"original_img_path": _to_relative_dataset_path(original_path, self.root) if original_path is not None else None,
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
class LocalKadidPresavedDataset(Dataset):
|
| 525 |
+
"""Presaved KADID dataset with local distortions and binary masks.
|
| 526 |
+
|
| 527 |
+
Expects a dataset root directory produced by the local-distortion presave script.
|
| 528 |
+
The root must contain index.csv.
|
| 529 |
+
Required columns:
|
| 530 |
+
distorted_img_path, mask_path
|
| 531 |
+
Optional metadata columns are returned as-is in each sample.
|
| 532 |
+
"""
|
| 533 |
+
|
| 534 |
+
def __init__(
|
| 535 |
+
self,
|
| 536 |
+
root: str,
|
| 537 |
+
crop_size: Optional[int] = 224,
|
| 538 |
+
):
|
| 539 |
+
self.root = Path(root)
|
| 540 |
+
self.index_path = self.root / "index.csv"
|
| 541 |
+
self.index_dir = self.root
|
| 542 |
+
if not self.index_path.exists():
|
| 543 |
+
raise FileNotFoundError(f"index.csv not found in local_kadid root: {self.root}")
|
| 544 |
+
self.df = pd.read_csv(self.index_path)
|
| 545 |
+
|
| 546 |
+
required_cols = {"distorted_img_path", "mask_path"}
|
| 547 |
+
missing = required_cols - set(self.df.columns)
|
| 548 |
+
if missing:
|
| 549 |
+
raise ValueError(f"Index is missing columns: {sorted(missing)}")
|
| 550 |
+
|
| 551 |
+
self.image_to_tensor = transforms.ToTensor()
|
| 552 |
+
self.mask_to_tensor = transforms.ToTensor()
|
| 553 |
+
self.crop = transforms.CenterCrop(crop_size) if crop_size is not None else None
|
| 554 |
+
|
| 555 |
+
def __len__(self) -> int:
|
| 556 |
+
return len(self.df)
|
| 557 |
+
|
| 558 |
+
def _resolve_path(self, value: str) -> Path:
|
| 559 |
+
p = Path(value)
|
| 560 |
+
return p if p.is_absolute() else (self.index_dir / p)
|
| 561 |
+
|
| 562 |
+
def __getitem__(self, index: int) -> dict:
|
| 563 |
+
row = self.df.iloc[index]
|
| 564 |
+
|
| 565 |
+
img_path = self._resolve_path(str(row["distorted_img_path"]))
|
| 566 |
+
mask_path = self._resolve_path(str(row["mask_path"]))
|
| 567 |
+
|
| 568 |
+
img = Image.open(img_path).convert("RGB")
|
| 569 |
+
mask = Image.open(mask_path).convert("L")
|
| 570 |
+
|
| 571 |
+
img = self.image_to_tensor(img)
|
| 572 |
+
mask = self.mask_to_tensor(mask)
|
| 573 |
+
mask = (mask > 0.5).to(img.dtype)
|
| 574 |
+
|
| 575 |
+
if self.crop is not None:
|
| 576 |
+
img = self.crop(img)
|
| 577 |
+
mask = self.crop(mask)
|
| 578 |
+
|
| 579 |
+
sample = {
|
| 580 |
+
"img": img,
|
| 581 |
+
"mask": mask,
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
for key, value in row.items():
|
| 585 |
+
if key == "mask_path":
|
| 586 |
+
continue
|
| 587 |
+
if isinstance(value, np.generic):
|
| 588 |
+
value = value.item()
|
| 589 |
+
if key in ("distorted_img_path", "original_img_path"):
|
| 590 |
+
value = _to_relative_dataset_path(Path(value), self.root)
|
| 591 |
+
sample[key] = value
|
| 592 |
+
|
| 593 |
+
return sample
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
def kadid_collate_fn(batch: List[dict]) -> dict:
|
| 597 |
+
"""
|
| 598 |
+
Collate для Kadid10kDataset.
|
| 599 |
+
|
| 600 |
+
Возвращает:
|
| 601 |
+
images: Tensor (B, C, H, W)
|
| 602 |
+
+ все остальные ключи как списки длины B
|
| 603 |
+
"""
|
| 604 |
+
images = torch.stack([item["img"] for item in batch], dim=0)
|
| 605 |
+
collated: dict = {"images": images}
|
| 606 |
+
for key in batch[0]:
|
| 607 |
+
if key == "img":
|
| 608 |
+
continue
|
| 609 |
+
collated[key] = [item[key] for item in batch]
|
| 610 |
+
return collated
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def local_kadid_collate_fn(batch: List[dict]) -> dict:
|
| 614 |
+
"""Collate for LocalKadidPresavedDataset.
|
| 615 |
+
|
| 616 |
+
Returns:
|
| 617 |
+
images: Tensor (B, C, H, W)
|
| 618 |
+
masks: Tensor (B, 1, H, W)
|
| 619 |
+
+ remaining keys as lists with length B
|
| 620 |
+
"""
|
| 621 |
+
images = torch.stack([item["img"] for item in batch], dim=0)
|
| 622 |
+
masks = torch.stack([item["mask"] for item in batch], dim=0)
|
| 623 |
+
|
| 624 |
+
collated: dict = {
|
| 625 |
+
"images": images,
|
| 626 |
+
"masks": masks,
|
| 627 |
+
}
|
| 628 |
+
for key in batch[0]:
|
| 629 |
+
if key in ("img", "mask"):
|
| 630 |
+
continue
|
| 631 |
+
collated[key] = [item[key] for item in batch]
|
| 632 |
+
return collated
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
class QGroundDataset(Dataset):
|
| 636 |
+
"""QGround dataset stored locally as JSON index files plus image/mask folders.
|
| 637 |
+
|
| 638 |
+
Expected layout:
|
| 639 |
+
root/
|
| 640 |
+
qground_train.json
|
| 641 |
+
qground_test.json
|
| 642 |
+
images/
|
| 643 |
+
masks/
|
| 644 |
+
"""
|
| 645 |
+
|
| 646 |
+
def __init__(
|
| 647 |
+
self,
|
| 648 |
+
root: str,
|
| 649 |
+
split: str = 'test',
|
| 650 |
+
json_path: Optional[str] = None,
|
| 651 |
+
crop_size: Optional[int] = 224,
|
| 652 |
+
annotation_index: int = 0,
|
| 653 |
+
transform=None,
|
| 654 |
+
):
|
| 655 |
+
self.root = Path(root)
|
| 656 |
+
self.images_root = self.root / 'images'
|
| 657 |
+
self.masks_root = self.root / 'masks'
|
| 658 |
+
self.split = str(split).strip().lower()
|
| 659 |
+
self.annotation_index = int(annotation_index)
|
| 660 |
+
|
| 661 |
+
if json_path is None:
|
| 662 |
+
candidates = [
|
| 663 |
+
self.root / f'qground_{self.split}.json',
|
| 664 |
+
self.root / f'QGround_{self.split}.json',
|
| 665 |
+
]
|
| 666 |
+
self.json_path = next((path for path in candidates if path.exists()), candidates[0])
|
| 667 |
+
else:
|
| 668 |
+
self.json_path = Path(json_path)
|
| 669 |
+
if not self.json_path.is_absolute():
|
| 670 |
+
self.json_path = self.root / self.json_path
|
| 671 |
+
|
| 672 |
+
if not self.json_path.exists():
|
| 673 |
+
raise FileNotFoundError(f'QGround split file not found: {self.json_path}')
|
| 674 |
+
|
| 675 |
+
with self.json_path.open('r', encoding='utf-8') as handle:
|
| 676 |
+
raw_samples = json.load(handle)
|
| 677 |
+
|
| 678 |
+
if not isinstance(raw_samples, list):
|
| 679 |
+
raise ValueError(f'QGround JSON must contain a list of samples: {self.json_path}')
|
| 680 |
+
|
| 681 |
+
self.samples = []
|
| 682 |
+
for sample in raw_samples:
|
| 683 |
+
if not isinstance(sample, dict):
|
| 684 |
+
continue
|
| 685 |
+
|
| 686 |
+
ann_list = sample.get('ann_list') or []
|
| 687 |
+
if isinstance(ann_list, dict):
|
| 688 |
+
ann_list = [ann_list]
|
| 689 |
+
if not ann_list:
|
| 690 |
+
continue
|
| 691 |
+
|
| 692 |
+
ann = ann_list[min(self.annotation_index, len(ann_list) - 1)]
|
| 693 |
+
image_rel = sample.get('image')
|
| 694 |
+
mask_rel = ann.get('segmentation_mask')
|
| 695 |
+
if not image_rel or not mask_rel:
|
| 696 |
+
continue
|
| 697 |
+
|
| 698 |
+
self.samples.append({
|
| 699 |
+
'sample_id': sample.get('id'),
|
| 700 |
+
'image_rel': image_rel,
|
| 701 |
+
'mask_rel': mask_rel,
|
| 702 |
+
'ann_id': ann.get('id'),
|
| 703 |
+
'quality_description': ann.get('quality_description'),
|
| 704 |
+
})
|
| 705 |
+
|
| 706 |
+
self.crop = transforms.CenterCrop(crop_size) if crop_size is not None else None
|
| 707 |
+
self.image_to_tensor = transforms.ToTensor() if transform is None else transform
|
| 708 |
+
self.image_paths = [self._resolve_path(self.images_root, sample['image_rel']) for sample in self.samples]
|
| 709 |
+
|
| 710 |
+
def __len__(self) -> int:
|
| 711 |
+
return len(self.samples)
|
| 712 |
+
|
| 713 |
+
def _resolve_path(self, base_dir: Path, rel_path: str) -> Path:
|
| 714 |
+
rel = Path(str(rel_path))
|
| 715 |
+
candidates = [
|
| 716 |
+
base_dir / rel,
|
| 717 |
+
self.root / rel,
|
| 718 |
+
base_dir / rel.name,
|
| 719 |
+
self.root / rel.name,
|
| 720 |
+
]
|
| 721 |
+
for candidate in candidates:
|
| 722 |
+
if candidate.exists():
|
| 723 |
+
return candidate
|
| 724 |
+
return candidates[0]
|
| 725 |
+
|
| 726 |
+
def __getitem__(self, index: int) -> dict:
|
| 727 |
+
sample = self.samples[index]
|
| 728 |
+
|
| 729 |
+
img_path = self._resolve_path(self.images_root, sample['image_rel'])
|
| 730 |
+
mask_path = self._resolve_path(self.masks_root, sample['mask_rel'])
|
| 731 |
+
|
| 732 |
+
image = Image.open(img_path).convert('RGB')
|
| 733 |
+
mask_image = Image.open(mask_path).convert('RGB')
|
| 734 |
+
|
| 735 |
+
if self.crop is not None:
|
| 736 |
+
image = self.crop(image)
|
| 737 |
+
mask_image = self.crop(mask_image)
|
| 738 |
+
|
| 739 |
+
image = self.image_to_tensor(image)
|
| 740 |
+
if isinstance(image, Image.Image):
|
| 741 |
+
image = transforms.ToTensor()(image)
|
| 742 |
+
|
| 743 |
+
mask_rgb = np.asarray(mask_image, dtype=np.uint8)
|
| 744 |
+
mask_label = _rgb2label_qground(mask_rgb)
|
| 745 |
+
mask = torch.from_numpy(mask_label.astype(np.float32)).unsqueeze(0)
|
| 746 |
+
|
| 747 |
+
return {
|
| 748 |
+
'img': image,
|
| 749 |
+
'mask': mask,
|
| 750 |
+
'mos': float('nan'),
|
| 751 |
+
'dist_level': 5,
|
| 752 |
+
'mask_coverage': float((mask_label > 0).mean()),
|
| 753 |
+
'qground_ann_id': sample['ann_id'],
|
| 754 |
+
'sample_id': str(sample['sample_id'] or Path(sample['image_rel']).stem),
|
| 755 |
+
'distorted_img_path': _to_relative_dataset_path(img_path, self.root),
|
| 756 |
+
'original_img_path': '', # no reference images in QGround
|
| 757 |
+
'image_path': _to_relative_dataset_path(img_path, self.root),
|
| 758 |
+
'mask_path': _to_relative_dataset_path(mask_path, self.root),
|
| 759 |
+
'split': self.split,
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def qground_collate_fn(batch: List[dict]) -> dict:
|
| 764 |
+
"""Collate for QGroundDataset.
|
| 765 |
+
|
| 766 |
+
Returns:
|
| 767 |
+
images: Tensor (B, C, H, W)
|
| 768 |
+
masks: Tensor (B, 1, H, W)
|
| 769 |
+
+ remaining keys as lists with length B
|
| 770 |
+
"""
|
| 771 |
+
images = torch.stack([item['img'] for item in batch], dim=0)
|
| 772 |
+
masks = torch.stack([item['mask'] for item in batch], dim=0)
|
| 773 |
+
|
| 774 |
+
collated: dict = {
|
| 775 |
+
'images': images,
|
| 776 |
+
'masks': masks,
|
| 777 |
+
}
|
| 778 |
+
for key in batch[0]:
|
| 779 |
+
if key in ('img', 'mask'):
|
| 780 |
+
continue
|
| 781 |
+
collated[key] = [item[key] for item in batch]
|
| 782 |
+
return collated
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
class SRGroundSmallDataset(Dataset):
|
| 786 |
+
"""
|
| 787 |
+
Dataset wrapper for SRGround JSON indexes such as `srground_train.json`.
|
| 788 |
+
|
| 789 |
+
Expects entries with fields like `image_path`, `real_distortions_ann_path`,
|
| 790 |
+
`sr_artifacts_ann_path`, `has_markup`, `prominences`.
|
| 791 |
+
"""
|
| 792 |
+
|
| 793 |
+
def __init__(
|
| 794 |
+
self,
|
| 795 |
+
root: str,
|
| 796 |
+
json_path: Optional[str] = None,
|
| 797 |
+
require_markup: bool = True,
|
| 798 |
+
require_sr: bool = True,
|
| 799 |
+
allowed_methods: Optional[List[str]] = ['DiT4SR_x2'],
|
| 800 |
+
crop_size: Optional[int] = None,
|
| 801 |
+
sr_artifact_threshold: float = SRGROUND_SR_ARTIFACT_THRESHOLD,
|
| 802 |
+
transform=None,
|
| 803 |
+
):
|
| 804 |
+
self.root = Path(root)
|
| 805 |
+
self.sr_artifact_threshold = float(sr_artifact_threshold)
|
| 806 |
+
self.json_path = self.root / 'srground_train.json'
|
| 807 |
+
df = pd.read_json(self.json_path)
|
| 808 |
+
|
| 809 |
+
if require_markup:
|
| 810 |
+
df = df[df['has_markup'].fillna(False).astype(bool)]
|
| 811 |
+
|
| 812 |
+
df = df[~df['image_path'].str.contains('blur')]
|
| 813 |
+
|
| 814 |
+
df = df[df['image_path'].notna() & (df['image_path'].astype(str) != '')]
|
| 815 |
+
|
| 816 |
+
if require_sr:
|
| 817 |
+
df = df[df['image_path'].astype(str).str.contains('@SR@', na=False)]
|
| 818 |
+
|
| 819 |
+
if allowed_methods is not None:
|
| 820 |
+
method_pattern = '|'.join(re.escape(method) for method in allowed_methods)
|
| 821 |
+
df = df[df['image_path'].astype(str).str.contains(method_pattern, na=False)]
|
| 822 |
+
|
| 823 |
+
df = df.assign(sample_id=df['image_path'].astype(str).map(lambda path: Path(path).stem))
|
| 824 |
+
df = df.reset_index(drop=True)
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
self.df = df.copy()
|
| 828 |
+
self.image_to_tensor = transforms.ToTensor() if transform is None else transform
|
| 829 |
+
self.crop = transforms.CenterCrop(crop_size) if crop_size is not None else None
|
| 830 |
+
self.image_paths = [self.root / Path(path) for path in self.df['image_path'].tolist()]
|
| 831 |
+
|
| 832 |
+
def __len__(self) -> int:
|
| 833 |
+
return len(self.df)
|
| 834 |
+
|
| 835 |
+
def _resolve_path(self, rel_path: Optional[str]) -> Path:
|
| 836 |
+
return self.root / Path(str(rel_path))
|
| 837 |
+
|
| 838 |
+
def _load_mask_labels(self, sample: dict) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]:
|
| 839 |
+
prominences = sample.get('prominences')
|
| 840 |
+
annot_rd = None
|
| 841 |
+
annot_sr = None
|
| 842 |
+
|
| 843 |
+
real_path = sample.get('real_distortions_ann_path')
|
| 844 |
+
real_ann_path = self._resolve_path(real_path)
|
| 845 |
+
if real_ann_path.exists():
|
| 846 |
+
annot_rd = _real_distortion_labels(_load_npy_gz(real_ann_path), prominences)
|
| 847 |
+
|
| 848 |
+
sr_path = sample.get('sr_artifacts_ann_path')
|
| 849 |
+
sr_ann_path = self._resolve_path(sr_path)
|
| 850 |
+
if sr_ann_path.exists():
|
| 851 |
+
annot_sr = _sr_artifact_labels(_load_npy_gz(sr_ann_path), prominences, threshold=self.sr_artifact_threshold)
|
| 852 |
+
|
| 853 |
+
if annot_rd is None and annot_sr is None:
|
| 854 |
+
return None, None
|
| 855 |
+
return annot_rd, annot_sr
|
| 856 |
+
|
| 857 |
+
def _resize_mask(self, mask_label: Optional[np.ndarray], image: Image.Image) -> Optional[np.ndarray]:
|
| 858 |
+
if mask_label is None:
|
| 859 |
+
return None
|
| 860 |
+
if mask_label.shape[:2] == image.size[::-1]:
|
| 861 |
+
return mask_label
|
| 862 |
+
|
| 863 |
+
mask_image = Image.fromarray(mask_label.astype(np.uint8), mode='L')
|
| 864 |
+
mask_image = mask_image.resize(image.size, resample=Image.NEAREST)
|
| 865 |
+
return np.asarray(mask_image, dtype=np.uint8)
|
| 866 |
+
|
| 867 |
+
def __getitem__(self, index: int) -> dict:
|
| 868 |
+
sample = self.df.iloc[index].to_dict()
|
| 869 |
+
img_path = self.image_paths[index]
|
| 870 |
+
|
| 871 |
+
image = Image.open(img_path).convert('RGB')
|
| 872 |
+
annot_rd, annot_sr = self._load_mask_labels(sample)
|
| 873 |
+
annot_rd = self._resize_mask(annot_rd, image)
|
| 874 |
+
annot_sr = self._resize_mask(annot_sr, image)
|
| 875 |
+
|
| 876 |
+
if self.crop is not None:
|
| 877 |
+
image = self.crop(image)
|
| 878 |
+
if annot_rd is not None:
|
| 879 |
+
mask_image = Image.fromarray(annot_rd.astype(np.uint8), mode='L')
|
| 880 |
+
annot_rd = np.asarray(self.crop(mask_image), dtype=np.uint8)
|
| 881 |
+
if annot_sr is not None:
|
| 882 |
+
mask_image = Image.fromarray(annot_sr.astype(np.uint8), mode='L')
|
| 883 |
+
annot_sr = np.asarray(self.crop(mask_image), dtype=np.uint8)
|
| 884 |
+
|
| 885 |
+
img_tensor = self.image_to_tensor(image)
|
| 886 |
+
|
| 887 |
+
mask_rd = torch.from_numpy(annot_rd.astype(np.float32)).unsqueeze(0)
|
| 888 |
+
mask_sr = torch.from_numpy(annot_sr.astype(np.float32)).unsqueeze(0)
|
| 889 |
+
|
| 890 |
+
mask = torch.from_numpy(merge_srground_masks(annot_rd, annot_sr).astype(np.float32)).unsqueeze(0)
|
| 891 |
+
mask_coverage = float((mask > 0).float().mean().item())
|
| 892 |
+
|
| 893 |
+
real_ann_path = self._resolve_path(sample.get('real_distortions_ann_path'))
|
| 894 |
+
sr_ann_path = self._resolve_path(sample.get('sr_artifacts_ann_path'))
|
| 895 |
+
|
| 896 |
+
return {
|
| 897 |
+
'img': img_tensor,
|
| 898 |
+
'mask': mask,
|
| 899 |
+
'mask_rd': mask_rd,
|
| 900 |
+
'mask_sr': mask_sr,
|
| 901 |
+
'mos': float('nan'),
|
| 902 |
+
'dist_level': 5,
|
| 903 |
+
'mask_coverage': mask_coverage,
|
| 904 |
+
'prominences': sample.get('prominences'),
|
| 905 |
+
'has_markup': sample.get('has_markup', False),
|
| 906 |
+
'sr_artifacts_ann_path': _to_relative_dataset_path(sr_ann_path, self.root),
|
| 907 |
+
'real_distortions_ann_path': _to_relative_dataset_path(real_ann_path, self.root),
|
| 908 |
+
'sample_id': sample.get('sample_id'),
|
| 909 |
+
'distorted_img_path': _to_relative_dataset_path(img_path, self.root),
|
| 910 |
+
'image_path': _to_relative_dataset_path(img_path, self.root),
|
| 911 |
+
'mask_path': _to_relative_dataset_path(real_ann_path, self.root),
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
def srground_collate_fn(batch: List[dict]) -> dict:
|
| 916 |
+
"""Collate for SRGroundSmallDataset.
|
| 917 |
+
|
| 918 |
+
Returns:
|
| 919 |
+
images: Tensor (B, C, H, W)
|
| 920 |
+
masks: Tensor (B, 1, H, W)
|
| 921 |
+
+ remaining keys as lists length B
|
| 922 |
+
"""
|
| 923 |
+
images = torch.stack([item['img'] for item in batch], dim=0)
|
| 924 |
+
masks = torch.stack([item['mask'] for item in batch], dim=0)
|
| 925 |
+
|
| 926 |
+
collated: dict = {
|
| 927 |
+
'images': images,
|
| 928 |
+
'masks': masks,
|
| 929 |
+
}
|
| 930 |
+
for key in batch[0]:
|
| 931 |
+
if key in ('img', 'mask'):
|
| 932 |
+
continue
|
| 933 |
+
collated[key] = [item[key] for item in batch]
|
| 934 |
+
return collated
|
| 935 |
+
|
| 936 |
+
|
| 937 |
+
class KadidPristineDataset(Dataset):
|
| 938 |
+
"""
|
| 939 |
+
KADID-10k pristine (reference) images dataset.
|
| 940 |
+
Возвращает только оригинальные изображения без искажений с RandomCrop.
|
| 941 |
+
"""
|
| 942 |
+
|
| 943 |
+
def __init__(
|
| 944 |
+
self,
|
| 945 |
+
root: str,
|
| 946 |
+
crop_size: int = 224,
|
| 947 |
+
transform=None,
|
| 948 |
+
):
|
| 949 |
+
self.root = Path(root)
|
| 950 |
+
self.crop_size = crop_size
|
| 951 |
+
|
| 952 |
+
if transform is None:
|
| 953 |
+
self.transform = transforms.Compose([
|
| 954 |
+
transforms.RandomCrop(self.crop_size),
|
| 955 |
+
transforms.ToTensor(),
|
| 956 |
+
])
|
| 957 |
+
else:
|
| 958 |
+
self.transform = transform
|
| 959 |
+
|
| 960 |
+
# Находим все оригинальные изображения (формат I{number}.png без суффиксов)
|
| 961 |
+
images_dir = self.root / "images"
|
| 962 |
+
pristine_pattern = re.compile(r'^I\d+\.png$')
|
| 963 |
+
|
| 964 |
+
self.images = sorted([
|
| 965 |
+
images_dir / fname
|
| 966 |
+
for fname in os.listdir(images_dir)
|
| 967 |
+
if pristine_pattern.match(fname)
|
| 968 |
+
])
|
| 969 |
+
|
| 970 |
+
if len(self.images) == 0:
|
| 971 |
+
raise ValueError(f"No pristine images found in {images_dir}")
|
| 972 |
+
|
| 973 |
+
print(f"Found {len(self.images)} pristine images")
|
| 974 |
+
|
| 975 |
+
def __len__(self) -> int:
|
| 976 |
+
return len(self.images)
|
| 977 |
+
|
| 978 |
+
def __getitem__(self, index: int) -> dict:
|
| 979 |
+
img_path = self.images[index]
|
| 980 |
+
img = Image.open(img_path).convert("RGB")
|
| 981 |
+
img = self.transform(img)
|
| 982 |
+
img_rel_path = _to_relative_dataset_path(Path(img_path), self.root)
|
| 983 |
+
|
| 984 |
+
return {
|
| 985 |
+
"img": img,
|
| 986 |
+
"mos": 5.0, # максимальное качество для pristine
|
| 987 |
+
"dist_type": "pristine",
|
| 988 |
+
"dist_group": "pristine",
|
| 989 |
+
"dist_level": 0, # уровень искажений = 0
|
| 990 |
+
"distorted_img_path": img_rel_path,
|
| 991 |
+
"original_img_path": img_rel_path, # сам себе референс
|
| 992 |
+
"sample_id": img_path.stem, # например "I01"
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
class LocalKadidPristineDataset(Dataset):
|
| 997 |
+
"""
|
| 998 |
+
Pristine KADID dataset.
|
| 999 |
+
|
| 1000 |
+
Ожидает корневую директорию с index.csv.
|
| 1001 |
+
Обязательные колонки: original_img_path
|
| 1002 |
+
Опциональные: mask_path (если есть маска области без искажений)
|
| 1003 |
+
"""
|
| 1004 |
+
|
| 1005 |
+
def __init__(
|
| 1006 |
+
self,
|
| 1007 |
+
root: str,
|
| 1008 |
+
crop_size: Optional[int] = 224,
|
| 1009 |
+
):
|
| 1010 |
+
self.root = Path(root)
|
| 1011 |
+
self.index_path = self.root / "index.csv"
|
| 1012 |
+
self.index_dir = self.root
|
| 1013 |
+
|
| 1014 |
+
if not self.index_path.exists():
|
| 1015 |
+
raise FileNotFoundError(f"index.csv not found in pristine root: {self.root}")
|
| 1016 |
+
|
| 1017 |
+
df = pd.read_csv(self.index_path)
|
| 1018 |
+
|
| 1019 |
+
required_cols = {"original_img_path"}
|
| 1020 |
+
|
| 1021 |
+
missing = required_cols - set(df.columns)
|
| 1022 |
+
if missing:
|
| 1023 |
+
raise ValueError(f"Index is missing columns: {sorted(missing)}")
|
| 1024 |
+
|
| 1025 |
+
self.images = sorted(df['original_img_path'].unique())
|
| 1026 |
+
self.image_to_tensor = transforms.ToTensor()
|
| 1027 |
+
self.crop = transforms.CenterCrop(crop_size) if crop_size is not None else None
|
| 1028 |
+
|
| 1029 |
+
def __len__(self) -> int:
|
| 1030 |
+
return len(self.images)
|
| 1031 |
+
|
| 1032 |
+
def _resolve_path(self, value: str) -> Path:
|
| 1033 |
+
p = Path(value)
|
| 1034 |
+
return p if p.is_absolute() else (self.index_dir / p)
|
| 1035 |
+
|
| 1036 |
+
def __getitem__(self, index: int) -> dict:
|
| 1037 |
+
img_path = self._resolve_path(str(self.images[index]))
|
| 1038 |
+
|
| 1039 |
+
img = Image.open(img_path).convert("RGB")
|
| 1040 |
+
img = self.image_to_tensor(img)
|
| 1041 |
+
img_rel_path = _to_relative_dataset_path(Path(img_path), self.root)
|
| 1042 |
+
|
| 1043 |
+
sample = {
|
| 1044 |
+
"img": img,
|
| 1045 |
+
"dist_type": "pristine",
|
| 1046 |
+
"dist_group": "pristine",
|
| 1047 |
+
"dist_level": 0,
|
| 1048 |
+
"distorted_img_path": img_rel_path,
|
| 1049 |
+
"original_img_path": img_rel_path,
|
| 1050 |
+
"sample_id": img_path.stem,
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
if self.crop is not None:
|
| 1054 |
+
sample["img"] = self.crop(sample["img"])
|
| 1055 |
+
|
| 1056 |
+
return sample
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
def kadid_pristine_collate_fn(batch: List[dict]) -> dict:
|
| 1060 |
+
"""
|
| 1061 |
+
Collate для KadidPristineDataset.
|
| 1062 |
+
|
| 1063 |
+
Возвращает:
|
| 1064 |
+
images: Tensor (B, C, H, W)
|
| 1065 |
+
+ все остальные ключи как списки длины B
|
| 1066 |
+
"""
|
| 1067 |
+
images = torch.stack([item["img"] for item in batch], dim=0)
|
| 1068 |
+
collated: dict = {"images": images}
|
| 1069 |
+
|
| 1070 |
+
for key in batch[0]:
|
| 1071 |
+
if key == "img":
|
| 1072 |
+
continue
|
| 1073 |
+
collated[key] = [item[key] for item in batch]
|
| 1074 |
+
|
| 1075 |
+
return collated
|
| 1076 |
+
|
| 1077 |
+
|
| 1078 |
+
def local_kadid_pristine_collate_fn(batch: List[dict]) -> dict:
|
| 1079 |
+
"""
|
| 1080 |
+
Collate для LocalKadidPristinePresavedDataset.
|
| 1081 |
+
|
| 1082 |
+
Возвращает:
|
| 1083 |
+
images: Tensor (B, C, H, W)
|
| 1084 |
+
masks: Tensor (B, 1, H, W) если has_masks=True
|
| 1085 |
+
+ остальные ключи как списки длины B
|
| 1086 |
+
"""
|
| 1087 |
+
images = torch.stack([item["img"] for item in batch], dim=0)
|
| 1088 |
+
|
| 1089 |
+
collated: dict = {"images": images}
|
| 1090 |
+
|
| 1091 |
+
# Если есть маски в батче
|
| 1092 |
+
if "mask" in batch[0]:
|
| 1093 |
+
masks = torch.stack([item["mask"] for item in batch], dim=0)
|
| 1094 |
+
collated["masks"] = masks
|
| 1095 |
+
|
| 1096 |
+
for key in batch[0]:
|
| 1097 |
+
if key in ("img", "mask"):
|
| 1098 |
+
continue
|
| 1099 |
+
collated[key] = [item[key] for item in batch]
|
| 1100 |
+
|
| 1101 |
+
return collated
|
| 1102 |
+
|
| 1103 |
+
|
| 1104 |
+
def resolve_dataset_image_path(
|
| 1105 |
+
dataset: str,
|
| 1106 |
+
path_from_meta: str,
|
| 1107 |
+
datasets_root: str | None = None,
|
| 1108 |
+
) -> Path:
|
| 1109 |
+
"""Resolve a path stored in activation-cache metadata to an absolute file path."""
|
| 1110 |
+
from analysis.config import dataset_images_root, load_sae_vis_config
|
| 1111 |
+
|
| 1112 |
+
root = Path(
|
| 1113 |
+
datasets_root
|
| 1114 |
+
if datasets_root is not None
|
| 1115 |
+
else load_sae_vis_config().DATASETS_ROOT
|
| 1116 |
+
)
|
| 1117 |
+
dataset_root = Path(dataset_images_root(str(root), dataset))
|
| 1118 |
+
path = Path(path_from_meta)
|
| 1119 |
+
return path if path.is_absolute() else (dataset_root / path)
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
+
def dataset_image_paths(
|
| 1123 |
+
dataset: str,
|
| 1124 |
+
kadid_images_path: str,
|
| 1125 |
+
crop_size: int,
|
| 1126 |
+
min_distortion_level: int,
|
| 1127 |
+
) -> List[str]:
|
| 1128 |
+
if dataset == 'kadid10k':
|
| 1129 |
+
ds = Kadid10kDataset(
|
| 1130 |
+
kadid_images_path,
|
| 1131 |
+
crop_size=crop_size,
|
| 1132 |
+
min_distortion_level=min_distortion_level,
|
| 1133 |
+
)
|
| 1134 |
+
return [_to_relative_dataset_path(Path(p), ds.root) for p in ds.images]
|
| 1135 |
+
|
| 1136 |
+
if dataset == 'local_kadid':
|
| 1137 |
+
ds = LocalKadidPresavedDataset(kadid_images_path, crop_size=crop_size)
|
| 1138 |
+
return [_to_relative_dataset_path(Path(str(p)), ds.root) for p in ds.df['distorted_img_path'].tolist()]
|
| 1139 |
+
|
| 1140 |
+
if dataset == 'QGround':
|
| 1141 |
+
ds = QGroundDataset(kadid_images_path, split='test', crop_size=crop_size)
|
| 1142 |
+
return [str(sample['image_rel']) for sample in ds.samples]
|
| 1143 |
+
|
| 1144 |
+
if dataset == 'SRGround':
|
| 1145 |
+
ds = SRGroundSmallDataset(kadid_images_path, json_path='srground_train.json', allowed_methods=['DiT4SR_x2'])
|
| 1146 |
+
return [_to_relative_dataset_path(Path(str(p)), ds.root) for p in ds.df['image_path'].tolist()]
|
| 1147 |
+
|
| 1148 |
+
raise ValueError(f'Unsupported dataset: {dataset}')
|
analysis/features/__init__.py
ADDED
|
File without changes
|
analysis/features/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (164 Bytes). View file
|
|
|
analysis/features/__pycache__/feature_filters.cpython-310.pyc
ADDED
|
Binary file (12.4 kB). View file
|
|
|
analysis/features/__pycache__/feature_indexing.cpython-310.pyc
ADDED
|
Binary file (4.99 kB). View file
|
|
|
analysis/features/__pycache__/feature_matrix.cpython-310.pyc
ADDED
|
Binary file (3.12 kB). View file
|
|
|
analysis/features/__pycache__/feature_selectors.cpython-310.pyc
ADDED
|
Binary file (28.9 kB). View file
|
|
|
analysis/features/feature_filters.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature filtering strategies applied before feature selection."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from abc import ABC, abstractmethod
|
| 6 |
+
import hashlib
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Callable, Dict, List, Mapping, Sequence
|
| 10 |
+
|
| 11 |
+
from tqdm import tqdm
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from scipy import stats
|
| 15 |
+
from scipy.sparse import csc_matrix
|
| 16 |
+
from statsmodels.stats.multitest import multipletests
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _json_default(value: object) -> object:
|
| 20 |
+
"""JSON serializer for numpy/pandas scalar-like values."""
|
| 21 |
+
if isinstance(value, (np.integer, np.floating)):
|
| 22 |
+
return value.item()
|
| 23 |
+
if isinstance(value, np.ndarray):
|
| 24 |
+
return value.tolist()
|
| 25 |
+
return str(value)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _build_filter_cache_key(
|
| 29 |
+
*,
|
| 30 |
+
filter_name: str,
|
| 31 |
+
params: Mapping[str, object],
|
| 32 |
+
meta_df: pd.DataFrame,
|
| 33 |
+
codes_shape: tuple[int, int],
|
| 34 |
+
group_col: str,
|
| 35 |
+
) -> str:
|
| 36 |
+
"""Build a stable cache key for feature-filter indices."""
|
| 37 |
+
if group_col not in meta_df.columns:
|
| 38 |
+
group_signature = {'missing_group_col': group_col}
|
| 39 |
+
else:
|
| 40 |
+
counts = meta_df[group_col].value_counts(dropna=False)
|
| 41 |
+
group_signature = {
|
| 42 |
+
'group_counts': [(str(idx), int(cnt)) for idx, cnt in counts.items()],
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
payload = {
|
| 46 |
+
'filter': filter_name,
|
| 47 |
+
'params': dict(params),
|
| 48 |
+
'n_samples': int(codes_shape[0]),
|
| 49 |
+
'n_features': int(codes_shape[1]),
|
| 50 |
+
'group_col': group_col,
|
| 51 |
+
'meta_rows': int(meta_df.shape[0]),
|
| 52 |
+
**group_signature,
|
| 53 |
+
}
|
| 54 |
+
raw = json.dumps(payload, sort_keys=True, default=_json_default).encode('utf-8')
|
| 55 |
+
return hashlib.sha256(raw).hexdigest()[:8]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _resolve_filter_cache_path(
|
| 59 |
+
*,
|
| 60 |
+
context: Mapping[str, object],
|
| 61 |
+
filter_name: str,
|
| 62 |
+
cache_key: str,
|
| 63 |
+
) -> Path | None:
|
| 64 |
+
"""Resolve cache file path for feature-filter indices from context."""
|
| 65 |
+
cache_dir = context.get('feature_filter_cache_dir')
|
| 66 |
+
if cache_dir is None:
|
| 67 |
+
return None
|
| 68 |
+
return Path(str(cache_dir)) / f'{filter_name}_{cache_key}.npz'
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _load_feature_ids_from_cache(cache_path: Path, n_features: int) -> List[int]:
|
| 72 |
+
"""Load and validate cached feature indices."""
|
| 73 |
+
with np.load(cache_path, allow_pickle=False) as data:
|
| 74 |
+
keep_feature_ids = np.asarray(data['keep_feature_ids'])
|
| 75 |
+
return [int(fid) for fid in keep_feature_ids.tolist()]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _save_feature_ids_to_cache(cache_path: Path, keep_feature_ids: Sequence[int]) -> None:
|
| 79 |
+
"""Persist feature indices to cache as compressed NPZ."""
|
| 80 |
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
np.savez_compressed(cache_path, keep_feature_ids=np.asarray(keep_feature_ids, dtype=np.int64))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def my_kruskal(
|
| 85 |
+
col_data: np.ndarray,
|
| 86 |
+
col_indices: np.ndarray,
|
| 87 |
+
n_samples: int,
|
| 88 |
+
group_indices: list[np.ndarray], # список массивов индексов для каждой группы
|
| 89 |
+
) -> float:
|
| 90 |
+
"""
|
| 91 |
+
Kruskal-Wallis H-test для одной колонки CSC-матрицы.
|
| 92 |
+
|
| 93 |
+
Параметры
|
| 94 |
+
----------
|
| 95 |
+
col_data : ненулевые значения колонки (csc.data[csc.indptr[j]:csc.indptr[j+1]])
|
| 96 |
+
col_indices : строковые индексы ненулевых элементов (csc.indices[...])
|
| 97 |
+
n_samples : полное число строк (n)
|
| 98 |
+
group_indices: group_indices[g] — массив строковых индексов группы g
|
| 99 |
+
|
| 100 |
+
Возвращает
|
| 101 |
+
----------
|
| 102 |
+
p-value (float), или np.nan если тест неприменим
|
| 103 |
+
"""
|
| 104 |
+
n = n_samples
|
| 105 |
+
n_zeros = n - len(col_data)
|
| 106 |
+
|
| 107 |
+
# --- 1. Ранги ненулевых элементов среди ВСЕХ n значений ----------------
|
| 108 |
+
# Нули занимают позиции 1..n_zeros в общем порядке.
|
| 109 |
+
# Ненулевые элементы начинаются с позиции n_zeros + 1.
|
| 110 |
+
|
| 111 |
+
# Сортируем ненулевые значения, получаем ранги внутри них
|
| 112 |
+
nonzero_vals = col_data.astype(np.float64)
|
| 113 |
+
order = np.argsort(nonzero_vals, kind='stable')
|
| 114 |
+
# Обрабатываем ties: каждому уникальному значению — средний ранг
|
| 115 |
+
ranks_local = np.empty(len(nonzero_vals), dtype=np.float64)
|
| 116 |
+
# временно назначаем 1-based ранги среди ненулевых
|
| 117 |
+
ranks_local[order] = np.arange(1, len(nonzero_vals) + 1, dtype=np.float64)
|
| 118 |
+
# Разрешаем ties усреднением
|
| 119 |
+
sorted_vals = nonzero_vals[order]
|
| 120 |
+
unique_vals, inverse, counts = np.unique(sorted_vals, return_inverse=True, return_counts=True)
|
| 121 |
+
# средний ранг для каждого уникального значения (1-based среди ненулевых)
|
| 122 |
+
cum = np.concatenate([[0], np.cumsum(counts)])
|
| 123 |
+
mean_local_ranks = (cum[:-1] + cum[1:] + 1) / 2.0 # (first + last + 1) / 2
|
| 124 |
+
ranks_local = mean_local_ranks[inverse]
|
| 125 |
+
|
| 126 |
+
# Сдвигаем ранги ненулевых элементов на n_zeros (они все правее нулей)
|
| 127 |
+
# Но нужно учесть ties между нулями и ненулевыми.
|
| 128 |
+
global_ranks_nonzero = ranks_local + n_zeros # сдвиг на количество нулей
|
| 129 |
+
|
| 130 |
+
# Средний ранг нулей: нули занимают ранги 1..n_zeros
|
| 131 |
+
zero_mean_rank = (n_zeros + 1) / 2.0 if n_zeros > 0 else 0.0
|
| 132 |
+
|
| 133 |
+
# --- 2. Для каждой группы считаем сумму рангов -------------------------
|
| 134 |
+
n_groups = len(group_indices)
|
| 135 |
+
if n_groups < 2:
|
| 136 |
+
return np.nan
|
| 137 |
+
|
| 138 |
+
# Маска ненулевых: строим dict index->global_rank для быстрого lookup
|
| 139 |
+
rank_map = dict(zip(col_indices, global_ranks_nonzero))
|
| 140 |
+
|
| 141 |
+
H_num = 0.0
|
| 142 |
+
n_total_valid = 0
|
| 143 |
+
|
| 144 |
+
group_sizes = []
|
| 145 |
+
group_rank_sums = []
|
| 146 |
+
|
| 147 |
+
for g_idx in group_indices:
|
| 148 |
+
ng = len(g_idx)
|
| 149 |
+
if ng == 0:
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
# Ранги элементов группы
|
| 153 |
+
r_sum = 0.0
|
| 154 |
+
for i in g_idx:
|
| 155 |
+
r_sum += rank_map.get(int(i), zero_mean_rank)
|
| 156 |
+
|
| 157 |
+
group_sizes.append(ng)
|
| 158 |
+
group_rank_sums.append(r_sum)
|
| 159 |
+
n_total_valid += ng
|
| 160 |
+
|
| 161 |
+
if len(group_sizes) < 2:
|
| 162 |
+
return np.nan
|
| 163 |
+
|
| 164 |
+
n_t = n_total_valid # должно равняться n
|
| 165 |
+
|
| 166 |
+
# --- 3. H-статистика (стандартная формула) ------------------------------
|
| 167 |
+
# H = 12 / (n*(n+1)) * sum(R_i^2 / n_i) - 3*(n+1)
|
| 168 |
+
H = 0.0
|
| 169 |
+
for ng, rs in zip(group_sizes, group_rank_sums):
|
| 170 |
+
H += (rs ** 2) / ng
|
| 171 |
+
|
| 172 |
+
H = (12.0 / (n_t * (n_t + 1))) * H - 3.0 * (n_t + 1)
|
| 173 |
+
|
| 174 |
+
# --- 4. Поправка на ties ------------------------------------------------
|
| 175 |
+
# T = sum(t^3 - t) для каждой группы ties, C = 1 - T / (n^3 - n)
|
| 176 |
+
# Ties среди ненулевых:
|
| 177 |
+
tie_correction = 1.0
|
| 178 |
+
if len(unique_vals) < len(nonzero_vals):
|
| 179 |
+
T = np.sum(counts ** 3 - counts, dtype=np.float64)
|
| 180 |
+
# Добавляем ties нулей:
|
| 181 |
+
if n_zeros > 1:
|
| 182 |
+
T += n_zeros ** 3 - n_zeros
|
| 183 |
+
denom = float(n_t) ** 3 - n_t
|
| 184 |
+
if denom > 0:
|
| 185 |
+
tie_correction = 1.0 - T / denom
|
| 186 |
+
|
| 187 |
+
if tie_correction > 0:
|
| 188 |
+
H /= tie_correction
|
| 189 |
+
|
| 190 |
+
if H < 0:
|
| 191 |
+
H = 0.0
|
| 192 |
+
|
| 193 |
+
# --- 5. p-value из chi2 с df = n_groups - 1 ----------------------------
|
| 194 |
+
df = len(group_sizes) - 1
|
| 195 |
+
p_value = float(stats.chi2.sf(H, df))
|
| 196 |
+
return p_value
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class BaseFeatureFilter(ABC):
|
| 200 |
+
"""Common interface for pre-selection feature filters."""
|
| 201 |
+
|
| 202 |
+
name: str
|
| 203 |
+
description: str
|
| 204 |
+
|
| 205 |
+
@abstractmethod
|
| 206 |
+
def apply_filter(
|
| 207 |
+
self,
|
| 208 |
+
context: Dict[str, object],
|
| 209 |
+
) -> Dict[str, object]:
|
| 210 |
+
"""Return filtered context with feature tables/matrix subsetted by feature ids."""
|
| 211 |
+
|
| 212 |
+
def filter_dataset(
|
| 213 |
+
self,
|
| 214 |
+
context: Dict[str, object],
|
| 215 |
+
) -> Dict[str, object]:
|
| 216 |
+
before_count = int(context['codes_csr'].shape[1])
|
| 217 |
+
if before_count <= 0:
|
| 218 |
+
raise ValueError('Feature filter received empty feature space')
|
| 219 |
+
|
| 220 |
+
base_feature_ids = context.get('feature_ids')
|
| 221 |
+
if base_feature_ids is None:
|
| 222 |
+
base_feature_ids_arr = np.arange(before_count, dtype=np.int64)
|
| 223 |
+
else:
|
| 224 |
+
base_feature_ids_arr = np.asarray(base_feature_ids, dtype=np.int64)
|
| 225 |
+
if base_feature_ids_arr.shape[0] != before_count:
|
| 226 |
+
raise ValueError(
|
| 227 |
+
f'Feature provenance length mismatch for filter {self.name!r}: '
|
| 228 |
+
f'expected {before_count}, got {base_feature_ids_arr.shape[0]}'
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
filtered_context = self.apply_filter(context)
|
| 232 |
+
after_count = int(filtered_context['codes_csr'].shape[1])
|
| 233 |
+
if after_count > before_count:
|
| 234 |
+
raise ValueError(
|
| 235 |
+
f'Filter {self.name!r} increased feature count from {before_count} to {after_count}')
|
| 236 |
+
if after_count <= 0:
|
| 237 |
+
raise ValueError(f'Filter {self.name!r} removed all candidate features')
|
| 238 |
+
|
| 239 |
+
keep_feature_ids = filtered_context.get('filtered_feature_ids')
|
| 240 |
+
if keep_feature_ids is None:
|
| 241 |
+
raise ValueError(
|
| 242 |
+
f'Filter {self.name!r} must return filtered_feature_ids to preserve feature provenance'
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
keep_feature_ids_arr = np.asarray(keep_feature_ids, dtype=np.int64)
|
| 246 |
+
filtered_context['filtered_feature_ids'] = keep_feature_ids_arr.tolist()
|
| 247 |
+
filtered_context['feature_ids'] = base_feature_ids_arr[keep_feature_ids_arr].astype(np.int64).tolist()
|
| 248 |
+
return filtered_context
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class KruskalWallisFeatureFilter(BaseFeatureFilter):
|
| 252 |
+
"""Filter features by Kruskal-Wallis significance across distortion groups."""
|
| 253 |
+
|
| 254 |
+
name = 'kruskal_wallis'
|
| 255 |
+
description = 'Keep features with p-value <= alpha in Kruskal-Wallis test across groups'
|
| 256 |
+
|
| 257 |
+
def __init__(
|
| 258 |
+
self,
|
| 259 |
+
alpha: float = 0.05,
|
| 260 |
+
group_col: str = 'dist_type',
|
| 261 |
+
min_group_size: int = 3,
|
| 262 |
+
batch_size: int = 1,
|
| 263 |
+
show_progress: bool = True,
|
| 264 |
+
):
|
| 265 |
+
self.alpha = float(alpha)
|
| 266 |
+
self.group_col = group_col
|
| 267 |
+
self.min_group_size = int(min_group_size)
|
| 268 |
+
self.batch_size = batch_size
|
| 269 |
+
self.show_progress = bool(show_progress)
|
| 270 |
+
|
| 271 |
+
def apply_filter(
|
| 272 |
+
self,
|
| 273 |
+
context: Dict[str, object],
|
| 274 |
+
) -> Dict[str, object]:
|
| 275 |
+
codes_csr = context['codes_csr']
|
| 276 |
+
meta_df = context['meta_df']
|
| 277 |
+
|
| 278 |
+
alpha = float(context.get('kruskal_alpha', self.alpha))
|
| 279 |
+
min_group_size = int(context.get('kruskal_min_group_size', self.min_group_size))
|
| 280 |
+
group_col = str(context.get('kruskal_group_col', self.group_col))
|
| 281 |
+
show_progress = bool(context.get('kruskal_show_progress', self.show_progress))
|
| 282 |
+
|
| 283 |
+
group_values = meta_df[group_col].to_numpy()
|
| 284 |
+
unique_groups = pd.unique(group_values)
|
| 285 |
+
n_samples = int(codes_csr.shape[0])
|
| 286 |
+
n_features = int(codes_csr.shape[1])
|
| 287 |
+
|
| 288 |
+
cache_params = {
|
| 289 |
+
'alpha': alpha,
|
| 290 |
+
'group_col': group_col,
|
| 291 |
+
'min_group_size': min_group_size,
|
| 292 |
+
}
|
| 293 |
+
cache_key = _build_filter_cache_key(
|
| 294 |
+
filter_name=self.name,
|
| 295 |
+
params=cache_params,
|
| 296 |
+
meta_df=meta_df,
|
| 297 |
+
codes_shape=(n_samples, n_features),
|
| 298 |
+
group_col=group_col,
|
| 299 |
+
)
|
| 300 |
+
cache_path = _resolve_filter_cache_path(
|
| 301 |
+
context=context,
|
| 302 |
+
filter_name=self.name,
|
| 303 |
+
cache_key=cache_key,
|
| 304 |
+
)
|
| 305 |
+
if cache_path is not None and cache_path.exists():
|
| 306 |
+
try:
|
| 307 |
+
keep_feature_ids = _load_feature_ids_from_cache(cache_path, n_features=n_features)
|
| 308 |
+
print(f'[cache][feature_filter] hit: {cache_path}')
|
| 309 |
+
filtered_context = dict(context)
|
| 310 |
+
filtered_context['codes_csr'] = codes_csr[:, keep_feature_ids]
|
| 311 |
+
filtered_context['filtered_feature_ids'] = keep_feature_ids
|
| 312 |
+
filtered_context['feature_filter_cache_path'] = str(cache_path)
|
| 313 |
+
return filtered_context
|
| 314 |
+
except Exception as exc:
|
| 315 |
+
print(f'[cache][feature_filter] invalid cache {cache_path}: {exc}. Recomputing...')
|
| 316 |
+
|
| 317 |
+
# Предвычисляем индексы строк для каждой группы — один раз
|
| 318 |
+
group_indices = []
|
| 319 |
+
for group in unique_groups:
|
| 320 |
+
idx = np.where(group_values == group)[0]
|
| 321 |
+
if idx.size >= min_group_size:
|
| 322 |
+
group_indices.append(idx)
|
| 323 |
+
|
| 324 |
+
codes_csc = codes_csr.tocsc()
|
| 325 |
+
|
| 326 |
+
valid_feature_ids = []
|
| 327 |
+
p_values = []
|
| 328 |
+
|
| 329 |
+
for fid in tqdm(range(n_features), disable=not show_progress):
|
| 330 |
+
start, stop = codes_csc.indptr[fid], codes_csc.indptr[fid + 1]
|
| 331 |
+
col_data = codes_csc.data[start:stop]
|
| 332 |
+
col_indices = codes_csc.indices[start:stop]
|
| 333 |
+
|
| 334 |
+
# Пропускаем константные колонки
|
| 335 |
+
n_nonzero = stop - start
|
| 336 |
+
all_same = (
|
| 337 |
+
n_nonzero == 0
|
| 338 |
+
or (n_nonzero == n_samples and np.all(col_data == col_data[0]))
|
| 339 |
+
or (n_nonzero < n_samples and n_nonzero == 0)
|
| 340 |
+
)
|
| 341 |
+
if all_same and n_nonzero == 0:
|
| 342 |
+
continue # все нули — константа
|
| 343 |
+
if n_nonzero > 0 and n_nonzero == n_samples and np.all(col_data == col_data[0]):
|
| 344 |
+
continue # все одинаковые ненулевые
|
| 345 |
+
|
| 346 |
+
p_value = my_kruskal(col_data, col_indices, n_samples, group_indices)
|
| 347 |
+
|
| 348 |
+
if np.isfinite(p_value):
|
| 349 |
+
valid_feature_ids.append(fid)
|
| 350 |
+
p_values.append(p_value)
|
| 351 |
+
|
| 352 |
+
if len(p_values) == 0:
|
| 353 |
+
keep_feature_ids = []
|
| 354 |
+
else:
|
| 355 |
+
rejected, _, _, _ = multipletests(p_values, alpha=alpha, method='fdr_bh')
|
| 356 |
+
keep_feature_ids = [
|
| 357 |
+
fid
|
| 358 |
+
for fid, r in zip(valid_feature_ids, rejected)
|
| 359 |
+
if r
|
| 360 |
+
]
|
| 361 |
+
|
| 362 |
+
if cache_path is not None:
|
| 363 |
+
try:
|
| 364 |
+
_save_feature_ids_to_cache(cache_path, keep_feature_ids)
|
| 365 |
+
print(f'[cache][feature_filter] saved: {cache_path}')
|
| 366 |
+
except Exception as exc:
|
| 367 |
+
print(f'[cache][feature_filter] failed to save {cache_path}: {exc}')
|
| 368 |
+
|
| 369 |
+
filtered_context = dict(context)
|
| 370 |
+
filtered_context['codes_csr'] = codes_csr[:, keep_feature_ids]
|
| 371 |
+
filtered_context['filtered_feature_ids'] = keep_feature_ids
|
| 372 |
+
if cache_path is not None:
|
| 373 |
+
filtered_context['feature_filter_cache_path'] = str(cache_path)
|
| 374 |
+
return filtered_context
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
class NonZeroMaxFeatureFilter(BaseFeatureFilter):
|
| 378 |
+
"""Keep features whose column-wise maximum activation is not zero."""
|
| 379 |
+
|
| 380 |
+
name = 'nonzero_max'
|
| 381 |
+
description = 'Keep features with max activation != 0'
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def __init__(
|
| 385 |
+
self,
|
| 386 |
+
show_progress: bool = False,
|
| 387 |
+
):
|
| 388 |
+
pass
|
| 389 |
+
|
| 390 |
+
def apply_filter(
|
| 391 |
+
self,
|
| 392 |
+
context: Dict[str, object],
|
| 393 |
+
) -> Dict[str, object]:
|
| 394 |
+
codes_csr = context['codes_csr']
|
| 395 |
+
keep_feature_ids = np.flatnonzero(np.asarray(codes_csr.getnnz(axis=0)).ravel() > 0).tolist()
|
| 396 |
+
|
| 397 |
+
filtered_context = dict(context)
|
| 398 |
+
filtered_context['codes_csr'] = codes_csr[:, keep_feature_ids]
|
| 399 |
+
filtered_context['filtered_feature_ids'] = keep_feature_ids
|
| 400 |
+
return filtered_context
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
FILTER_REGISTRY: Mapping[str, BaseFeatureFilter] = {}
|
| 404 |
+
|
| 405 |
+
FILTER_BUILDERS: Mapping[str, Callable[..., BaseFeatureFilter]] = {
|
| 406 |
+
'nonzero_max': NonZeroMaxFeatureFilter,
|
| 407 |
+
'kruskal_wallis': KruskalWallisFeatureFilter,
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def get_filter_registry() -> Dict[str, BaseFeatureFilter]:
|
| 412 |
+
"""Return a mutable copy of built-in filter registry."""
|
| 413 |
+
return dict(FILTER_REGISTRY)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def load_filter(filter_name: str) -> BaseFeatureFilter:
|
| 417 |
+
"""Load built-in feature filter by name."""
|
| 418 |
+
filter_obj = FILTER_REGISTRY.get(filter_name)
|
| 419 |
+
if filter_obj is None:
|
| 420 |
+
available = ', '.join(sorted(FILTER_REGISTRY.keys()))
|
| 421 |
+
raise ValueError(f'Unknown filter {filter_name!r}. Available: {available}')
|
| 422 |
+
return filter_obj
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def load_filters(filter_names: Sequence[str]) -> List[BaseFeatureFilter]:
|
| 426 |
+
"""Load an ordered sequence of filters."""
|
| 427 |
+
return [load_filter(name) for name in filter_names]
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def build_filter(
|
| 431 |
+
filter_name: str,
|
| 432 |
+
params: Mapping[str, object] | None = None,
|
| 433 |
+
) -> BaseFeatureFilter:
|
| 434 |
+
"""Build a feature filter instance from name and kwargs-like params."""
|
| 435 |
+
builder = FILTER_BUILDERS.get(filter_name)
|
| 436 |
+
kwargs = dict(params or {})
|
| 437 |
+
return builder(**kwargs)
|
analysis/features/feature_indexing.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CSR activations with global SAE id per column: ``FeatureMatrix``."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Sequence
|
| 7 |
+
|
| 8 |
+
import scipy.sparse as sp
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def global_to_column(column_feature_ids: Sequence[int], global_feature_id: int) -> int | None:
|
| 12 |
+
try:
|
| 13 |
+
return column_feature_ids.index(int(global_feature_id))
|
| 14 |
+
except ValueError:
|
| 15 |
+
return None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def column_for_global(column_feature_ids: Sequence[int], global_feature_id: int) -> int:
|
| 19 |
+
col = global_to_column(column_feature_ids, global_feature_id)
|
| 20 |
+
if col is None:
|
| 21 |
+
raise KeyError(
|
| 22 |
+
f'Global feature id {int(global_feature_id)!r} is not in column_feature_ids'
|
| 23 |
+
)
|
| 24 |
+
return col
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def global_id_set(column_feature_ids: Sequence[int]) -> frozenset[int]:
|
| 28 |
+
return frozenset(int(fid) for fid in column_feature_ids)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class FeatureMatrix:
|
| 33 |
+
"""Sparse activations; ``column_feature_ids[col]`` is the global SAE id for column ``col``."""
|
| 34 |
+
|
| 35 |
+
codes: sp.csr_matrix
|
| 36 |
+
column_feature_ids: tuple[int, ...]
|
| 37 |
+
|
| 38 |
+
def __post_init__(self) -> None:
|
| 39 |
+
n_cols = int(self.codes.shape[1])
|
| 40 |
+
if len(self.column_feature_ids) != n_cols:
|
| 41 |
+
raise ValueError(
|
| 42 |
+
f'column_feature_ids length ({len(self.column_feature_ids)}) '
|
| 43 |
+
f'must match codes.shape[1] ({n_cols})'
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def from_codes(
|
| 48 |
+
cls,
|
| 49 |
+
codes: sp.csr_matrix,
|
| 50 |
+
column_feature_ids: Sequence[int] | None = None,
|
| 51 |
+
) -> FeatureMatrix:
|
| 52 |
+
if column_feature_ids is None:
|
| 53 |
+
labels = tuple(range(int(codes.shape[1])))
|
| 54 |
+
else:
|
| 55 |
+
labels = tuple(int(fid) for fid in column_feature_ids)
|
| 56 |
+
return cls(codes=codes, column_feature_ids=labels)
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def n_features(self) -> int:
|
| 60 |
+
return len(self.column_feature_ids)
|
| 61 |
+
|
| 62 |
+
def column_for(self, global_feature_id: int) -> int:
|
| 63 |
+
return column_for_global(self.column_feature_ids, global_feature_id)
|
| 64 |
+
|
| 65 |
+
def global_to_column(self, global_feature_id: int) -> int | None:
|
| 66 |
+
return global_to_column(self.column_feature_ids, global_feature_id)
|
| 67 |
+
|
| 68 |
+
def global_id_set(self) -> frozenset[int]:
|
| 69 |
+
return global_id_set(self.column_feature_ids)
|
| 70 |
+
|
| 71 |
+
def subset(self, global_feature_ids: Sequence[int] | None = None) -> FeatureMatrix:
|
| 72 |
+
"""Keep only the given global ids (order preserved). ``None`` = all columns."""
|
| 73 |
+
if global_feature_ids is None:
|
| 74 |
+
return self
|
| 75 |
+
cols: list[int] = []
|
| 76 |
+
labels: list[int] = []
|
| 77 |
+
for gid in global_feature_ids:
|
| 78 |
+
col = column_for_global(self.column_feature_ids, int(gid))
|
| 79 |
+
cols.append(col)
|
| 80 |
+
labels.append(int(gid))
|
| 81 |
+
return FeatureMatrix(
|
| 82 |
+
codes=self.codes[:, cols],
|
| 83 |
+
column_feature_ids=tuple(labels),
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
def slice_columns(self, column_indices: Sequence[int]) -> FeatureMatrix:
|
| 87 |
+
"""Slice by matrix column index; global ids follow the selected columns."""
|
| 88 |
+
idx = [int(i) for i in column_indices]
|
| 89 |
+
return FeatureMatrix(
|
| 90 |
+
codes=self.codes[:, idx],
|
| 91 |
+
column_feature_ids=tuple(self.column_feature_ids[i] for i in idx),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def with_row_mask(self, mask) -> FeatureMatrix:
|
| 95 |
+
"""Return a view with rows filtered (e.g. one distortion group)."""
|
| 96 |
+
return FeatureMatrix(
|
| 97 |
+
codes=self.codes[mask],
|
| 98 |
+
column_feature_ids=self.column_feature_ids,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def columns_for_global_subset(
|
| 103 |
+
column_feature_ids: Sequence[int],
|
| 104 |
+
global_feature_ids: Sequence[int] | None = None,
|
| 105 |
+
) -> tuple[list[int] | None, list[int]]:
|
| 106 |
+
"""Deprecated: use ``FeatureMatrix.subset``."""
|
| 107 |
+
col_ids = [int(fid) for fid in column_feature_ids]
|
| 108 |
+
if global_feature_ids is None:
|
| 109 |
+
return None, col_ids
|
| 110 |
+
cols: list[int] = []
|
| 111 |
+
names: list[int] = []
|
| 112 |
+
for gid in global_feature_ids:
|
| 113 |
+
col = column_for_global(col_ids, int(gid))
|
| 114 |
+
cols.append(col)
|
| 115 |
+
names.append(int(gid))
|
| 116 |
+
return cols, names
|
analysis/features/feature_matrix.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List, Sequence
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _zscore_columns(x: np.ndarray) -> np.ndarray:
|
| 11 |
+
mu = x.mean(axis=0, keepdims=True)
|
| 12 |
+
sigma = x.std(axis=0, keepdims=True)
|
| 13 |
+
sigma[sigma < 1e-8] = 1.0
|
| 14 |
+
return (x - mu) / sigma
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def build_feature_matrix(
|
| 18 |
+
feature_id: int,
|
| 19 |
+
features: FeatureMatrix,
|
| 20 |
+
rows_img: Sequence[int],
|
| 21 |
+
rows_patch: Sequence[int],
|
| 22 |
+
n_images_used: int,
|
| 23 |
+
patches_per_image: int,
|
| 24 |
+
) -> np.ndarray:
|
| 25 |
+
"""Build image-level matrix (n_images_used, patches_per_image) for one global SAE feature."""
|
| 26 |
+
col = features.column_for(feature_id)
|
| 27 |
+
vals = features.codes[:, col].toarray().ravel().astype(np.float32)
|
| 28 |
+
x = np.zeros((int(n_images_used), int(patches_per_image)), dtype=np.float32)
|
| 29 |
+
x[rows_img, rows_patch] = vals
|
| 30 |
+
return _zscore_columns(x)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def build_image_feature_matrix(
|
| 34 |
+
features: FeatureMatrix,
|
| 35 |
+
image_row_indices: Sequence[Sequence[int]],
|
| 36 |
+
n_images_used: int,
|
| 37 |
+
aggregation_mode: str = 'max',
|
| 38 |
+
) -> np.ndarray:
|
| 39 |
+
"""Build image-level feature matrix (n_images_used, n_features_total).
|
| 40 |
+
|
| 41 |
+
`image_row_indices` is a sequence where each element is an array of row indices
|
| 42 |
+
(patch indices) belonging to that image.
|
| 43 |
+
Aggregation modes: 'max', 'sum', 'mean_acts' (mean over positive activations) or 'mean'.
|
| 44 |
+
"""
|
| 45 |
+
return _zscore_columns(
|
| 46 |
+
build_image_feature_matrix_raw(
|
| 47 |
+
features,
|
| 48 |
+
image_row_indices,
|
| 49 |
+
n_images_used,
|
| 50 |
+
aggregation_mode,
|
| 51 |
+
)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_image_feature_matrix_raw(
|
| 56 |
+
features: FeatureMatrix,
|
| 57 |
+
image_row_indices: Sequence[Sequence[int]],
|
| 58 |
+
n_images_used: int,
|
| 59 |
+
aggregation_mode: str = 'max',
|
| 60 |
+
) -> np.ndarray:
|
| 61 |
+
"""Build an unnormalized image-level feature matrix.
|
| 62 |
+
|
| 63 |
+
This keeps the same aggregation logic as ``build_image_feature_matrix`` but
|
| 64 |
+
skips the final z-score normalization so it can be used for paired deltas.
|
| 65 |
+
"""
|
| 66 |
+
mode = str(aggregation_mode)
|
| 67 |
+
if mode not in {'max', 'sum', 'mean_acts', 'mean'}:
|
| 68 |
+
raise ValueError(f'Unknown aggregation mode: {mode!r}')
|
| 69 |
+
|
| 70 |
+
n_features_total = features.n_features
|
| 71 |
+
codes_subset = features.codes
|
| 72 |
+
x = np.zeros((int(n_images_used), int(n_features_total)), dtype=np.float32)
|
| 73 |
+
|
| 74 |
+
for img_i, row_ids in enumerate(image_row_indices):
|
| 75 |
+
if len(row_ids) == 0:
|
| 76 |
+
continue
|
| 77 |
+
chunk = codes_subset[row_ids, :]
|
| 78 |
+
if mode == 'max':
|
| 79 |
+
x[img_i] = np.asarray(chunk.max(axis=0)).ravel().astype(np.float32)
|
| 80 |
+
elif mode == 'sum':
|
| 81 |
+
x[img_i] = np.asarray(chunk.sum(axis=0)).ravel().astype(np.float32)
|
| 82 |
+
elif mode == 'mean':
|
| 83 |
+
x[img_i] = np.asarray(chunk.mean(axis=0)).ravel().astype(np.float32)
|
| 84 |
+
else: # mean_acts: mean over positive activations
|
| 85 |
+
if hasattr(chunk, 'multiply'):
|
| 86 |
+
positive_mask = chunk > 0
|
| 87 |
+
positive_chunk = chunk.multiply(positive_mask)
|
| 88 |
+
pos_sum = np.asarray(positive_chunk.sum(axis=0)).ravel().astype(np.float32)
|
| 89 |
+
pos_count = np.asarray(positive_mask.sum(axis=0)).ravel().astype(np.float32)
|
| 90 |
+
else:
|
| 91 |
+
chunk_arr = np.asarray(chunk, dtype=np.float32)
|
| 92 |
+
positive_mask = chunk_arr > 0
|
| 93 |
+
pos_sum = np.asarray((chunk_arr * positive_mask).sum(axis=0)).ravel().astype(np.float32)
|
| 94 |
+
pos_count = np.asarray(positive_mask.sum(axis=0)).ravel().astype(np.float32)
|
| 95 |
+
mean_pos = np.zeros(int(n_features_total), dtype=np.float32)
|
| 96 |
+
nonzero = pos_count > 0
|
| 97 |
+
mean_pos[nonzero] = pos_sum[nonzero] / pos_count[nonzero]
|
| 98 |
+
x[img_i] = mean_pos
|
| 99 |
+
|
| 100 |
+
return x
|
analysis/features/feature_selectors.py
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature selection strategies for automated SAE analysis reports."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from abc import ABC, abstractmethod
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 14 |
+
from analysis.metrics.correlations import compute_distortion_correlations
|
| 15 |
+
from analysis.metrics.mutual_information import compute_distortion_mutual_information
|
| 16 |
+
from analysis.metrics.paired_deltas import build_paired_delta_tables
|
| 17 |
+
from analysis.metrics.roc_auc import compute_distortion_roc_auc
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True)
|
| 21 |
+
class SelectorResult:
|
| 22 |
+
"""Output bundle produced by selector execution."""
|
| 23 |
+
|
| 24 |
+
selected_features: pd.DataFrame
|
| 25 |
+
metric_tables: Dict[str, pd.DataFrame]
|
| 26 |
+
selection_stats: Dict[str, object]
|
| 27 |
+
selector_name: str
|
| 28 |
+
selector_description: str
|
| 29 |
+
relation_label: str
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass(frozen=True)
|
| 33 |
+
class SelectorInputs:
|
| 34 |
+
"""Typed selector inputs with required core data and optional pristine data."""
|
| 35 |
+
|
| 36 |
+
meta_df: pd.DataFrame
|
| 37 |
+
features: FeatureMatrix
|
| 38 |
+
dataset: str
|
| 39 |
+
selector_top_k: int = 20
|
| 40 |
+
selector_params: Mapping[str, object] = field(default_factory=dict)
|
| 41 |
+
cache_paths: Any | None = None
|
| 42 |
+
pristine_meta_df: pd.DataFrame | None = None
|
| 43 |
+
pristine_features: FeatureMatrix | None = None
|
| 44 |
+
feature_weight_norms: Mapping[int, float] | None = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _match_metric_df_columns(corr_df: pd.DataFrame, feature_ids: Sequence[int]) -> List[object]:
|
| 48 |
+
id_set = {int(x) for x in feature_ids}
|
| 49 |
+
matched_columns: List[object] = []
|
| 50 |
+
for col in corr_df.columns:
|
| 51 |
+
try:
|
| 52 |
+
col_id = int(col)
|
| 53 |
+
except (TypeError, ValueError):
|
| 54 |
+
continue
|
| 55 |
+
if col_id in id_set:
|
| 56 |
+
matched_columns.append(col)
|
| 57 |
+
return matched_columns
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _build_feature_importance_table(
|
| 61 |
+
importance_series: pd.Series,
|
| 62 |
+
top_k: int,
|
| 63 |
+
metric_name: str,
|
| 64 |
+
) -> pd.DataFrame:
|
| 65 |
+
top_series = importance_series.nlargest(top_k)
|
| 66 |
+
result = pd.DataFrame({
|
| 67 |
+
'feature_id': [int(fid) for fid in top_series.index.tolist()],
|
| 68 |
+
'importance_score': [float(x) for x in top_series.values.tolist()],
|
| 69 |
+
})
|
| 70 |
+
result['importance_rank'] = range(1, len(result) + 1)
|
| 71 |
+
result['importance_metric'] = metric_name
|
| 72 |
+
return result[['feature_id', 'importance_score', 'importance_rank', 'importance_metric']]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _collect_table_stats(
|
| 76 |
+
stats: Dict[str, object],
|
| 77 |
+
table_key: str,
|
| 78 |
+
table_df: pd.DataFrame,
|
| 79 |
+
feature_ids: Sequence[int],
|
| 80 |
+
) -> None:
|
| 81 |
+
matched_cols = _match_metric_df_columns(table_df, feature_ids)
|
| 82 |
+
if not matched_cols:
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
table_values = table_df.loc[:, matched_cols]
|
| 86 |
+
stats[f'{table_key}_matched_cols'] = len(matched_cols)
|
| 87 |
+
stats[f'{table_key}_mean_abs'] = float(table_values.abs().values.mean())
|
| 88 |
+
stats[f'{table_key}_max_abs'] = float(table_values.abs().values.max())
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class BaseFeatureSelector(ABC):
|
| 92 |
+
"""Common interface for feature selectors."""
|
| 93 |
+
|
| 94 |
+
name: str
|
| 95 |
+
description: str
|
| 96 |
+
relation_label: str = 'feature relevance'
|
| 97 |
+
|
| 98 |
+
@abstractmethod
|
| 99 |
+
def compute_metric_tables(
|
| 100 |
+
self,
|
| 101 |
+
inputs: SelectorInputs,
|
| 102 |
+
feature_ids: Sequence[int] | None = None,
|
| 103 |
+
) -> Dict[str, pd.DataFrame]:
|
| 104 |
+
"""Compute metric tables required by the selector."""
|
| 105 |
+
|
| 106 |
+
@abstractmethod
|
| 107 |
+
def compute_importance(
|
| 108 |
+
self,
|
| 109 |
+
metric_tables: Dict[str, pd.DataFrame],
|
| 110 |
+
inputs: SelectorInputs | None = None,
|
| 111 |
+
feature_ids: Sequence[int] | None = None,
|
| 112 |
+
) -> Tuple[pd.Series, str]:
|
| 113 |
+
"""Return per-feature importance scores and metric name."""
|
| 114 |
+
|
| 115 |
+
def execute(
|
| 116 |
+
self,
|
| 117 |
+
inputs: SelectorInputs,
|
| 118 |
+
feature_ids: Sequence[int] | None = None,
|
| 119 |
+
) -> SelectorResult:
|
| 120 |
+
"""Run full selector pipeline and return selected features + computed measures."""
|
| 121 |
+
metric_tables = self.compute_metric_tables(inputs, feature_ids=feature_ids)
|
| 122 |
+
importance_series, metric_name = self.compute_importance(
|
| 123 |
+
metric_tables=metric_tables,
|
| 124 |
+
inputs=inputs,
|
| 125 |
+
feature_ids=feature_ids,
|
| 126 |
+
)
|
| 127 |
+
top_k = int(inputs.selector_top_k)
|
| 128 |
+
selected_features = _build_feature_importance_table(
|
| 129 |
+
importance_series=importance_series,
|
| 130 |
+
top_k=top_k,
|
| 131 |
+
metric_name=metric_name,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
selected_features = selected_features.copy()
|
| 135 |
+
selected_features['feature_id'] = selected_features['feature_id'].astype(int)
|
| 136 |
+
selected_features['importance_score'] = selected_features['importance_score'].astype(float)
|
| 137 |
+
selected_features['importance_rank'] = selected_features['importance_rank'].astype(int)
|
| 138 |
+
|
| 139 |
+
selected_features = selected_features.drop_duplicates(
|
| 140 |
+
subset=['feature_id'], keep='first').reset_index(drop=True)
|
| 141 |
+
if selected_features.empty:
|
| 142 |
+
raise ValueError('Selector returned empty feature list')
|
| 143 |
+
|
| 144 |
+
selected_features['importance_rank'] = range(1, len(selected_features) + 1)
|
| 145 |
+
stats = self.selection_stats(selected_features, metric_tables)
|
| 146 |
+
return SelectorResult(
|
| 147 |
+
selected_features=selected_features,
|
| 148 |
+
metric_tables=metric_tables,
|
| 149 |
+
selection_stats=stats,
|
| 150 |
+
selector_name=self.name,
|
| 151 |
+
selector_description=self.description,
|
| 152 |
+
relation_label=self.relation_label,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
def select_features(
|
| 156 |
+
self,
|
| 157 |
+
inputs: SelectorInputs,
|
| 158 |
+
feature_ids: Sequence[int] | None = None,
|
| 159 |
+
) -> pd.DataFrame:
|
| 160 |
+
"""Template method: normalize and return top-k with importance stats."""
|
| 161 |
+
metric_tables = self.compute_metric_tables(inputs, feature_ids=feature_ids)
|
| 162 |
+
importance_series, metric_name = self.compute_importance(
|
| 163 |
+
metric_tables=metric_tables,
|
| 164 |
+
inputs=inputs,
|
| 165 |
+
feature_ids=feature_ids,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
top_k = int(inputs.selector_top_k)
|
| 169 |
+
selected_features = _build_feature_importance_table(
|
| 170 |
+
importance_series=importance_series,
|
| 171 |
+
top_k=top_k,
|
| 172 |
+
metric_name=metric_name,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
selected_features = selected_features.copy()
|
| 176 |
+
selected_features['feature_id'] = selected_features['feature_id'].astype(int)
|
| 177 |
+
selected_features['importance_score'] = selected_features['importance_score'].astype(float)
|
| 178 |
+
selected_features['importance_rank'] = selected_features['importance_rank'].astype(int)
|
| 179 |
+
|
| 180 |
+
selected_features = selected_features.drop_duplicates(
|
| 181 |
+
subset=['feature_id'], keep='first').reset_index(drop=True)
|
| 182 |
+
if selected_features.empty:
|
| 183 |
+
raise ValueError('Selector returned empty feature list')
|
| 184 |
+
|
| 185 |
+
selected_features['importance_rank'] = range(1, len(selected_features) + 1)
|
| 186 |
+
return selected_features
|
| 187 |
+
|
| 188 |
+
def selection_stats(
|
| 189 |
+
self,
|
| 190 |
+
selected_features: pd.DataFrame,
|
| 191 |
+
metric_tables: Mapping[str, pd.DataFrame],
|
| 192 |
+
) -> Dict[str, object]:
|
| 193 |
+
feature_ids = selected_features['feature_id'].tolist()
|
| 194 |
+
stats: Dict[str, object] = {
|
| 195 |
+
'selector_name': self.name,
|
| 196 |
+
'selector_description': self.description,
|
| 197 |
+
'n_selected': len(feature_ids),
|
| 198 |
+
}
|
| 199 |
+
if not selected_features.empty:
|
| 200 |
+
stats['mean_importance_score'] = float(selected_features['importance_score'].mean())
|
| 201 |
+
stats['max_importance_score'] = float(selected_features['importance_score'].max())
|
| 202 |
+
stats['min_importance_score'] = float(selected_features['importance_score'].min())
|
| 203 |
+
if feature_ids:
|
| 204 |
+
numeric_ids = [int(x) for x in feature_ids]
|
| 205 |
+
stats['min_feature_id'] = min(numeric_ids)
|
| 206 |
+
stats['max_feature_id'] = max(numeric_ids)
|
| 207 |
+
|
| 208 |
+
for corr_key in ('corr_type_df', 'corr_group_df'):
|
| 209 |
+
corr_df = metric_tables.get(corr_key)
|
| 210 |
+
if isinstance(corr_df, pd.DataFrame) and len(feature_ids) > 0:
|
| 211 |
+
matched_cols = _match_metric_df_columns(corr_df, feature_ids)
|
| 212 |
+
if matched_cols:
|
| 213 |
+
abs_corr = corr_df.loc[:, matched_cols].abs()
|
| 214 |
+
stats[f'{corr_key}_matched_cols'] = len(matched_cols)
|
| 215 |
+
stats[f'{corr_key}_mean_abs_corr'] = float(abs_corr.values.mean())
|
| 216 |
+
stats[f'{corr_key}_max_abs_corr'] = float(abs_corr.values.max())
|
| 217 |
+
|
| 218 |
+
for mi_key in ('mi_type_df', 'mi_group_df'):
|
| 219 |
+
mi_df = metric_tables.get(mi_key)
|
| 220 |
+
if isinstance(mi_df, pd.DataFrame) and len(feature_ids) > 0:
|
| 221 |
+
matched_cols = _match_metric_df_columns(mi_df, feature_ids)
|
| 222 |
+
if matched_cols:
|
| 223 |
+
mi_values = mi_df.loc[:, matched_cols]
|
| 224 |
+
stats[f'{mi_key}_matched_cols'] = len(matched_cols)
|
| 225 |
+
stats[f'{mi_key}_mean'] = float(mi_values.values.mean())
|
| 226 |
+
stats[f'{mi_key}_max'] = float(mi_values.values.max())
|
| 227 |
+
|
| 228 |
+
for auc_key in ('auc_type_df', 'auc_group_df'):
|
| 229 |
+
auc_df = metric_tables.get(auc_key)
|
| 230 |
+
if isinstance(auc_df, pd.DataFrame) and len(feature_ids) > 0:
|
| 231 |
+
matched_cols = _match_metric_df_columns(auc_df, feature_ids)
|
| 232 |
+
if matched_cols:
|
| 233 |
+
auc_values = auc_df.loc[:, matched_cols]
|
| 234 |
+
stats[f'{auc_key}_matched_cols'] = len(matched_cols)
|
| 235 |
+
stats[f'{auc_key}_mean'] = float(auc_values.values.mean())
|
| 236 |
+
stats[f'{auc_key}_max'] = float(auc_values.values.max())
|
| 237 |
+
stats[f'{auc_key}_min'] = float(auc_values.values.min())
|
| 238 |
+
|
| 239 |
+
for key, value in metric_tables.items():
|
| 240 |
+
if not (isinstance(key, str) and key.startswith('paired_') and key.endswith('_df')):
|
| 241 |
+
continue
|
| 242 |
+
if isinstance(value, pd.DataFrame) and len(feature_ids) > 0:
|
| 243 |
+
_collect_table_stats(stats, key, value, feature_ids)
|
| 244 |
+
|
| 245 |
+
return stats
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@dataclass(frozen=True)
|
| 249 |
+
class TopKAbsCorrSelector(BaseFeatureSelector):
|
| 250 |
+
"""Top-k by max absolute correlation across category rows."""
|
| 251 |
+
|
| 252 |
+
name: str = 'topk_abs_corr'
|
| 253 |
+
description: str = 'Top-k features by max absolute dist_type correlation.'
|
| 254 |
+
corr_key: str = 'corr_type_df'
|
| 255 |
+
relation_label: str = 'высокая корреляция'
|
| 256 |
+
|
| 257 |
+
def compute_metric_tables(
|
| 258 |
+
self,
|
| 259 |
+
inputs: SelectorInputs,
|
| 260 |
+
feature_ids: Sequence[int] | None = None,
|
| 261 |
+
) -> Dict[str, pd.DataFrame]:
|
| 262 |
+
selector_params = dict(inputs.selector_params)
|
| 263 |
+
level = str(selector_params.get('level', 'patch'))
|
| 264 |
+
binarize = bool(selector_params.get('binarize', False))
|
| 265 |
+
group_col = 'dist_group' if self.corr_key == 'corr_group_df' else 'dist_type'
|
| 266 |
+
|
| 267 |
+
cache_path = selector_params.get('cache_path')
|
| 268 |
+
if cache_path is None:
|
| 269 |
+
cache_paths = inputs.cache_paths
|
| 270 |
+
if cache_paths is not None:
|
| 271 |
+
attr_map = {
|
| 272 |
+
('corr_type_df', 'patch'): 'corr_type_patch_cache_path',
|
| 273 |
+
('corr_group_df', 'patch'): 'corr_group_patch_cache_path',
|
| 274 |
+
('corr_type_df', 'image'): 'corr_type_cache_path',
|
| 275 |
+
('corr_group_df', 'image'): 'corr_group_cache_path',
|
| 276 |
+
}
|
| 277 |
+
cache_attr = attr_map.get((self.corr_key, level))
|
| 278 |
+
if cache_attr is not None and hasattr(cache_paths, cache_attr):
|
| 279 |
+
cache_path = getattr(cache_paths, cache_attr)
|
| 280 |
+
|
| 281 |
+
corr_df = compute_distortion_correlations(
|
| 282 |
+
meta=inputs.meta_df,
|
| 283 |
+
features=inputs.features,
|
| 284 |
+
group_col=group_col,
|
| 285 |
+
global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None,
|
| 286 |
+
cache_path=cache_path,
|
| 287 |
+
)
|
| 288 |
+
return {self.corr_key: corr_df}
|
| 289 |
+
|
| 290 |
+
def compute_importance(
|
| 291 |
+
self,
|
| 292 |
+
metric_tables: Dict[str, pd.DataFrame],
|
| 293 |
+
inputs: SelectorInputs | None = None,
|
| 294 |
+
feature_ids: Sequence[int] | None = None,
|
| 295 |
+
) -> Tuple[pd.Series, str]:
|
| 296 |
+
corr_df = metric_tables[self.corr_key]
|
| 297 |
+
if feature_ids is not None:
|
| 298 |
+
corr_df = corr_df[[int(fid) for fid in feature_ids]]
|
| 299 |
+
importance_series = corr_df.abs().max(axis=0)
|
| 300 |
+
return importance_series, f'max_abs_corr:{self.corr_key}'
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@dataclass(frozen=True)
|
| 304 |
+
class TopKAbsCorrGroupSelector(TopKAbsCorrSelector):
|
| 305 |
+
"""Top-k by max absolute correlation at distortion-group level."""
|
| 306 |
+
|
| 307 |
+
name: str = 'topk_abs_corr_group'
|
| 308 |
+
description: str = 'Top-k features by max absolute dist_group correlation.'
|
| 309 |
+
corr_key: str = 'corr_group_df'
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@dataclass(frozen=True)
|
| 313 |
+
class TopKMutualInfoSelector(BaseFeatureSelector):
|
| 314 |
+
"""Top-k by max mutual information across category rows."""
|
| 315 |
+
|
| 316 |
+
name: str = 'topk_mi'
|
| 317 |
+
description: str = 'Top-k features by max dist_type mutual information.'
|
| 318 |
+
mi_key: str = 'mi_type_df'
|
| 319 |
+
relation_label: str = 'высокая взаимная информация'
|
| 320 |
+
|
| 321 |
+
def compute_metric_tables(
|
| 322 |
+
self,
|
| 323 |
+
inputs: SelectorInputs,
|
| 324 |
+
feature_ids: Sequence[int] | None = None,
|
| 325 |
+
) -> Dict[str, pd.DataFrame]:
|
| 326 |
+
selector_params = dict(inputs.selector_params)
|
| 327 |
+
level = str(selector_params.get('level', 'patch'))
|
| 328 |
+
binarize = bool(selector_params.get('binarize', False))
|
| 329 |
+
n_bins = int(selector_params.get('n_bins', 16))
|
| 330 |
+
feature_chunk_size = int(selector_params.get('feature_chunk_size', 512))
|
| 331 |
+
group_col = 'dist_group' if self.mi_key == 'mi_group_df' else 'dist_type'
|
| 332 |
+
|
| 333 |
+
cache_path = selector_params.get('cache_path')
|
| 334 |
+
if cache_path is None:
|
| 335 |
+
cache_paths = inputs.cache_paths
|
| 336 |
+
if cache_paths is not None:
|
| 337 |
+
attr_map = {
|
| 338 |
+
('mi_type_df', 'patch'): 'mi_type_patch_cache_path',
|
| 339 |
+
('mi_group_df', 'patch'): 'mi_group_patch_cache_path',
|
| 340 |
+
('mi_type_df', 'image'): 'mi_type_cache_path',
|
| 341 |
+
('mi_group_df', 'image'): 'mi_group_cache_path',
|
| 342 |
+
}
|
| 343 |
+
cache_attr = attr_map.get((self.mi_key, level))
|
| 344 |
+
if cache_attr is not None and hasattr(cache_paths, cache_attr):
|
| 345 |
+
cache_path = getattr(cache_paths, cache_attr)
|
| 346 |
+
|
| 347 |
+
mi_df = compute_distortion_mutual_information(
|
| 348 |
+
meta=inputs.meta_df,
|
| 349 |
+
features=inputs.features,
|
| 350 |
+
group_col=group_col,
|
| 351 |
+
global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None,
|
| 352 |
+
binarize=binarize,
|
| 353 |
+
level=level,
|
| 354 |
+
n_bins=n_bins,
|
| 355 |
+
feature_chunk_size=feature_chunk_size,
|
| 356 |
+
cache_path=cache_path,
|
| 357 |
+
)
|
| 358 |
+
return {self.mi_key: mi_df}
|
| 359 |
+
|
| 360 |
+
def compute_importance(
|
| 361 |
+
self,
|
| 362 |
+
metric_tables: Dict[str, pd.DataFrame],
|
| 363 |
+
inputs: SelectorInputs | None = None,
|
| 364 |
+
feature_ids: Sequence[int] | None = None,
|
| 365 |
+
) -> Tuple[pd.Series, str]:
|
| 366 |
+
mi_df = metric_tables[self.mi_key]
|
| 367 |
+
if feature_ids is not None:
|
| 368 |
+
mi_df = mi_df[[int(fid) for fid in feature_ids]]
|
| 369 |
+
importance_series = mi_df.max(axis=0)
|
| 370 |
+
return importance_series, f'max_mi:{self.mi_key}'
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@dataclass(frozen=True)
|
| 374 |
+
class TopKMutualInfoGroupSelector(TopKMutualInfoSelector):
|
| 375 |
+
"""Top-k by max mutual information at distortion-group level."""
|
| 376 |
+
|
| 377 |
+
name: str = 'topk_mi_group'
|
| 378 |
+
description: str = 'Top-k features by max dist_group mutual information.'
|
| 379 |
+
mi_key: str = 'mi_group_df'
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
@dataclass(frozen=True)
|
| 383 |
+
class TopKRocAucSelector(BaseFeatureSelector):
|
| 384 |
+
"""Top-k by one-vs-rest ROC-AUC across distortion categories."""
|
| 385 |
+
|
| 386 |
+
name: str = 'topk_auc'
|
| 387 |
+
description: str = 'Top-k features by one-vs-rest ROC-AUC at dist_type level.'
|
| 388 |
+
auc_key: str = 'auc_type_df'
|
| 389 |
+
relation_label: str = 'высокая ROC-AUC различимость'
|
| 390 |
+
|
| 391 |
+
def compute_metric_tables(
|
| 392 |
+
self,
|
| 393 |
+
inputs: SelectorInputs,
|
| 394 |
+
feature_ids: Sequence[int] | None = None,
|
| 395 |
+
) -> Dict[str, pd.DataFrame]:
|
| 396 |
+
selector_params = dict(inputs.selector_params)
|
| 397 |
+
level = str(selector_params.get('level', 'patch'))
|
| 398 |
+
feature_chunk_size = int(selector_params.get('feature_chunk_size', 512))
|
| 399 |
+
group_col = 'dist_group' if self.auc_key == 'auc_group_df' else 'dist_type'
|
| 400 |
+
|
| 401 |
+
cache_path = selector_params.get('cache_path')
|
| 402 |
+
if cache_path is None:
|
| 403 |
+
cache_paths = inputs.cache_paths
|
| 404 |
+
if cache_paths is not None:
|
| 405 |
+
attr_map = {
|
| 406 |
+
('auc_type_df', 'patch'): 'auc_type_patch_cache_path',
|
| 407 |
+
('auc_group_df', 'patch'): 'auc_group_patch_cache_path',
|
| 408 |
+
('auc_type_df', 'image'): 'auc_type_cache_path',
|
| 409 |
+
('auc_group_df', 'image'): 'auc_group_cache_path',
|
| 410 |
+
}
|
| 411 |
+
cache_attr = attr_map.get((self.auc_key, level))
|
| 412 |
+
if cache_attr is not None and hasattr(cache_paths, cache_attr):
|
| 413 |
+
cache_path = getattr(cache_paths, cache_attr)
|
| 414 |
+
|
| 415 |
+
auc_df = compute_distortion_roc_auc(
|
| 416 |
+
meta=inputs.meta_df,
|
| 417 |
+
features=inputs.features,
|
| 418 |
+
group_col=group_col,
|
| 419 |
+
global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None,
|
| 420 |
+
level=level,
|
| 421 |
+
feature_chunk_size=feature_chunk_size,
|
| 422 |
+
cache_path=cache_path,
|
| 423 |
+
)
|
| 424 |
+
return {self.auc_key: auc_df}
|
| 425 |
+
|
| 426 |
+
def compute_importance(
|
| 427 |
+
self,
|
| 428 |
+
metric_tables: Dict[str, pd.DataFrame],
|
| 429 |
+
inputs: SelectorInputs | None = None,
|
| 430 |
+
feature_ids: Sequence[int] | None = None,
|
| 431 |
+
) -> Tuple[pd.Series, str]:
|
| 432 |
+
auc_df = metric_tables[self.auc_key]
|
| 433 |
+
if feature_ids is not None:
|
| 434 |
+
auc_df = auc_df[[int(fid) for fid in feature_ids]]
|
| 435 |
+
|
| 436 |
+
# Symmetric separability score: high both for direct and inverse predictors.
|
| 437 |
+
direct_auc = auc_df.max(axis=0)
|
| 438 |
+
inverse_auc = 1.0 - auc_df.min(axis=0)
|
| 439 |
+
importance_series = pd.concat([direct_auc, inverse_auc], axis=1).max(axis=1)
|
| 440 |
+
return importance_series, f'symmetric_auc:{self.auc_key}'
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@dataclass(frozen=True)
|
| 444 |
+
class TopKRocAucGroupSelector(TopKRocAucSelector):
|
| 445 |
+
"""Top-k by one-vs-rest ROC-AUC at distortion-group level."""
|
| 446 |
+
|
| 447 |
+
name: str = 'topk_auc_group'
|
| 448 |
+
description: str = 'Top-k features by one-vs-rest ROC-AUC at dist_group level.'
|
| 449 |
+
auc_key: str = 'auc_group_df'
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@dataclass(frozen=True)
|
| 453 |
+
class TopKPairedDeltaSelector(BaseFeatureSelector):
|
| 454 |
+
"""Top-k by original-vs-distorted paired activation deltas."""
|
| 455 |
+
|
| 456 |
+
name: str = 'paired_delta'
|
| 457 |
+
description: str = 'Top-k features by max relative paired activation delta.'
|
| 458 |
+
delta_key: str = 'paired_relative_dist_type_df'
|
| 459 |
+
relation_label: str = 'высокая парная дельта активаций'
|
| 460 |
+
|
| 461 |
+
@staticmethod
|
| 462 |
+
def _delta_mode_from_key(delta_key: str) -> str:
|
| 463 |
+
if '_signed_' in delta_key:
|
| 464 |
+
return 'signed'
|
| 465 |
+
if '_relative_' in delta_key:
|
| 466 |
+
return 'relative'
|
| 467 |
+
return 'abs'
|
| 468 |
+
|
| 469 |
+
@staticmethod
|
| 470 |
+
def _group_col_from_key(delta_key: str) -> str:
|
| 471 |
+
if 'dist_group' in delta_key:
|
| 472 |
+
return 'dist_group'
|
| 473 |
+
return 'dist_type'
|
| 474 |
+
|
| 475 |
+
def compute_metric_tables(
|
| 476 |
+
self,
|
| 477 |
+
inputs: SelectorInputs,
|
| 478 |
+
feature_ids: Sequence[int] | None = None,
|
| 479 |
+
) -> Dict[str, pd.DataFrame]:
|
| 480 |
+
if inputs.pristine_meta_df is None or inputs.pristine_features is None:
|
| 481 |
+
raise ValueError(
|
| 482 |
+
f"Selector '{self.name}' requires 'pristine_meta_df' and 'pristine_features' for paired deltas."
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
selector_params = dict(inputs.selector_params)
|
| 486 |
+
level = str(selector_params.get('level', 'patch'))
|
| 487 |
+
ranking_mode = str(selector_params.get('ranking_mode', 'default')).strip().lower()
|
| 488 |
+
if ranking_mode not in {'default', 'weighted_average'}:
|
| 489 |
+
raise ValueError(
|
| 490 |
+
f"Selector '{self.name}': unsupported ranking_mode={ranking_mode!r}. "
|
| 491 |
+
"Allowed: {'default', 'weighted_average'}."
|
| 492 |
+
)
|
| 493 |
+
delta_mode = self._delta_mode_from_key(self.delta_key)
|
| 494 |
+
group_col = self._group_col_from_key(self.delta_key)
|
| 495 |
+
|
| 496 |
+
cache_prefix = selector_params.get('cache_prefix')
|
| 497 |
+
if cache_prefix is None:
|
| 498 |
+
cache_paths = inputs.cache_paths
|
| 499 |
+
dataset_cache_dir = getattr(cache_paths, 'dataset_cache_dir', None) if cache_paths is not None else None
|
| 500 |
+
if dataset_cache_dir is not None:
|
| 501 |
+
cache_prefix = str(Path(dataset_cache_dir) / f'paired_delta_{level}')
|
| 502 |
+
|
| 503 |
+
tables = build_paired_delta_tables(
|
| 504 |
+
distorted_meta=inputs.meta_df,
|
| 505 |
+
distorted=inputs.features,
|
| 506 |
+
original_meta=inputs.pristine_meta_df,
|
| 507 |
+
original=inputs.pristine_features,
|
| 508 |
+
group_cols=(group_col,),
|
| 509 |
+
global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None,
|
| 510 |
+
delta_mode=delta_mode,
|
| 511 |
+
level=level,
|
| 512 |
+
cache_prefix=cache_prefix,
|
| 513 |
+
)
|
| 514 |
+
if self.delta_key not in tables:
|
| 515 |
+
available = ', '.join(sorted(tables.keys()))
|
| 516 |
+
raise ValueError(
|
| 517 |
+
f"Selector '{self.name}' expected table '{self.delta_key}', available: {available}"
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
metric_table = tables[self.delta_key]
|
| 521 |
+
if ranking_mode == 'weighted_average':
|
| 522 |
+
if not inputs.feature_weight_norms:
|
| 523 |
+
raise ValueError(
|
| 524 |
+
f"Selector '{self.name}' (ranking_mode='weighted_average') requires per-feature decoder weight norms."
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
norm_by_feature_id: Dict[int, float] = {}
|
| 528 |
+
for raw_feature_id, raw_norm in inputs.feature_weight_norms.items():
|
| 529 |
+
try:
|
| 530 |
+
feature_id = int(raw_feature_id)
|
| 531 |
+
norm_by_feature_id[feature_id] = float(raw_norm)
|
| 532 |
+
except (TypeError, ValueError):
|
| 533 |
+
continue
|
| 534 |
+
|
| 535 |
+
if not norm_by_feature_id:
|
| 536 |
+
raise ValueError(
|
| 537 |
+
f"Selector '{self.name}' (ranking_mode='weighted_average') received empty/invalid decoder weight norms."
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
weight_series = pd.Series(index=metric_table.columns, dtype='float64')
|
| 541 |
+
missing_columns: List[object] = []
|
| 542 |
+
for column in metric_table.columns:
|
| 543 |
+
try:
|
| 544 |
+
weight_series.loc[column] = norm_by_feature_id[int(column)]
|
| 545 |
+
except (TypeError, ValueError, KeyError):
|
| 546 |
+
missing_columns.append(column)
|
| 547 |
+
|
| 548 |
+
if missing_columns:
|
| 549 |
+
missing_preview = ', '.join(str(x) for x in missing_columns[:8])
|
| 550 |
+
raise ValueError(
|
| 551 |
+
f"Selector '{self.name}' has no decoder norms for {len(missing_columns)} features "
|
| 552 |
+
f"(first: {missing_preview})."
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
weighted_abs = metric_table.abs().mul(weight_series, axis=1)
|
| 556 |
+
col_min = weighted_abs.min(axis=0)
|
| 557 |
+
col_max = weighted_abs.max(axis=0)
|
| 558 |
+
denom = col_max - col_min
|
| 559 |
+
normalized = weighted_abs.subtract(col_min, axis=1)
|
| 560 |
+
nonzero_denom = denom > 0
|
| 561 |
+
if nonzero_denom.any():
|
| 562 |
+
normalized.loc[:, nonzero_denom] = normalized.loc[:, nonzero_denom].div(denom[nonzero_denom], axis=1)
|
| 563 |
+
if (~nonzero_denom).any():
|
| 564 |
+
normalized.loc[:, ~nonzero_denom] = 0.0
|
| 565 |
+
metric_table = normalized.astype('float32', copy=False)
|
| 566 |
+
|
| 567 |
+
return {self.delta_key: metric_table}
|
| 568 |
+
|
| 569 |
+
def compute_importance(
|
| 570 |
+
self,
|
| 571 |
+
metric_tables: Dict[str, pd.DataFrame],
|
| 572 |
+
inputs: SelectorInputs | None = None,
|
| 573 |
+
feature_ids: Sequence[int] | None = None,
|
| 574 |
+
) -> Tuple[pd.Series, str]:
|
| 575 |
+
delta_df = metric_tables[self.delta_key]
|
| 576 |
+
if feature_ids is not None:
|
| 577 |
+
delta_df = delta_df[[int(fid) for fid in feature_ids]]
|
| 578 |
+
|
| 579 |
+
ranking_mode = 'default'
|
| 580 |
+
if inputs is not None:
|
| 581 |
+
ranking_mode = str(inputs.selector_params.get('ranking_mode', 'default')).strip().lower()
|
| 582 |
+
|
| 583 |
+
if ranking_mode == 'weighted_average':
|
| 584 |
+
importance_series = delta_df.mean(axis=0)
|
| 585 |
+
return importance_series, f'mean_weighted_delta_norm01:{self.delta_key}'
|
| 586 |
+
|
| 587 |
+
delta_abs_mean = delta_df.abs().mean(axis=0)
|
| 588 |
+
return delta_abs_mean, f'mean_abs_delta:{self.delta_key}'
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
@dataclass(frozen=True)
|
| 592 |
+
class TopKPairedDeltaAbsTypeSelector(TopKPairedDeltaSelector):
|
| 593 |
+
name: str = 'paired_delta_abs_type'
|
| 594 |
+
description: str = 'Top-k features by absolute paired delta at dist_type level.'
|
| 595 |
+
delta_key: str = 'paired_abs_dist_type_df'
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
@dataclass(frozen=True)
|
| 599 |
+
class TopKPairedDeltaAbsGroupSelector(TopKPairedDeltaSelector):
|
| 600 |
+
name: str = 'paired_delta_abs_group'
|
| 601 |
+
description: str = 'Top-k features by absolute paired delta at dist_group level.'
|
| 602 |
+
delta_key: str = 'paired_abs_dist_group_df'
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
@dataclass(frozen=True)
|
| 606 |
+
class TopKPairedDeltaSignedTypeSelector(TopKPairedDeltaSelector):
|
| 607 |
+
name: str = 'paired_delta_signed_type'
|
| 608 |
+
description: str = 'Top-k features by signed paired delta at dist_type level.'
|
| 609 |
+
delta_key: str = 'paired_signed_dist_type_df'
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
@dataclass(frozen=True)
|
| 613 |
+
class TopKPairedDeltaSignedGroupSelector(TopKPairedDeltaSelector):
|
| 614 |
+
name: str = 'paired_delta_signed_group'
|
| 615 |
+
description: str = 'Top-k features by signed paired delta at dist_group level.'
|
| 616 |
+
delta_key: str = 'paired_signed_dist_group_df'
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
@dataclass(frozen=True)
|
| 620 |
+
class TopKPairedDeltaRelativeTypeSelector(TopKPairedDeltaSelector):
|
| 621 |
+
name: str = 'paired_delta_relative_type'
|
| 622 |
+
description: str = 'Top-k features by relative paired delta at dist_type level.'
|
| 623 |
+
delta_key: str = 'paired_relative_dist_type_df'
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
@dataclass(frozen=True)
|
| 627 |
+
class TopKPairedDeltaRelativeGroupSelector(TopKPairedDeltaSelector):
|
| 628 |
+
name: str = 'paired_delta_relative_group'
|
| 629 |
+
description: str = 'Top-k features by relative paired delta at dist_group level.'
|
| 630 |
+
delta_key: str = 'paired_relative_dist_group_df'
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
@dataclass(frozen=True)
|
| 634 |
+
class TopKIoUSelector(BaseFeatureSelector):
|
| 635 |
+
"""Top-k by median IoU (Intersection over Union) with distortion masks at dist_type level."""
|
| 636 |
+
|
| 637 |
+
name: str = 'topk_iou_type'
|
| 638 |
+
description: str = 'Top-k features by median IoU with distortion regions at dist_type level.'
|
| 639 |
+
relation_label: str = 'высокая пространственная локализация'
|
| 640 |
+
|
| 641 |
+
def compute_metric_tables(
|
| 642 |
+
self,
|
| 643 |
+
inputs: SelectorInputs,
|
| 644 |
+
feature_ids: Sequence[int] | None = None,
|
| 645 |
+
) -> Dict[str, pd.DataFrame]:
|
| 646 |
+
from analysis.metrics.iou_utils import compute_iou_per_distortion_type_and_feature
|
| 647 |
+
|
| 648 |
+
selector_params = dict(inputs.selector_params)
|
| 649 |
+
iou_key = inputs.selector_params.get('iou_key', 'iou_type_df')
|
| 650 |
+
group_col = 'dist_group' if iou_key == 'iou_group_df' else 'dist_type'
|
| 651 |
+
|
| 652 |
+
cache_path = selector_params.get('cache_path')
|
| 653 |
+
if cache_path is None:
|
| 654 |
+
cache_paths = inputs.cache_paths
|
| 655 |
+
if cache_paths is not None:
|
| 656 |
+
attr_map = {
|
| 657 |
+
('iou_type_df',): 'iou_type_cache_path',
|
| 658 |
+
('iou_group_df',): 'iou_group_cache_path',
|
| 659 |
+
}
|
| 660 |
+
cache_attr = attr_map.get((iou_key,))
|
| 661 |
+
if cache_attr is not None and hasattr(cache_paths, cache_attr):
|
| 662 |
+
cache_path = getattr(cache_paths, cache_attr)
|
| 663 |
+
|
| 664 |
+
# Infer spatial shape from patch counts
|
| 665 |
+
# Assume square grid: n_patches = height * width
|
| 666 |
+
n_samples_total = inputs.features.codes.shape[0]
|
| 667 |
+
n_unique_images = inputs.meta_df['image_idx'].nunique()
|
| 668 |
+
patches_per_image = n_samples_total // n_unique_images if n_unique_images > 0 else 49
|
| 669 |
+
spatial_dim = int(np.sqrt(patches_per_image))
|
| 670 |
+
|
| 671 |
+
# Allow override via selector_params
|
| 672 |
+
if 'spatial_shape' in selector_params:
|
| 673 |
+
spatial_shape = tuple(selector_params['spatial_shape'])
|
| 674 |
+
else:
|
| 675 |
+
spatial_shape = (spatial_dim, spatial_dim)
|
| 676 |
+
|
| 677 |
+
feature_batch_size = int(selector_params.get('feature_batch_size', 1024))
|
| 678 |
+
|
| 679 |
+
iou_df = compute_iou_per_distortion_type_and_feature(
|
| 680 |
+
features=inputs.features,
|
| 681 |
+
meta_df=inputs.meta_df,
|
| 682 |
+
spatial_shape=spatial_shape,
|
| 683 |
+
group_col=group_col,
|
| 684 |
+
global_feature_ids=[int(fid) for fid in feature_ids] if feature_ids is not None else None,
|
| 685 |
+
feature_batch_size=feature_batch_size,
|
| 686 |
+
cache_path=cache_path,
|
| 687 |
+
dataset=inputs.dataset,
|
| 688 |
+
)
|
| 689 |
+
|
| 690 |
+
return {iou_key: iou_df}
|
| 691 |
+
|
| 692 |
+
def compute_importance(
|
| 693 |
+
self,
|
| 694 |
+
metric_tables: Dict[str, pd.DataFrame],
|
| 695 |
+
inputs: SelectorInputs | None = None,
|
| 696 |
+
feature_ids: Sequence[int] | None = None,
|
| 697 |
+
) -> Tuple[pd.Series, str]:
|
| 698 |
+
iou_key = inputs.selector_params.get('iou_key', 'iou_type_df')
|
| 699 |
+
iou_df = metric_tables[iou_key]
|
| 700 |
+
if iou_df.empty:
|
| 701 |
+
# Return empty series if no IoU data
|
| 702 |
+
if feature_ids is not None:
|
| 703 |
+
feature_ids_int = [int(fid) for fid in feature_ids]
|
| 704 |
+
importance_series = pd.Series(0.0, index=feature_ids_int)
|
| 705 |
+
else:
|
| 706 |
+
importance_series = pd.Series(dtype=float)
|
| 707 |
+
return importance_series, f'max_median_iou:{iou_key}'
|
| 708 |
+
|
| 709 |
+
if feature_ids is not None:
|
| 710 |
+
iou_df = iou_df[[int(fid) for fid in feature_ids]]
|
| 711 |
+
|
| 712 |
+
# Take max IoU across all distortion types (each row is a distortion type)
|
| 713 |
+
importance_series = iou_df.max(axis=0)
|
| 714 |
+
return importance_series, f'max_median_iou:{iou_key}'
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
@dataclass(frozen=True)
|
| 718 |
+
class TopKIoUDistGroupSelector(TopKIoUSelector):
|
| 719 |
+
"""Top-k by median IoU (Intersection over Union) with distortion masks at dist_group level."""
|
| 720 |
+
|
| 721 |
+
name: str = 'topk_iou_group'
|
| 722 |
+
description: str = 'Top-k features by median IoU with distortion regions at dist_group level.'
|
| 723 |
+
iou_key: str = 'iou_group_df'
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
SELECTOR_REGISTRY: Mapping[str, BaseFeatureSelector] = {
|
| 727 |
+
'topk_abs_corr': TopKAbsCorrSelector(),
|
| 728 |
+
'topk_abs_corr_group': TopKAbsCorrGroupSelector(),
|
| 729 |
+
'topk_mi': TopKMutualInfoSelector(),
|
| 730 |
+
'topk_mi_group': TopKMutualInfoGroupSelector(),
|
| 731 |
+
'topk_auc_type_image': TopKRocAucSelector(),
|
| 732 |
+
'topk_auc_group_image': TopKRocAucGroupSelector(),
|
| 733 |
+
'topk_auc_type_patch': TopKRocAucSelector(),
|
| 734 |
+
'topk_auc_group_patch': TopKRocAucGroupSelector(),
|
| 735 |
+
'paired_delta_abs_type': TopKPairedDeltaAbsTypeSelector(),
|
| 736 |
+
'paired_delta_abs_group': TopKPairedDeltaAbsGroupSelector(),
|
| 737 |
+
'paired_delta_signed_type': TopKPairedDeltaSignedTypeSelector(),
|
| 738 |
+
'paired_delta_signed_group': TopKPairedDeltaSignedGroupSelector(),
|
| 739 |
+
'paired_delta_relative_type': TopKPairedDeltaRelativeTypeSelector(),
|
| 740 |
+
'paired_delta_relative_group': TopKPairedDeltaRelativeGroupSelector(),
|
| 741 |
+
'topk_iou_type': TopKIoUSelector(),
|
| 742 |
+
'topk_iou_group': TopKIoUDistGroupSelector(),
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def get_selector_registry() -> Dict[str, BaseFeatureSelector]:
|
| 747 |
+
"""Return a mutable copy of built-in selector registry."""
|
| 748 |
+
return dict(SELECTOR_REGISTRY)
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def load_selector(selector_name: str) -> BaseFeatureSelector:
|
| 752 |
+
"""Load built-in selector by name."""
|
| 753 |
+
normalized = selector_name or 'topk_abs_corr'
|
| 754 |
+
selector = SELECTOR_REGISTRY.get(normalized)
|
| 755 |
+
if selector is None:
|
| 756 |
+
available = ', '.join(sorted(SELECTOR_REGISTRY.keys()))
|
| 757 |
+
raise ValueError(f'Unknown selector {selector_name!r}. Available: {available}')
|
| 758 |
+
return selector
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def run_selector(
|
| 762 |
+
selector: BaseFeatureSelector,
|
| 763 |
+
inputs: SelectorInputs,
|
| 764 |
+
feature_ids: Sequence[int] | None = None,
|
| 765 |
+
) -> pd.DataFrame:
|
| 766 |
+
"""Compatibility wrapper around selector.select_features."""
|
| 767 |
+
selected_features = selector.select_features(inputs, feature_ids=feature_ids)
|
| 768 |
+
required_cols = {'feature_id', 'importance_score', 'importance_rank', 'importance_metric'}
|
| 769 |
+
missing_cols = required_cols.difference(selected_features.columns)
|
| 770 |
+
if missing_cols:
|
| 771 |
+
missing = ', '.join(sorted(missing_cols))
|
| 772 |
+
raise ValueError(f'Selector result is missing required columns: {missing}')
|
| 773 |
+
return selected_features
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def run_selector_with_metrics(
|
| 777 |
+
selector: BaseFeatureSelector,
|
| 778 |
+
inputs: SelectorInputs,
|
| 779 |
+
feature_ids: Sequence[int] | None = None,
|
| 780 |
+
) -> SelectorResult:
|
| 781 |
+
"""Run selector and return selected features plus computed metric tables."""
|
| 782 |
+
result = selector.execute(inputs, feature_ids=feature_ids)
|
| 783 |
+
required_cols = {'feature_id', 'importance_score', 'importance_rank', 'importance_metric'}
|
| 784 |
+
missing_cols = required_cols.difference(result.selected_features.columns)
|
| 785 |
+
if missing_cols:
|
| 786 |
+
missing = ', '.join(sorted(missing_cols))
|
| 787 |
+
raise ValueError(f'Selector result is missing required columns: {missing}')
|
| 788 |
+
return result
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
def selected_feature_ids(selected_features: pd.DataFrame) -> List[int]:
|
| 792 |
+
"""Convenience helper to get ordered feature ids from selector output."""
|
| 793 |
+
return [int(x) for x in selected_features['feature_id'].tolist()]
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
def _normalize_selector_configs(selector_configs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 797 |
+
if not selector_configs:
|
| 798 |
+
raise ValueError("selector_configs must contain at least one selector config")
|
| 799 |
+
|
| 800 |
+
normalized: List[Dict[str, Any]] = []
|
| 801 |
+
for selector_cfg in selector_configs:
|
| 802 |
+
if not isinstance(selector_cfg, dict):
|
| 803 |
+
raise ValueError("Each selector config must be a dict: {'name': str, 'params': dict, 'top_k': int}")
|
| 804 |
+
|
| 805 |
+
selector_name = str(selector_cfg.get('name', '')).strip()
|
| 806 |
+
if not selector_name:
|
| 807 |
+
raise ValueError("Each selector config must include non-empty 'name'")
|
| 808 |
+
|
| 809 |
+
if 'params' not in selector_cfg:
|
| 810 |
+
raise ValueError(f"Selector '{selector_name}': 'params' is required and must be a dict")
|
| 811 |
+
selector_params = selector_cfg['params']
|
| 812 |
+
if selector_params is None:
|
| 813 |
+
selector_params = {}
|
| 814 |
+
if not isinstance(selector_params, dict):
|
| 815 |
+
raise ValueError(f"Selector '{selector_name}': 'params' must be a dict")
|
| 816 |
+
|
| 817 |
+
if 'top_k' not in selector_cfg:
|
| 818 |
+
raise ValueError(f"Selector '{selector_name}': 'top_k' is required and must be positive")
|
| 819 |
+
selector_top_k = int(selector_cfg['top_k'])
|
| 820 |
+
if selector_top_k <= 0:
|
| 821 |
+
raise ValueError(f"Selector '{selector_name}': 'top_k' must be positive")
|
| 822 |
+
|
| 823 |
+
selector_feature_ids = selector_cfg.get('feature_ids')
|
| 824 |
+
if selector_feature_ids is not None:
|
| 825 |
+
if not isinstance(selector_feature_ids, (list, tuple)):
|
| 826 |
+
raise ValueError(f"Selector '{selector_name}': 'feature_ids' must be list/tuple or null")
|
| 827 |
+
selector_feature_ids = [int(fid) for fid in selector_feature_ids]
|
| 828 |
+
|
| 829 |
+
normalized.append({
|
| 830 |
+
'name': selector_name,
|
| 831 |
+
'params': selector_params,
|
| 832 |
+
'top_k': selector_top_k,
|
| 833 |
+
'feature_ids': selector_feature_ids,
|
| 834 |
+
})
|
| 835 |
+
|
| 836 |
+
return normalized
|
| 837 |
+
|
| 838 |
+
|
| 839 |
+
def run_selectors_from_configs(
|
| 840 |
+
*,
|
| 841 |
+
selector_configs: List[Dict[str, Any]],
|
| 842 |
+
meta_df: Any,
|
| 843 |
+
features: FeatureMatrix,
|
| 844 |
+
pristine_meta_df: Any,
|
| 845 |
+
pristine_features: FeatureMatrix | None,
|
| 846 |
+
dataset: str,
|
| 847 |
+
sae_checkpoint_path: str,
|
| 848 |
+
cache_dir: str,
|
| 849 |
+
cache_paths: Any = None,
|
| 850 |
+
selector_registry: Optional[Dict[str, BaseFeatureSelector]] = None,
|
| 851 |
+
feature_weight_norms: Optional[Mapping[int, float]] = None,
|
| 852 |
+
) -> Dict[str, SelectorResult]:
|
| 853 |
+
"""Execute configured selectors and return full per-run selector outputs."""
|
| 854 |
+
from analysis.models import extract_decoder_weight_norms
|
| 855 |
+
|
| 856 |
+
normalized_configs = _normalize_selector_configs(selector_configs)
|
| 857 |
+
|
| 858 |
+
needs_pristine_inputs = any(
|
| 859 |
+
str(selector_cfg.get('name', '')).startswith('paired_delta')
|
| 860 |
+
for selector_cfg in normalized_configs
|
| 861 |
+
)
|
| 862 |
+
if not needs_pristine_inputs:
|
| 863 |
+
pristine_meta_df = None
|
| 864 |
+
pristine_features = None
|
| 865 |
+
|
| 866 |
+
registry = dict(selector_registry) if selector_registry is not None else get_selector_registry()
|
| 867 |
+
selector_entries = []
|
| 868 |
+
for selector_cfg in normalized_configs:
|
| 869 |
+
selector_name = selector_cfg['name']
|
| 870 |
+
if selector_name not in registry:
|
| 871 |
+
available = ', '.join(sorted(registry.keys()))
|
| 872 |
+
raise ValueError(f"Unknown selector '{selector_name}'. Available: {available}")
|
| 873 |
+
selector_entries.append((selector_name, registry[selector_name], selector_cfg))
|
| 874 |
+
|
| 875 |
+
needs_weighted_delta = False
|
| 876 |
+
for selector_cfg in normalized_configs:
|
| 877 |
+
selector_params = selector_cfg.get('params', {})
|
| 878 |
+
if selector_params and str(selector_params.get('ranking_mode', 'default')) == 'weighted_average':
|
| 879 |
+
needs_weighted_delta = True
|
| 880 |
+
break
|
| 881 |
+
|
| 882 |
+
resolved_weight_norms: Optional[Mapping[int, float]] = feature_weight_norms
|
| 883 |
+
if needs_weighted_delta and not resolved_weight_norms:
|
| 884 |
+
print('[run] Loading SAE decoder norms for weighted_average ranking mode...')
|
| 885 |
+
resolved_weight_norms = extract_decoder_weight_norms(
|
| 886 |
+
checkpoint_path=sae_checkpoint_path,
|
| 887 |
+
cache_dir=cache_dir,
|
| 888 |
+
)
|
| 889 |
+
print(f'[run] Loaded decoder norms for {len(resolved_weight_norms)} SAE features.')
|
| 890 |
+
|
| 891 |
+
selector_name_counts: Dict[str, int] = {}
|
| 892 |
+
for selector_name, _, _ in selector_entries:
|
| 893 |
+
selector_name_counts[selector_name] = selector_name_counts.get(selector_name, 0) + 1
|
| 894 |
+
|
| 895 |
+
selector_results: Dict[str, SelectorResult] = {}
|
| 896 |
+
for selector_idx, (selector_name, selector, selector_cfg) in enumerate(selector_entries, start=1):
|
| 897 |
+
selector_run_key = selector_name
|
| 898 |
+
if selector_name_counts[selector_name] > 1:
|
| 899 |
+
selector_run_key = f'{selector_name}__{selector_idx}'
|
| 900 |
+
|
| 901 |
+
selector_inputs = SelectorInputs(
|
| 902 |
+
meta_df=meta_df,
|
| 903 |
+
features=features,
|
| 904 |
+
dataset=dataset,
|
| 905 |
+
pristine_meta_df=pristine_meta_df,
|
| 906 |
+
pristine_features=pristine_features,
|
| 907 |
+
feature_weight_norms=resolved_weight_norms,
|
| 908 |
+
cache_paths=cache_paths,
|
| 909 |
+
selector_top_k=int(selector_cfg['top_k']),
|
| 910 |
+
selector_params=dict(selector_cfg.get('params', {})),
|
| 911 |
+
)
|
| 912 |
+
selector_feature_ids = selector_cfg.get('feature_ids')
|
| 913 |
+
selector_results[selector_run_key] = selector.execute(
|
| 914 |
+
selector_inputs,
|
| 915 |
+
feature_ids=selector_feature_ids,
|
| 916 |
+
)
|
| 917 |
+
|
| 918 |
+
return selector_results
|
analysis/features/feature_stats.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Статистики по SAE-признакам и их визуализация (bar charts).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
|
| 11 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def compute_feature_stats(features: FeatureMatrix) -> pd.DataFrame:
|
| 15 |
+
"""
|
| 16 |
+
Вычисляет статистики для каждого признака SAE.
|
| 17 |
+
|
| 18 |
+
Параметры
|
| 19 |
+
----------
|
| 20 |
+
features : CSR activations; ``column_feature_ids[j]`` = global SAE id
|
| 21 |
+
|
| 22 |
+
Возвращает
|
| 23 |
+
----------
|
| 24 |
+
DataFrame с колонками: feature_id (global SAE id), mean, frequency, max, mean_acts
|
| 25 |
+
mean — средняя активация по всем патчам
|
| 26 |
+
frequency — доля патчей с ненулевой активацией
|
| 27 |
+
max — максимальная активация среди всех патчей
|
| 28 |
+
mean_acts — средняя активация по ненулевым патчам
|
| 29 |
+
"""
|
| 30 |
+
codes = features.codes
|
| 31 |
+
mat = codes if codes.dtype == np.float32 else codes.astype(np.float32)
|
| 32 |
+
|
| 33 |
+
n_rows = mat.shape[0]
|
| 34 |
+
means = np.asarray(mat.mean(axis=0)).ravel()
|
| 35 |
+
|
| 36 |
+
freqs = np.asarray(mat.getnnz(axis=0), dtype=np.float32).ravel() / float(n_rows)
|
| 37 |
+
|
| 38 |
+
maxes = np.asarray(mat.tocsc().max(axis=0).toarray()).ravel()
|
| 39 |
+
mean_acts = np.where(freqs > 0, means / freqs, 0.0)
|
| 40 |
+
|
| 41 |
+
col_ids = [int(fid) for fid in features.column_feature_ids]
|
| 42 |
+
return pd.DataFrame({
|
| 43 |
+
'feature_id': col_ids,
|
| 44 |
+
'mean': means,
|
| 45 |
+
'frequency': freqs,
|
| 46 |
+
'max': maxes,
|
| 47 |
+
'mean_acts': mean_acts,
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_top_features(
|
| 52 |
+
stats: pd.DataFrame,
|
| 53 |
+
top_k: int = 20,
|
| 54 |
+
criterion: str = 'mean',
|
| 55 |
+
min_mean_acts: Optional[float] = None,
|
| 56 |
+
) -> List[int]:
|
| 57 |
+
"""
|
| 58 |
+
Возвращает индексы top-K признаков по выбранному критерию.
|
| 59 |
+
|
| 60 |
+
Параметры
|
| 61 |
+
----------
|
| 62 |
+
stats : DataFrame из compute_feature_stats
|
| 63 |
+
top_k : число топ-признаков
|
| 64 |
+
criterion : 'mean' | 'frequency' | 'max' | 'mean_acts'
|
| 65 |
+
min_mean_acts : предварительный фильтр по mean_acts
|
| 66 |
+
|
| 67 |
+
Возвращает
|
| 68 |
+
----------
|
| 69 |
+
List[int] — feature_id в порядке убывания критерия
|
| 70 |
+
"""
|
| 71 |
+
assert criterion in ('mean', 'frequency', 'max', 'mean_acts'), \
|
| 72 |
+
f"criterion must be one of 'mean', 'frequency', 'max', 'mean_acts', got {criterion!r}"
|
| 73 |
+
filtered = stats
|
| 74 |
+
if min_mean_acts is not None:
|
| 75 |
+
filtered = stats[stats['mean_acts'] >= min_mean_acts]
|
| 76 |
+
return filtered.nlargest(top_k, criterion)['feature_id'].tolist()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def plot_top_features(
|
| 80 |
+
stats: pd.DataFrame,
|
| 81 |
+
top_k: int = 20,
|
| 82 |
+
criterion: str = 'mean',
|
| 83 |
+
figsize: Tuple[int, int] = (14, 4),
|
| 84 |
+
min_mean_acts: Optional[float] = None,
|
| 85 |
+
) -> None:
|
| 86 |
+
"""
|
| 87 |
+
Bar-chart топ-K признаков по выбранному критерию.
|
| 88 |
+
Над каждым баром подписывается frequency (доля ненулевых патчей).
|
| 89 |
+
"""
|
| 90 |
+
top_ids = get_top_features(stats, top_k=top_k, criterion=criterion,
|
| 91 |
+
min_mean_acts=min_mean_acts)
|
| 92 |
+
top_stats = stats.set_index('feature_id').loc[top_ids]
|
| 93 |
+
|
| 94 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 95 |
+
bars = ax.bar(range(len(top_ids)), top_stats[criterion].values, color='steelblue')
|
| 96 |
+
|
| 97 |
+
for bar, freq_val in zip(bars, top_stats['frequency'].values):
|
| 98 |
+
ax.text(
|
| 99 |
+
bar.get_x() + bar.get_width() / 2,
|
| 100 |
+
bar.get_height(),
|
| 101 |
+
f'{freq_val:.3f}',
|
| 102 |
+
ha='center', va='bottom', fontsize=6, color='black', rotation=90,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
ax.set_xticks(range(len(top_ids)))
|
| 106 |
+
ax.set_xticklabels([str(i) for i in top_ids], rotation=90, fontsize=8)
|
| 107 |
+
ax.set_xlabel('Feature ID')
|
| 108 |
+
ax.set_ylabel(criterion)
|
| 109 |
+
ax.set_title(f'Top-{top_k} features by {criterion}')
|
| 110 |
+
plt.tight_layout()
|
| 111 |
+
plt.show()
|
analysis/metrics/__init__.py
ADDED
|
File without changes
|
analysis/metrics/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (163 Bytes). View file
|
|
|
analysis/metrics/__pycache__/correlations.cpython-310.pyc
ADDED
|
Binary file (3.79 kB). View file
|
|
|
analysis/metrics/__pycache__/iou_utils.cpython-310.pyc
ADDED
|
Binary file (9 kB). View file
|
|
|
analysis/metrics/__pycache__/mutual_information.cpython-310.pyc
ADDED
|
Binary file (3.52 kB). View file
|
|
|
analysis/metrics/__pycache__/paired_deltas.cpython-310.pyc
ADDED
|
Binary file (6.77 kB). View file
|
|
|
analysis/metrics/__pycache__/precision_recall.cpython-310.pyc
ADDED
|
Binary file (3.68 kB). View file
|
|
|
analysis/metrics/__pycache__/roc_auc.cpython-310.pyc
ADDED
|
Binary file (2.85 kB). View file
|
|
|
analysis/metrics/correlations.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Correlation computations for SAE feature analysis."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional, Tuple
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import scipy.sparse as sp
|
| 8 |
+
|
| 9 |
+
from analysis.cache_utils import load_parquet_cache, save_parquet_cache
|
| 10 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _prepare_codes_and_categories(
|
| 14 |
+
meta: pd.DataFrame,
|
| 15 |
+
features: FeatureMatrix,
|
| 16 |
+
group_col: str,
|
| 17 |
+
level: str,
|
| 18 |
+
binarize: bool,
|
| 19 |
+
) -> Tuple[sp.csr_matrix | np.ndarray, np.ndarray, List[int], np.ndarray, np.ndarray]:
|
| 20 |
+
codes = features.codes
|
| 21 |
+
feature_names = [int(fid) for fid in features.column_feature_ids]
|
| 22 |
+
|
| 23 |
+
if binarize:
|
| 24 |
+
threshold = 0.2 # Такой был в PatchSAE
|
| 25 |
+
print(f'[binarize] Бинаризация с порогом {threshold}')
|
| 26 |
+
if sp.issparse(codes):
|
| 27 |
+
codes = codes.copy()
|
| 28 |
+
codes.data = (codes.data > threshold).astype(np.float32)
|
| 29 |
+
codes.eliminate_zeros()
|
| 30 |
+
else:
|
| 31 |
+
codes = (codes > threshold).astype(np.float32)
|
| 32 |
+
|
| 33 |
+
if level == 'image':
|
| 34 |
+
image_idx_arr = meta['image_idx'].values
|
| 35 |
+
rows, cats = [], []
|
| 36 |
+
groups = meta.groupby('image_idx').indices
|
| 37 |
+
|
| 38 |
+
for img_idx, _ in groups.items():
|
| 39 |
+
mask = np.where(image_idx_arr == img_idx)[0]
|
| 40 |
+
avg = np.asarray(codes[mask].mean(axis=0)).ravel().astype(np.float32)
|
| 41 |
+
rows.append(avg)
|
| 42 |
+
cats.append(meta.iloc[mask[0]][group_col])
|
| 43 |
+
|
| 44 |
+
work_codes = np.stack(rows)
|
| 45 |
+
work_categories = np.array(cats)
|
| 46 |
+
elif level == 'patch':
|
| 47 |
+
work_codes = codes
|
| 48 |
+
work_categories = meta[group_col].values
|
| 49 |
+
else:
|
| 50 |
+
raise ValueError(f"level must be 'image' or 'patch', got {level!r}")
|
| 51 |
+
|
| 52 |
+
unique_categories = np.unique(work_categories)
|
| 53 |
+
cat_to_idx = {c: i for i, c in enumerate(unique_categories)}
|
| 54 |
+
cat_idx = np.array([cat_to_idx[c] for c in work_categories], dtype=np.int32)
|
| 55 |
+
|
| 56 |
+
return work_codes, work_categories, feature_names, unique_categories, cat_idx
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def compute_distortion_correlations(
|
| 60 |
+
meta: pd.DataFrame,
|
| 61 |
+
features: FeatureMatrix,
|
| 62 |
+
group_col: str = 'dist_group',
|
| 63 |
+
*,
|
| 64 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 65 |
+
binarize: bool = False,
|
| 66 |
+
level: str = 'image',
|
| 67 |
+
cache_path: Optional[str] = None,
|
| 68 |
+
) -> pd.DataFrame:
|
| 69 |
+
"""Correlations between activations and distortion categories.
|
| 70 |
+
|
| 71 |
+
``features.column_feature_ids[j]`` is the global SAE id for matrix column ``j``.
|
| 72 |
+
``global_feature_ids`` restricts which globals to include (default: all columns).
|
| 73 |
+
"""
|
| 74 |
+
cached = load_parquet_cache(cache_path, label='correlations')
|
| 75 |
+
if cached is not None:
|
| 76 |
+
return cached
|
| 77 |
+
|
| 78 |
+
work_features = features.subset(global_feature_ids)
|
| 79 |
+
|
| 80 |
+
work_codes, _, feature_names, unique_categories, cat_idx = _prepare_codes_and_categories(
|
| 81 |
+
meta=meta,
|
| 82 |
+
features=work_features,
|
| 83 |
+
group_col=group_col,
|
| 84 |
+
level=level,
|
| 85 |
+
binarize=binarize,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
n_cats = len(unique_categories)
|
| 89 |
+
|
| 90 |
+
n_samples, _ = work_codes.shape
|
| 91 |
+
|
| 92 |
+
one_hot = sp.csr_matrix(
|
| 93 |
+
(np.ones(n_samples, dtype=np.float32), (cat_idx, np.arange(n_samples))),
|
| 94 |
+
shape=(n_cats, n_samples)
|
| 95 |
+
)
|
| 96 |
+
is_sparse = sp.issparse(work_codes)
|
| 97 |
+
|
| 98 |
+
feat_mean = np.asarray(work_codes.mean(axis=0)).ravel()
|
| 99 |
+
if is_sparse:
|
| 100 |
+
feat_sq_mean = np.asarray(work_codes.power(2).mean(axis=0)).ravel()
|
| 101 |
+
else:
|
| 102 |
+
feat_sq_mean = (work_codes ** 2).mean(axis=0)
|
| 103 |
+
feat_std = np.sqrt(feat_sq_mean - feat_mean ** 2).clip(min=1e-8)
|
| 104 |
+
|
| 105 |
+
product = one_hot @ work_codes
|
| 106 |
+
grouped_sum = product if is_sparse else np.asarray(product).astype(np.float32)
|
| 107 |
+
|
| 108 |
+
cat_counts = np.bincount(cat_idx, minlength=n_cats).astype(np.float32)
|
| 109 |
+
cat_mean = cat_counts / n_samples
|
| 110 |
+
cat_std = np.sqrt(cat_mean * (1 - cat_mean)).clip(min=1e-8)
|
| 111 |
+
|
| 112 |
+
cov = grouped_sum / n_samples - np.outer(cat_mean, feat_mean)
|
| 113 |
+
corr_mat = cov / np.outer(cat_std, feat_std)
|
| 114 |
+
|
| 115 |
+
result = pd.DataFrame(corr_mat.astype(np.float32),
|
| 116 |
+
index=unique_categories,
|
| 117 |
+
columns=feature_names)
|
| 118 |
+
|
| 119 |
+
save_parquet_cache(result, cache_path, label='correlations')
|
| 120 |
+
|
| 121 |
+
return result
|
analysis/metrics/iou_utils.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IoU (Intersection over Union) computation for feature selection based on spatial distortion masks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Dict, List, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import scipy.sparse as sp
|
| 10 |
+
import torch
|
| 11 |
+
from tqdm.auto import tqdm
|
| 12 |
+
|
| 13 |
+
from analysis.datasets import distortion_types_mapping_qground, distortion_types_mapping_srground
|
| 14 |
+
from analysis.cache_utils import load_parquet_cache, save_parquet_cache
|
| 15 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
_QGROUND_TYPE_TO_LABEL = {
|
| 19 |
+
dist_type: label_id
|
| 20 |
+
for label_id, dist_type in distortion_types_mapping_qground.items()
|
| 21 |
+
if label_id > 0
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
_SRGROUND_TYPE_TO_LABEL = {
|
| 25 |
+
dist_type: label_id
|
| 26 |
+
for label_id, dist_type in distortion_types_mapping_srground.items()
|
| 27 |
+
if label_id > 0
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
_SPATIAL_MASK_DATASETS = frozenset({'QGround', 'SRGround'})
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _binarize_activations(
|
| 34 |
+
feature_activations: np.ndarray,
|
| 35 |
+
) -> np.ndarray:
|
| 36 |
+
"""
|
| 37 |
+
Binarize feature activations.
|
| 38 |
+
Uses threshold > 0 since ReLU already ensures non-negative values.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
feature_activations: Shape (n_patches,) or (n_samples, n_patches)
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
Binary array with same shape as input
|
| 45 |
+
"""
|
| 46 |
+
return (feature_activations > 0).astype(np.uint8)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _mask_label_for_dist_type(dist_type: Optional[str]) -> Optional[int]:
|
| 50 |
+
if dist_type is None:
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
dist_type = str(dist_type)
|
| 54 |
+
if dist_type == 'background':
|
| 55 |
+
return 0
|
| 56 |
+
|
| 57 |
+
if dist_type in _QGROUND_TYPE_TO_LABEL:
|
| 58 |
+
return _QGROUND_TYPE_TO_LABEL[dist_type]
|
| 59 |
+
|
| 60 |
+
if dist_type in _SRGROUND_TYPE_TO_LABEL:
|
| 61 |
+
return _SRGROUND_TYPE_TO_LABEL[dist_type]
|
| 62 |
+
|
| 63 |
+
# KADID / local KADID are binary masks: any non-background distortion is label 1.
|
| 64 |
+
return 1
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _compute_patch_iou(
|
| 69 |
+
activation_binary: np.ndarray,
|
| 70 |
+
mask_binary: np.ndarray,
|
| 71 |
+
) -> float:
|
| 72 |
+
"""
|
| 73 |
+
Compute IoU (Jaccard index) between binary activation and binary mask.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
activation_binary: Shape (n_patches,) - binary feature activation map
|
| 77 |
+
mask_binary: Shape (n_patches,) - binary distortion mask
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
IoU score in [0, 1]
|
| 81 |
+
"""
|
| 82 |
+
intersection = np.logical_and(activation_binary, mask_binary).sum()
|
| 83 |
+
union = np.logical_and(activation_binary, mask_binary).sum() + np.logical_xor(activation_binary, mask_binary).sum()
|
| 84 |
+
|
| 85 |
+
if union == 0:
|
| 86 |
+
return 0.0
|
| 87 |
+
|
| 88 |
+
return float(intersection / union)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _compute_iou_batch_torch(
|
| 92 |
+
activation_binary: torch.Tensor,
|
| 93 |
+
mask_binary: torch.Tensor,
|
| 94 |
+
) -> torch.Tensor:
|
| 95 |
+
"""Compute IoU for a batch of features against one binary mask.
|
| 96 |
+
|
| 97 |
+
activation_binary: bool tensor with shape (n_features, n_patches)
|
| 98 |
+
mask_binary: bool tensor with shape (n_patches,)
|
| 99 |
+
"""
|
| 100 |
+
if activation_binary.ndim != 2:
|
| 101 |
+
raise ValueError(f'activation_binary must be 2D, got {activation_binary.shape}')
|
| 102 |
+
if mask_binary.ndim != 1:
|
| 103 |
+
raise ValueError(f'mask_binary must be 1D, got {mask_binary.shape}')
|
| 104 |
+
|
| 105 |
+
mask_row = mask_binary.unsqueeze(0)
|
| 106 |
+
intersection = torch.logical_and(activation_binary, mask_row).sum(dim=1)
|
| 107 |
+
union = torch.logical_or(activation_binary, mask_row).sum(dim=1)
|
| 108 |
+
iou = torch.zeros_like(union, dtype=torch.float32)
|
| 109 |
+
nonzero = union > 0
|
| 110 |
+
iou[nonzero] = intersection[nonzero].float() / union[nonzero].float()
|
| 111 |
+
return iou
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _load_patch_mask_for_group(
|
| 115 |
+
group_df: pd.DataFrame,
|
| 116 |
+
target_dist_type: Optional[str] = None,
|
| 117 |
+
*,
|
| 118 |
+
dataset: str,
|
| 119 |
+
) -> np.ndarray:
|
| 120 |
+
"""Return a flattened binary patch mask for one image group.
|
| 121 |
+
|
| 122 |
+
Requires a cached patch-level label map.
|
| 123 |
+
"""
|
| 124 |
+
if 'patch_mask_label' in group_df.columns:
|
| 125 |
+
patch_labels = group_df['patch_mask_label'].values.astype(np.uint8)
|
| 126 |
+
elif 'patch_is_distorted' in group_df.columns:
|
| 127 |
+
patch_labels = group_df['patch_is_distorted'].values.astype(np.uint8)
|
| 128 |
+
else:
|
| 129 |
+
raise ValueError(
|
| 130 |
+
"IoU requires either 'patch_mask_label' or 'patch_is_distorted' in metadata. "
|
| 131 |
+
"Rebuild the cache with patch-level labels or distorted-patch flags."
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
dataset_name = str(dataset).strip()
|
| 135 |
+
if dataset_name not in _SPATIAL_MASK_DATASETS:
|
| 136 |
+
return (patch_labels > 0).astype(np.uint8)
|
| 137 |
+
|
| 138 |
+
target_label = _mask_label_for_dist_type(target_dist_type)
|
| 139 |
+
if target_label is None:
|
| 140 |
+
return (patch_labels > 0).astype(np.uint8)
|
| 141 |
+
if target_label == 0:
|
| 142 |
+
return np.zeros_like(patch_labels, dtype=np.uint8)
|
| 143 |
+
return (patch_labels == target_label).astype(np.uint8)
|
| 144 |
+
|
| 145 |
+
CREATE_IOU_CACHE_IF_MISSING: bool = True
|
| 146 |
+
|
| 147 |
+
def compute_iou_per_feature(
|
| 148 |
+
features: FeatureMatrix,
|
| 149 |
+
meta_df: pd.DataFrame,
|
| 150 |
+
spatial_shape: Tuple[int, int],
|
| 151 |
+
group_col: str = 'dist_type',
|
| 152 |
+
*,
|
| 153 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 154 |
+
feature_batch_size: int = 1024,
|
| 155 |
+
show_progress_bars: bool = True,
|
| 156 |
+
cache_path: Optional[str] = None,
|
| 157 |
+
dataset: str,
|
| 158 |
+
create_cache_if_missing: Optional[bool] = None,
|
| 159 |
+
) -> pd.DataFrame:
|
| 160 |
+
"""
|
| 161 |
+
Compute per-feature IoU scores grouped by distortion type or group.
|
| 162 |
+
|
| 163 |
+
This function:
|
| 164 |
+
1. Iterates through each feature
|
| 165 |
+
2. For each image with mask data, computes IoU between feature activation and mask
|
| 166 |
+
3. Groups IoU scores by distortion type/group
|
| 167 |
+
4. Computes median IoU per feature per distortion type/group
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
features: Activations with global SAE id per column
|
| 171 |
+
meta_df: DataFrame with metadata including 'dist_type', 'dist_group', 'image_idx', 'patch_is_distorted'
|
| 172 |
+
spatial_shape: Tuple (patch_h, patch_w) for activation grid size
|
| 173 |
+
group_col: Column to group by ('dist_type' or 'dist_group')
|
| 174 |
+
global_feature_ids: optional subset of globals to compute (default: all columns)
|
| 175 |
+
feature_batch_size: Number of features to evaluate together on torch
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
DataFrame with shape (n_groups, n_features) where rows are distortion types/groups
|
| 179 |
+
and columns are feature IDs, values are median IoU
|
| 180 |
+
"""
|
| 181 |
+
cached = load_parquet_cache(cache_path, label='iou')
|
| 182 |
+
if cached is not None:
|
| 183 |
+
return cached
|
| 184 |
+
|
| 185 |
+
# Decide whether to create cache when missing. Priority:
|
| 186 |
+
# 1) explicit function arg, 2) module-level global flag, 3) default True
|
| 187 |
+
if create_cache_if_missing is None:
|
| 188 |
+
create_cache = bool(CREATE_IOU_CACHE_IF_MISSING)
|
| 189 |
+
else:
|
| 190 |
+
create_cache = bool(create_cache_if_missing)
|
| 191 |
+
|
| 192 |
+
if not create_cache:
|
| 193 |
+
# If we shouldn't build missing caches, return empty DataFrame so
|
| 194 |
+
# callers can detect absence of IoU data without triggering heavy compute.
|
| 195 |
+
return pd.DataFrame()
|
| 196 |
+
|
| 197 |
+
work = features.subset(global_feature_ids)
|
| 198 |
+
feature_codes = work.codes
|
| 199 |
+
global_labels = list(work.column_feature_ids)
|
| 200 |
+
column_indices = list(range(work.n_features))
|
| 201 |
+
|
| 202 |
+
n_samples, _n_features = feature_codes.shape
|
| 203 |
+
_patch_h, _patch_w = spatial_shape
|
| 204 |
+
n_patches = _patch_h * _patch_w
|
| 205 |
+
|
| 206 |
+
if 'patch_mask_label' not in meta_df.columns and 'patch_is_distorted' not in meta_df.columns:
|
| 207 |
+
raise ValueError(
|
| 208 |
+
"Meta DataFrame must contain either 'patch_mask_label' or 'patch_is_distorted' column for IoU computation. "
|
| 209 |
+
"Rebuild the cache with patch-level labels or distorted-patch flags."
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
# Group metadata by image for efficient processing
|
| 213 |
+
image_groups = meta_df.groupby('image_idx')
|
| 214 |
+
|
| 215 |
+
# Track IoU scores: {group_value: {feature_id: [iou_scores]}}
|
| 216 |
+
iou_scores_by_group: Dict[str, Dict[int, List[float]]] = {}
|
| 217 |
+
|
| 218 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 219 |
+
feature_batch_size = max(1, int(feature_batch_size))
|
| 220 |
+
|
| 221 |
+
image_payloads = []
|
| 222 |
+
for image_idx, group_df in image_groups:
|
| 223 |
+
sample_indices = group_df.index.to_numpy()
|
| 224 |
+
if len(group_df) != n_patches:
|
| 225 |
+
raise ValueError(
|
| 226 |
+
f"Patch row count mismatch for image_idx={image_idx}: got {len(group_df)}, expected {n_patches}. "
|
| 227 |
+
"Rebuild the cache."
|
| 228 |
+
)
|
| 229 |
+
image_payloads.append((group_df, sample_indices))
|
| 230 |
+
|
| 231 |
+
dataset_name = str(dataset).strip()
|
| 232 |
+
spatial_mask_dataset = dataset_name in _SPATIAL_MASK_DATASETS
|
| 233 |
+
|
| 234 |
+
for feature_start in tqdm(
|
| 235 |
+
range(0, len(column_indices), feature_batch_size),
|
| 236 |
+
desc='Computing IoU batches',
|
| 237 |
+
unit='batch',
|
| 238 |
+
disable=not show_progress_bars,
|
| 239 |
+
):
|
| 240 |
+
batch_col_indices = column_indices[feature_start:feature_start + feature_batch_size]
|
| 241 |
+
batch_global_labels = global_labels[feature_start:feature_start + feature_batch_size]
|
| 242 |
+
for group_df, sample_indices in image_payloads:
|
| 243 |
+
img_feature_block = feature_codes[sample_indices][:, batch_col_indices]
|
| 244 |
+
if sp.issparse(img_feature_block):
|
| 245 |
+
img_feature_block = img_feature_block.toarray()
|
| 246 |
+
else:
|
| 247 |
+
img_feature_block = np.asarray(img_feature_block)
|
| 248 |
+
|
| 249 |
+
act_binary = torch.as_tensor(
|
| 250 |
+
img_feature_block.T > 0,
|
| 251 |
+
device=device,
|
| 252 |
+
dtype=torch.bool,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
group_values = group_df[group_col].unique()
|
| 256 |
+
for group_val in group_values:
|
| 257 |
+
group_key = str(group_val)
|
| 258 |
+
if spatial_mask_dataset:
|
| 259 |
+
target_dist_type = group_key
|
| 260 |
+
elif 'dist_type' in group_df.columns and len(group_df) > 0:
|
| 261 |
+
target_dist_type = str(group_df['dist_type'].iloc[0])
|
| 262 |
+
else:
|
| 263 |
+
target_dist_type = None
|
| 264 |
+
|
| 265 |
+
patch_masks = _load_patch_mask_for_group(
|
| 266 |
+
group_df,
|
| 267 |
+
target_dist_type=target_dist_type,
|
| 268 |
+
dataset=dataset,
|
| 269 |
+
)
|
| 270 |
+
mask_binary = torch.as_tensor(patch_masks > 0, device=device, dtype=torch.bool)
|
| 271 |
+
iou_batch = _compute_iou_batch_torch(act_binary, mask_binary).detach().cpu().numpy()
|
| 272 |
+
|
| 273 |
+
if group_key not in iou_scores_by_group:
|
| 274 |
+
iou_scores_by_group[group_key] = {}
|
| 275 |
+
|
| 276 |
+
group_bucket = iou_scores_by_group[group_key]
|
| 277 |
+
for local_idx, global_id in enumerate(batch_global_labels):
|
| 278 |
+
if global_id not in group_bucket:
|
| 279 |
+
group_bucket[global_id] = []
|
| 280 |
+
group_bucket[global_id].append(float(iou_batch[local_idx]))
|
| 281 |
+
|
| 282 |
+
# Aggregate scores: compute median per feature per group
|
| 283 |
+
result_data = {}
|
| 284 |
+
for group_key in sorted(iou_scores_by_group.keys()):
|
| 285 |
+
row = {}
|
| 286 |
+
for global_id in global_labels:
|
| 287 |
+
scores = iou_scores_by_group[group_key].get(global_id, [])
|
| 288 |
+
if scores:
|
| 289 |
+
row[global_id] = float(np.median(scores))
|
| 290 |
+
else:
|
| 291 |
+
row[global_id] = np.nan
|
| 292 |
+
result_data[group_key] = row
|
| 293 |
+
|
| 294 |
+
if not result_data:
|
| 295 |
+
return pd.DataFrame()
|
| 296 |
+
|
| 297 |
+
# Create result DataFrame
|
| 298 |
+
iou_df = pd.DataFrame(result_data).T
|
| 299 |
+
iou_df.index.name = group_col
|
| 300 |
+
save_parquet_cache(iou_df, cache_path, label='iou')
|
| 301 |
+
return iou_df
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def compute_iou_per_distortion_type_and_feature(
|
| 305 |
+
features: FeatureMatrix,
|
| 306 |
+
meta_df: pd.DataFrame,
|
| 307 |
+
spatial_shape: Tuple[int, int],
|
| 308 |
+
group_col: str = 'dist_type',
|
| 309 |
+
*,
|
| 310 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 311 |
+
feature_batch_size: int = 1024,
|
| 312 |
+
show_progress_bars: bool = True,
|
| 313 |
+
cache_path: Optional[str] = None,
|
| 314 |
+
dataset: str,
|
| 315 |
+
create_cache_if_missing: Optional[bool] = None,
|
| 316 |
+
) -> pd.DataFrame:
|
| 317 |
+
"""
|
| 318 |
+
Compute per-feature median IoU scores, organized by distortion type or group.
|
| 319 |
+
|
| 320 |
+
Args:
|
| 321 |
+
feature_codes: Sparse matrix of shape (n_samples, n_features)
|
| 322 |
+
meta_df: Metadata DataFrame with 'dist_type', 'dist_group', 'image_idx', 'patch_is_distorted' columns
|
| 323 |
+
spatial_shape: (patch_h, patch_w) grid dimensions
|
| 324 |
+
group_col: Column name for grouping ('dist_type' or 'dist_group')
|
| 325 |
+
global_feature_ids: optional subset of globals to compute
|
| 326 |
+
|
| 327 |
+
Returns:
|
| 328 |
+
DataFrame with shape (n_distortion_types, n_features) where values are median IoU
|
| 329 |
+
"""
|
| 330 |
+
return compute_iou_per_feature(
|
| 331 |
+
features=features,
|
| 332 |
+
meta_df=meta_df,
|
| 333 |
+
spatial_shape=spatial_shape,
|
| 334 |
+
group_col=group_col,
|
| 335 |
+
global_feature_ids=global_feature_ids,
|
| 336 |
+
feature_batch_size=feature_batch_size,
|
| 337 |
+
show_progress_bars=show_progress_bars,
|
| 338 |
+
cache_path=cache_path,
|
| 339 |
+
dataset=dataset,
|
| 340 |
+
create_cache_if_missing=create_cache_if_missing,
|
| 341 |
+
)
|
analysis/metrics/mutual_information.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mutual information computations for SAE feature analysis."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import scipy.sparse as sp
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
|
| 10 |
+
from analysis.cache_utils import load_parquet_cache, save_parquet_cache
|
| 11 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 12 |
+
from analysis.metrics.correlations import _prepare_codes_and_categories
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _build_contingency_bincount(
|
| 16 |
+
x_bin: np.ndarray,
|
| 17 |
+
cat_idx: np.ndarray,
|
| 18 |
+
n_cats: int,
|
| 19 |
+
n_bins: int,
|
| 20 |
+
) -> np.ndarray:
|
| 21 |
+
"""Build contingency[c, b, f] counts using a fast bincount kernel."""
|
| 22 |
+
n_samples, n_features = x_bin.shape
|
| 23 |
+
if cat_idx.shape[0] != n_samples:
|
| 24 |
+
raise ValueError('cat_idx length must match number of samples in x_bin')
|
| 25 |
+
|
| 26 |
+
contingency = np.empty((n_cats, n_bins, n_features), dtype=np.float64)
|
| 27 |
+
packed_base = cat_idx.astype(np.int32, copy=False) * n_bins
|
| 28 |
+
|
| 29 |
+
for fid in range(n_features):
|
| 30 |
+
packed = packed_base + x_bin[:, fid]
|
| 31 |
+
counts = np.bincount(packed, minlength=n_cats * n_bins)
|
| 32 |
+
contingency[:, :, fid] = counts.reshape(n_cats, n_bins)
|
| 33 |
+
|
| 34 |
+
return contingency
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def compute_distortion_mutual_information(
|
| 38 |
+
meta: pd.DataFrame,
|
| 39 |
+
features: FeatureMatrix,
|
| 40 |
+
group_col: str = 'dist_group',
|
| 41 |
+
*,
|
| 42 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 43 |
+
binarize: bool = False,
|
| 44 |
+
level: str = 'image',
|
| 45 |
+
n_bins: int = 16,
|
| 46 |
+
feature_chunk_size: int = 512,
|
| 47 |
+
show_progress_bars: bool = True,
|
| 48 |
+
cache_path: Optional[str] = None,
|
| 49 |
+
) -> pd.DataFrame:
|
| 50 |
+
"""
|
| 51 |
+
Compute mutual information between SAE feature activations and
|
| 52 |
+
a binary indicator for each distortion category.
|
| 53 |
+
|
| 54 |
+
Returns a DataFrame of shape (n_categories x n_features), values >= 0.
|
| 55 |
+
|
| 56 |
+
The computation is chunked over features to keep memory bounded.
|
| 57 |
+
"""
|
| 58 |
+
cached = load_parquet_cache(cache_path, label='mutual information')
|
| 59 |
+
if cached is not None:
|
| 60 |
+
return cached
|
| 61 |
+
|
| 62 |
+
work_features = features.subset(global_feature_ids)
|
| 63 |
+
|
| 64 |
+
work_codes, _, feature_names, unique_categories, cat_idx = _prepare_codes_and_categories(
|
| 65 |
+
meta=meta,
|
| 66 |
+
features=work_features,
|
| 67 |
+
group_col=group_col,
|
| 68 |
+
level=level,
|
| 69 |
+
binarize=binarize,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
_, n_features = work_codes.shape
|
| 73 |
+
n_cats = len(unique_categories)
|
| 74 |
+
|
| 75 |
+
if feature_chunk_size <= 0:
|
| 76 |
+
raise ValueError(f'feature_chunk_size must be > 0, got {feature_chunk_size}')
|
| 77 |
+
|
| 78 |
+
quantiles = np.linspace(0, 1, n_bins + 1)
|
| 79 |
+
mi_mat = np.empty((n_cats, n_features), dtype=np.float32)
|
| 80 |
+
|
| 81 |
+
for start in tqdm(range(0, n_features, feature_chunk_size), desc='Computing MI', unit='chunk', disable=not show_progress_bars):
|
| 82 |
+
end = min(start + feature_chunk_size, n_features)
|
| 83 |
+
if sp.issparse(work_codes):
|
| 84 |
+
x_values = work_codes[:, start:end].toarray().astype(np.float32, copy=False)
|
| 85 |
+
else:
|
| 86 |
+
x_values = np.asarray(work_codes[:, start:end], dtype=np.float32)
|
| 87 |
+
|
| 88 |
+
edges = np.quantile(x_values, quantiles, axis=0)
|
| 89 |
+
x_bin = np.zeros_like(x_values, dtype=np.int16)
|
| 90 |
+
for b in range(n_bins - 1):
|
| 91 |
+
x_bin += (x_values > edges[b + 1]).astype(np.int16)
|
| 92 |
+
|
| 93 |
+
contingency = _build_contingency_bincount(
|
| 94 |
+
x_bin=x_bin,
|
| 95 |
+
cat_idx=cat_idx,
|
| 96 |
+
n_cats=n_cats,
|
| 97 |
+
n_bins=n_bins,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
total = contingency.sum(axis=1, keepdims=True)
|
| 101 |
+
pxy = contingency / total.clip(min=1e-12)
|
| 102 |
+
px = pxy.sum(axis=0, keepdims=True)
|
| 103 |
+
py = pxy.sum(axis=1, keepdims=True)
|
| 104 |
+
|
| 105 |
+
with np.errstate(divide='ignore', invalid='ignore'):
|
| 106 |
+
log_ratio = np.where(pxy > 0, np.log(pxy / (py * px).clip(min=1e-12)), 0.0)
|
| 107 |
+
|
| 108 |
+
mi_chunk = (pxy * log_ratio).sum(axis=1)
|
| 109 |
+
mi_mat[:, start:end] = mi_chunk.astype(np.float32)
|
| 110 |
+
|
| 111 |
+
result = pd.DataFrame(mi_mat, index=unique_categories, columns=feature_names)
|
| 112 |
+
|
| 113 |
+
save_parquet_cache(result, cache_path, label='mutual information')
|
| 114 |
+
|
| 115 |
+
return result
|
analysis/metrics/paired_deltas.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for paired original-vs-distorted SAE activation comparisons."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, List, Mapping, Optional, Sequence
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import scipy.sparse as sp
|
| 12 |
+
|
| 13 |
+
from analysis.cache_utils import load_parquet_cache, save_parquet_cache
|
| 14 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def infer_original_image_path(distorted_img_path: str | Path) -> str | None:
|
| 18 |
+
"""Infer the KADID-style original image path from a distorted image path."""
|
| 19 |
+
path = Path(distorted_img_path)
|
| 20 |
+
match = re.match(r'(I\d+)_\d+_\d+\.png$', path.name, re.IGNORECASE)
|
| 21 |
+
if match:
|
| 22 |
+
return str(path.with_name(f'{match.group(1)}.png'))
|
| 23 |
+
|
| 24 |
+
local_match = re.match(r'(I\d+)_\d+_dist\.png$', path.name, re.IGNORECASE)
|
| 25 |
+
if local_match:
|
| 26 |
+
return str(path.with_name(f'{local_match.group(1)}.png'))
|
| 27 |
+
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _extract_pair_path(dist_row: pd.Series, distorted_path_col: str) -> str | None:
|
| 32 |
+
if 'original_img_path' in dist_row and pd.notna(dist_row['original_img_path']):
|
| 33 |
+
return str(dist_row['original_img_path'])
|
| 34 |
+
if distorted_path_col in dist_row and pd.notna(dist_row[distorted_path_col]):
|
| 35 |
+
return infer_original_image_path(str(dist_row[distorted_path_col]))
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _delta_vector(
|
| 40 |
+
distorted_vec: np.ndarray,
|
| 41 |
+
original_vec: np.ndarray,
|
| 42 |
+
delta_mode: str,
|
| 43 |
+
) -> np.ndarray:
|
| 44 |
+
if delta_mode == 'abs':
|
| 45 |
+
return np.abs(distorted_vec - original_vec)
|
| 46 |
+
if delta_mode == 'signed':
|
| 47 |
+
return distorted_vec - original_vec
|
| 48 |
+
if delta_mode == 'relative':
|
| 49 |
+
denom = np.abs(original_vec) + 1e-8
|
| 50 |
+
return (distorted_vec - original_vec) / denom
|
| 51 |
+
raise ValueError("delta_mode must be one of {'abs', 'signed', 'relative'}")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _aggregate_by_category(
|
| 55 |
+
category_to_rows: Mapping[str, List[np.ndarray]],
|
| 56 |
+
feature_names: Sequence[object],
|
| 57 |
+
) -> pd.DataFrame:
|
| 58 |
+
rows = []
|
| 59 |
+
index = []
|
| 60 |
+
for category, category_rows in category_to_rows.items():
|
| 61 |
+
if not category_rows:
|
| 62 |
+
continue
|
| 63 |
+
stacked = np.stack(category_rows, axis=0).astype(np.float32, copy=False)
|
| 64 |
+
rows.append(stacked.mean(axis=0).astype(np.float32, copy=False))
|
| 65 |
+
index.append(category)
|
| 66 |
+
|
| 67 |
+
if not rows:
|
| 68 |
+
return pd.DataFrame(columns=feature_names)
|
| 69 |
+
|
| 70 |
+
return pd.DataFrame(np.stack(rows, axis=0), index=index, columns=feature_names)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def build_paired_delta_tables(
|
| 74 |
+
distorted_meta: pd.DataFrame,
|
| 75 |
+
distorted: FeatureMatrix,
|
| 76 |
+
original_meta: pd.DataFrame,
|
| 77 |
+
original: FeatureMatrix,
|
| 78 |
+
*,
|
| 79 |
+
group_cols: Sequence[str] = ('dist_type', 'dist_group'),
|
| 80 |
+
global_feature_ids: Sequence[int] | None = None,
|
| 81 |
+
delta_mode: str = 'relative',
|
| 82 |
+
level: str = 'patch',
|
| 83 |
+
distorted_path_col: str = 'distorted_img_path',
|
| 84 |
+
original_path_col: str = 'original_img_path',
|
| 85 |
+
cache_prefix: Optional[str] = None,
|
| 86 |
+
) -> Dict[str, pd.DataFrame]:
|
| 87 |
+
"""Build category-by-feature paired delta tables from matched original/distorted samples.
|
| 88 |
+
|
| 89 |
+
Samples are matched by original image path. If a row does not expose the original path
|
| 90 |
+
explicitly, it is inferred from the distorted filename.
|
| 91 |
+
"""
|
| 92 |
+
if level not in {'image', 'patch'}:
|
| 93 |
+
raise ValueError(f"level must be 'image' or 'patch', got {level!r}")
|
| 94 |
+
|
| 95 |
+
if distorted.column_feature_ids != original.column_feature_ids:
|
| 96 |
+
raise ValueError('distorted and original FeatureMatrix must share column_feature_ids')
|
| 97 |
+
work_distorted = distorted.subset(global_feature_ids)
|
| 98 |
+
work_original = original.subset(global_feature_ids)
|
| 99 |
+
distorted_codes = work_distorted.codes
|
| 100 |
+
original_codes = work_original.codes
|
| 101 |
+
feature_names = [int(fid) for fid in work_distorted.column_feature_ids]
|
| 102 |
+
|
| 103 |
+
if distorted_meta.shape[0] != distorted_codes.shape[0]:
|
| 104 |
+
raise ValueError('distorted_meta and distorted codes must have the same number of rows')
|
| 105 |
+
if original_meta.shape[0] != original_codes.shape[0]:
|
| 106 |
+
raise ValueError('original_meta and original codes must have the same number of rows')
|
| 107 |
+
|
| 108 |
+
if distorted_path_col not in distorted_meta.columns:
|
| 109 |
+
raise ValueError(f'distorted_meta must contain {distorted_path_col!r}')
|
| 110 |
+
|
| 111 |
+
if original_path_col not in original_meta.columns:
|
| 112 |
+
fallback_cols = [col for col in ('distorted_img_path', 'img_path', 'image_path')
|
| 113 |
+
if col in original_meta.columns]
|
| 114 |
+
if not fallback_cols:
|
| 115 |
+
raise ValueError(
|
| 116 |
+
f'original_meta must contain {original_path_col!r} or a fallback path column; '
|
| 117 |
+
f'available columns: {sorted(original_meta.columns.tolist())}'
|
| 118 |
+
)
|
| 119 |
+
original_path_col = fallback_cols[0]
|
| 120 |
+
|
| 121 |
+
original_groups = original_meta.groupby(original_path_col).indices
|
| 122 |
+
distorted_groups = distorted_meta.groupby(distorted_path_col).indices
|
| 123 |
+
|
| 124 |
+
tables: Dict[str, pd.DataFrame] = {}
|
| 125 |
+
for group_col in group_cols:
|
| 126 |
+
table_key = f'paired_{delta_mode}_{group_col}_df'
|
| 127 |
+
table_cache_path = None
|
| 128 |
+
if cache_prefix is not None:
|
| 129 |
+
table_cache_path = f'{cache_prefix}_{delta_mode}_{group_col}_{level}.parquet'
|
| 130 |
+
cached = load_parquet_cache(table_cache_path, label=table_key)
|
| 131 |
+
if cached is not None:
|
| 132 |
+
tables[table_key] = cached
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
if group_col not in distorted_meta.columns:
|
| 136 |
+
raise ValueError(f'distorted_meta must contain {group_col!r}')
|
| 137 |
+
|
| 138 |
+
category_to_rows: Dict[str, List[np.ndarray]] = {}
|
| 139 |
+
for distorted_path, distorted_indices in distorted_groups.items():
|
| 140 |
+
first_dist_idx = int(distorted_indices[0])
|
| 141 |
+
original_path = _extract_pair_path(distorted_meta.iloc[first_dist_idx], distorted_path_col)
|
| 142 |
+
if original_path is None:
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
original_indices = original_groups.get(original_path)
|
| 146 |
+
if original_indices is None or len(original_indices) == 0:
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
distorted_rows = np.asarray(distorted_indices, dtype=np.int64)
|
| 150 |
+
original_rows = np.asarray(original_indices, dtype=np.int64)
|
| 151 |
+
|
| 152 |
+
if level == 'patch' and 'patch_idx' in distorted_meta.columns and 'patch_idx' in original_meta.columns:
|
| 153 |
+
dist_patch_to_idx = {
|
| 154 |
+
int(distorted_meta.iloc[row_idx]['patch_idx']): int(row_idx)
|
| 155 |
+
for row_idx in distorted_rows
|
| 156 |
+
}
|
| 157 |
+
orig_patch_to_idx = {
|
| 158 |
+
int(original_meta.iloc[row_idx]['patch_idx']): int(row_idx)
|
| 159 |
+
for row_idx in original_rows
|
| 160 |
+
}
|
| 161 |
+
common_patches = sorted(set(dist_patch_to_idx).intersection(orig_patch_to_idx))
|
| 162 |
+
if not common_patches:
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
distorted_rows = np.asarray([dist_patch_to_idx[patch_idx]
|
| 166 |
+
for patch_idx in common_patches], dtype=np.int64)
|
| 167 |
+
original_rows = np.asarray([orig_patch_to_idx[patch_idx]
|
| 168 |
+
for patch_idx in common_patches], dtype=np.int64)
|
| 169 |
+
else:
|
| 170 |
+
n_rows = min(len(distorted_rows), len(original_rows))
|
| 171 |
+
if n_rows == 0:
|
| 172 |
+
continue
|
| 173 |
+
distorted_rows = distorted_rows[:n_rows]
|
| 174 |
+
original_rows = original_rows[:n_rows]
|
| 175 |
+
|
| 176 |
+
distorted_block = distorted_codes[distorted_rows]
|
| 177 |
+
original_block = original_codes[original_rows]
|
| 178 |
+
if sp.issparse(distorted_block):
|
| 179 |
+
distorted_block = distorted_block.toarray()
|
| 180 |
+
if sp.issparse(original_block):
|
| 181 |
+
original_block = original_block.toarray()
|
| 182 |
+
|
| 183 |
+
if distorted_block.shape != original_block.shape:
|
| 184 |
+
n_rows = min(distorted_block.shape[0], original_block.shape[0])
|
| 185 |
+
n_cols = min(distorted_block.shape[1], original_block.shape[1])
|
| 186 |
+
distorted_block = distorted_block[:n_rows, :n_cols]
|
| 187 |
+
original_block = original_block[:n_rows, :n_cols]
|
| 188 |
+
|
| 189 |
+
if level == 'image':
|
| 190 |
+
distorted_vec = np.asarray(distorted_block.mean(axis=0)).ravel().astype(np.float32, copy=False)
|
| 191 |
+
original_vec = np.asarray(original_block.mean(axis=0)).ravel().astype(np.float32, copy=False)
|
| 192 |
+
delta_rows = [_delta_vector(distorted_vec, original_vec, delta_mode)]
|
| 193 |
+
else:
|
| 194 |
+
delta_rows = [
|
| 195 |
+
_delta_vector(
|
| 196 |
+
np.asarray(distorted_block[row_idx], dtype=np.float32),
|
| 197 |
+
np.asarray(original_block[row_idx], dtype=np.float32),
|
| 198 |
+
delta_mode,
|
| 199 |
+
)
|
| 200 |
+
for row_idx in range(distorted_block.shape[0])
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
category_value = str(distorted_meta.iloc[first_dist_idx][group_col])
|
| 204 |
+
category_to_rows.setdefault(category_value, []).extend(delta_rows)
|
| 205 |
+
|
| 206 |
+
table_df = _aggregate_by_category(category_to_rows, feature_names)
|
| 207 |
+
save_parquet_cache(table_df, table_cache_path, label=table_key)
|
| 208 |
+
tables[table_key] = table_df
|
| 209 |
+
|
| 210 |
+
return tables
|
analysis/metrics/precision_recall.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Precision/Recall computations for SAE feature analysis."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import scipy.sparse as sp
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
|
| 10 |
+
from analysis.cache_utils import load_parquet_cache, save_parquet_cache
|
| 11 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 12 |
+
from analysis.metrics.correlations import _prepare_codes_and_categories
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _compute_binary_metric_chunk(
|
| 16 |
+
scores: np.ndarray,
|
| 17 |
+
positives_mask: np.ndarray,
|
| 18 |
+
metric_name: str,
|
| 19 |
+
activation_threshold: float,
|
| 20 |
+
) -> np.ndarray:
|
| 21 |
+
"""Compute precision/recall for a block of features."""
|
| 22 |
+
preds = scores > float(activation_threshold)
|
| 23 |
+
positives_col = positives_mask[:, None]
|
| 24 |
+
|
| 25 |
+
tp = np.logical_and(preds, positives_col).sum(axis=0).astype(np.float32)
|
| 26 |
+
pred_pos = preds.sum(axis=0).astype(np.float32)
|
| 27 |
+
true_pos_total = float(positives_mask.sum())
|
| 28 |
+
|
| 29 |
+
if metric_name == 'precision':
|
| 30 |
+
out = np.divide(tp, pred_pos, out=np.zeros_like(tp), where=pred_pos > 0)
|
| 31 |
+
elif metric_name == 'recall':
|
| 32 |
+
if true_pos_total <= 0:
|
| 33 |
+
out = np.zeros_like(tp)
|
| 34 |
+
else:
|
| 35 |
+
out = tp / true_pos_total
|
| 36 |
+
else:
|
| 37 |
+
raise ValueError(f"Unsupported metric_name={metric_name!r}. Allowed: ('precision', 'recall')")
|
| 38 |
+
|
| 39 |
+
return np.clip(out, 0.0, 1.0).astype(np.float32)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _compute_distortion_binary_metric(
|
| 43 |
+
metric_name: str,
|
| 44 |
+
meta: pd.DataFrame,
|
| 45 |
+
features: FeatureMatrix,
|
| 46 |
+
group_col: str = 'dist_type',
|
| 47 |
+
*,
|
| 48 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 49 |
+
level: str = 'image',
|
| 50 |
+
feature_chunk_size: int = 512,
|
| 51 |
+
activation_threshold: float = 0.0,
|
| 52 |
+
show_progress_bars: bool = True,
|
| 53 |
+
cache_path: Optional[str] = None,
|
| 54 |
+
) -> pd.DataFrame:
|
| 55 |
+
cache_label = metric_name
|
| 56 |
+
cached = load_parquet_cache(cache_path, label=cache_label)
|
| 57 |
+
if cached is not None:
|
| 58 |
+
return cached
|
| 59 |
+
|
| 60 |
+
work_features = features.subset(global_feature_ids)
|
| 61 |
+
work_codes, _, feature_names, unique_categories, cat_idx = _prepare_codes_and_categories(
|
| 62 |
+
meta=meta,
|
| 63 |
+
features=work_features,
|
| 64 |
+
group_col=group_col,
|
| 65 |
+
level=level,
|
| 66 |
+
binarize=False,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
_n_samples, n_features = work_codes.shape
|
| 70 |
+
n_cats = len(unique_categories)
|
| 71 |
+
|
| 72 |
+
if feature_chunk_size <= 0:
|
| 73 |
+
raise ValueError(f'feature_chunk_size must be > 0, got {feature_chunk_size}')
|
| 74 |
+
|
| 75 |
+
metric_mat = np.empty((n_cats, n_features), dtype=np.float32)
|
| 76 |
+
|
| 77 |
+
for start in tqdm(
|
| 78 |
+
range(0, n_features, feature_chunk_size),
|
| 79 |
+
desc=f'Computing {metric_name}',
|
| 80 |
+
unit='chunk',
|
| 81 |
+
disable=not show_progress_bars,
|
| 82 |
+
):
|
| 83 |
+
end = min(start + feature_chunk_size, n_features)
|
| 84 |
+
if sp.issparse(work_codes):
|
| 85 |
+
x_values = work_codes[:, start:end].toarray().astype(np.float32, copy=False)
|
| 86 |
+
else:
|
| 87 |
+
x_values = np.asarray(work_codes[:, start:end], dtype=np.float32)
|
| 88 |
+
|
| 89 |
+
for cat_i in range(n_cats):
|
| 90 |
+
positives_mask = cat_idx == cat_i
|
| 91 |
+
metric_mat[cat_i, start:end] = _compute_binary_metric_chunk(
|
| 92 |
+
scores=x_values,
|
| 93 |
+
positives_mask=positives_mask,
|
| 94 |
+
metric_name=metric_name,
|
| 95 |
+
activation_threshold=activation_threshold,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
result = pd.DataFrame(metric_mat, index=unique_categories, columns=feature_names)
|
| 99 |
+
save_parquet_cache(result, cache_path, label=cache_label)
|
| 100 |
+
return result
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def compute_distortion_precision(
|
| 104 |
+
meta: pd.DataFrame,
|
| 105 |
+
features: FeatureMatrix,
|
| 106 |
+
group_col: str = 'dist_type',
|
| 107 |
+
*,
|
| 108 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 109 |
+
level: str = 'image',
|
| 110 |
+
feature_chunk_size: int = 512,
|
| 111 |
+
activation_threshold: float = 0.0,
|
| 112 |
+
show_progress_bars: bool = True,
|
| 113 |
+
cache_path: Optional[str] = None,
|
| 114 |
+
) -> pd.DataFrame:
|
| 115 |
+
"""Compute one-vs-rest precision for distortion categories by feature."""
|
| 116 |
+
return _compute_distortion_binary_metric(
|
| 117 |
+
metric_name='precision',
|
| 118 |
+
meta=meta,
|
| 119 |
+
features=features,
|
| 120 |
+
group_col=group_col,
|
| 121 |
+
global_feature_ids=global_feature_ids,
|
| 122 |
+
level=level,
|
| 123 |
+
feature_chunk_size=feature_chunk_size,
|
| 124 |
+
activation_threshold=activation_threshold,
|
| 125 |
+
show_progress_bars=show_progress_bars,
|
| 126 |
+
cache_path=cache_path,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def compute_distortion_recall(
|
| 131 |
+
meta: pd.DataFrame,
|
| 132 |
+
features: FeatureMatrix,
|
| 133 |
+
group_col: str = 'dist_type',
|
| 134 |
+
*,
|
| 135 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 136 |
+
level: str = 'image',
|
| 137 |
+
feature_chunk_size: int = 512,
|
| 138 |
+
activation_threshold: float = 0.0,
|
| 139 |
+
show_progress_bars: bool = True,
|
| 140 |
+
cache_path: Optional[str] = None,
|
| 141 |
+
) -> pd.DataFrame:
|
| 142 |
+
"""Compute one-vs-rest recall for distortion categories by feature."""
|
| 143 |
+
return _compute_distortion_binary_metric(
|
| 144 |
+
metric_name='recall',
|
| 145 |
+
meta=meta,
|
| 146 |
+
features=features,
|
| 147 |
+
group_col=group_col,
|
| 148 |
+
global_feature_ids=global_feature_ids,
|
| 149 |
+
level=level,
|
| 150 |
+
feature_chunk_size=feature_chunk_size,
|
| 151 |
+
activation_threshold=activation_threshold,
|
| 152 |
+
show_progress_bars=show_progress_bars,
|
| 153 |
+
cache_path=cache_path,
|
| 154 |
+
)
|
analysis/metrics/roc_auc.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ROC-AUC computations for SAE feature analysis."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import scipy.sparse as sp
|
| 8 |
+
from scipy.stats import rankdata
|
| 9 |
+
from tqdm import tqdm
|
| 10 |
+
|
| 11 |
+
from analysis.cache_utils import load_parquet_cache, save_parquet_cache
|
| 12 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 13 |
+
from analysis.metrics.correlations import _prepare_codes_and_categories
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _auc_from_scores(scores: np.ndarray, positives_mask: np.ndarray) -> float:
|
| 17 |
+
"""Compute binary ROC-AUC via rank statistics with neutral fallback for degenerate labels."""
|
| 18 |
+
n_pos = int(positives_mask.sum())
|
| 19 |
+
n_total = int(scores.shape[0])
|
| 20 |
+
n_neg = n_total - n_pos
|
| 21 |
+
if n_pos == 0 or n_neg == 0:
|
| 22 |
+
return 0.5
|
| 23 |
+
|
| 24 |
+
ranks = rankdata(scores, method='average')
|
| 25 |
+
sum_pos_ranks = float(ranks[positives_mask].sum())
|
| 26 |
+
auc = (sum_pos_ranks - (n_pos * (n_pos + 1) / 2.0)) / (n_pos * n_neg)
|
| 27 |
+
if not np.isfinite(auc):
|
| 28 |
+
return 0.5
|
| 29 |
+
return float(np.clip(auc, 0.0, 1.0))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def compute_distortion_roc_auc(
|
| 33 |
+
meta: pd.DataFrame,
|
| 34 |
+
features: FeatureMatrix,
|
| 35 |
+
group_col: str = 'dist_type',
|
| 36 |
+
*,
|
| 37 |
+
global_feature_ids: Optional[List[int]] = None,
|
| 38 |
+
level: str = 'image',
|
| 39 |
+
feature_chunk_size: int = 512,
|
| 40 |
+
show_progress_bars: bool = True,
|
| 41 |
+
cache_path: Optional[str] = None,
|
| 42 |
+
) -> pd.DataFrame:
|
| 43 |
+
"""Compute one-vs-rest ROC-AUC between feature activations and distortion categories.
|
| 44 |
+
|
| 45 |
+
Returns a DataFrame with shape (n_categories, n_features), values in [0, 1].
|
| 46 |
+
"""
|
| 47 |
+
cached = load_parquet_cache(cache_path, label='roc auc')
|
| 48 |
+
if cached is not None:
|
| 49 |
+
return cached
|
| 50 |
+
|
| 51 |
+
work_features = features.subset(global_feature_ids)
|
| 52 |
+
work_codes, _, feature_names, unique_categories, cat_idx = _prepare_codes_and_categories(
|
| 53 |
+
meta=meta,
|
| 54 |
+
features=work_features,
|
| 55 |
+
group_col=group_col,
|
| 56 |
+
level=level,
|
| 57 |
+
binarize=False,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
n_samples, n_features = work_codes.shape
|
| 61 |
+
n_cats = len(unique_categories)
|
| 62 |
+
|
| 63 |
+
if feature_chunk_size <= 0:
|
| 64 |
+
raise ValueError(f'feature_chunk_size must be > 0, got {feature_chunk_size}')
|
| 65 |
+
|
| 66 |
+
auc_mat = np.empty((n_cats, n_features), dtype=np.float32)
|
| 67 |
+
|
| 68 |
+
for start in tqdm(range(0, n_features, feature_chunk_size), desc='Computing ROC-AUC', unit='chunk', disable=not show_progress_bars):
|
| 69 |
+
end = min(start + feature_chunk_size, n_features)
|
| 70 |
+
if sp.issparse(work_codes):
|
| 71 |
+
x_values = work_codes[:, start:end].toarray().astype(np.float32, copy=False)
|
| 72 |
+
else:
|
| 73 |
+
x_values = np.asarray(work_codes[:, start:end], dtype=np.float32)
|
| 74 |
+
|
| 75 |
+
for cat_i in range(n_cats):
|
| 76 |
+
positives_mask = (cat_idx == cat_i)
|
| 77 |
+
chunk_auc = np.empty((end - start,), dtype=np.float32)
|
| 78 |
+
for local_fid in range(end - start):
|
| 79 |
+
chunk_auc[local_fid] = _auc_from_scores(x_values[:, local_fid], positives_mask)
|
| 80 |
+
auc_mat[cat_i, start:end] = chunk_auc
|
| 81 |
+
|
| 82 |
+
result = pd.DataFrame(auc_mat, index=unique_categories, columns=feature_names)
|
| 83 |
+
|
| 84 |
+
save_parquet_cache(result, cache_path, label='roc auc')
|
| 85 |
+
|
| 86 |
+
return result
|
analysis/models.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import math
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from analysis.qalign_utils import QAlignVisionOnlyWrapper, flatten_blc_drop_cls
|
| 11 |
+
|
| 12 |
+
_iqa_activations: Dict[str, torch.Tensor] = {}
|
| 13 |
+
_iqa_activation_grids: Dict[str, Tuple[int, int]] = {}
|
| 14 |
+
_hook_handle = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _make_hook(name: str, sequence_layout: str = 'blc'):
|
| 18 |
+
"""Forward hook для ARNIQA/MANIQA/LIQE.
|
| 19 |
+
|
| 20 |
+
Поддерживаемые форматы выхода слоя:
|
| 21 |
+
- (B, C, H, W) -> (B*H*W, C)
|
| 22 |
+
- (B, L, C) -> (B*L, C) (MANIQA)
|
| 23 |
+
- (L, B, C) -> (B*L, C) (LIQE/LIQE-MIX)
|
| 24 |
+
"""
|
| 25 |
+
def hook(module, inp, out):
|
| 26 |
+
if hasattr(out, 'last_hidden_state'):
|
| 27 |
+
out_detached = out.last_hidden_state.detach() # Пока костыль
|
| 28 |
+
else:
|
| 29 |
+
out_detached = out.detach()
|
| 30 |
+
if out_detached.ndim == 4:
|
| 31 |
+
tensor_perm = out_detached.permute(0, 2, 3, 1) # (B, H, W, C)
|
| 32 |
+
_iqa_activation_grids[name] = tuple(tensor_perm.shape[1:3])
|
| 33 |
+
_iqa_activations[name] = tensor_perm.flatten(0, -2) # (B*H*W, C)
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
if out_detached.ndim == 3:
|
| 37 |
+
if sequence_layout == 'lbc':
|
| 38 |
+
# LIQE CLIP resblocks usually output (L, B, C).
|
| 39 |
+
tokens, batch, channels = out_detached.shape
|
| 40 |
+
# Drop CLS token when sequence length is 1 + square (e.g. 50 = 1 + 49).
|
| 41 |
+
spatial_wo_cls = int(math.isqrt(max(tokens - 1, 0)))
|
| 42 |
+
if spatial_wo_cls * spatial_wo_cls == (tokens - 1):
|
| 43 |
+
out_detached = out_detached[1:, :, :]
|
| 44 |
+
tokens = tokens - 1
|
| 45 |
+
flat_acts = out_detached.permute(1, 0, 2).reshape(batch * tokens, channels)
|
| 46 |
+
elif sequence_layout == 'blc':
|
| 47 |
+
# MANIQA Swin layers usually output (B, L, C).
|
| 48 |
+
batch, tokens, channels = out_detached.shape
|
| 49 |
+
flat_acts = out_detached.reshape(batch * tokens, channels)
|
| 50 |
+
elif sequence_layout == 'blc_drop_cls':
|
| 51 |
+
batch, tokens, channels = out_detached.shape
|
| 52 |
+
flat_acts = flatten_blc_drop_cls(out_detached)
|
| 53 |
+
tokens = flat_acts.shape[0] // batch
|
| 54 |
+
else:
|
| 55 |
+
raise ValueError(
|
| 56 |
+
f'Unsupported sequence_layout={sequence_layout!r} for layer {name!r}; '
|
| 57 |
+
"expected one of ('blc', 'lbc')"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
spatial = int(math.isqrt(tokens))
|
| 61 |
+
if spatial * spatial == tokens:
|
| 62 |
+
_iqa_activation_grids[name] = (spatial, spatial)
|
| 63 |
+
else:
|
| 64 |
+
_iqa_activation_grids[name] = (tokens, 1)
|
| 65 |
+
_iqa_activations[name] = flat_acts
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
raise ValueError(
|
| 69 |
+
f'Unsupported hooked activation ndim={out_detached.ndim} for layer {name!r}; '
|
| 70 |
+
'expected 3D or 4D output'
|
| 71 |
+
)
|
| 72 |
+
return hook
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
SAE_CONFIG_FILENAME = "sae_config.json"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def read_sae_config(
|
| 79 |
+
checkpoint_path: str,
|
| 80 |
+
config_path: Optional[str] = None,
|
| 81 |
+
**overrides: Any,
|
| 82 |
+
) -> Dict[str, Any]:
|
| 83 |
+
"""Read SAE JSON config next to a checkpoint (with optional field overrides)."""
|
| 84 |
+
if config_path is None:
|
| 85 |
+
config_path = os.path.join(
|
| 86 |
+
os.path.dirname(os.path.abspath(checkpoint_path)),
|
| 87 |
+
SAE_CONFIG_FILENAME,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
with open(config_path) as f:
|
| 91 |
+
cfg: Dict[str, Any] = json.load(f)
|
| 92 |
+
cfg.update(overrides)
|
| 93 |
+
return cfg
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def load_sae(
|
| 97 |
+
checkpoint_path: str,
|
| 98 |
+
config_path: Optional[str] = None,
|
| 99 |
+
device: str = 'cpu',
|
| 100 |
+
dtype: torch.dtype = torch.float32,
|
| 101 |
+
sae_config: Optional[Dict[str, Any]] = None,
|
| 102 |
+
**overrides: Any,
|
| 103 |
+
) -> torch.nn.Module:
|
| 104 |
+
"""Загружает SAE из чекпоинта, определяя архитектуру из JSON-конфига.
|
| 105 |
+
|
| 106 |
+
Parameters
|
| 107 |
+
----------
|
| 108 |
+
checkpoint_path : str
|
| 109 |
+
Путь к директории чекпоинта (output_dir/checkpoint-<step>/) или файлу весов.
|
| 110 |
+
config_path : str, optional
|
| 111 |
+
Путь к sae_config.json. По умолчанию ищется в родительской директории
|
| 112 |
+
чекпоинта (т.е. output_dir/sae_config.json).
|
| 113 |
+
device : str
|
| 114 |
+
Устройство для загрузки.
|
| 115 |
+
dtype : torch.dtype
|
| 116 |
+
Тип данных для загрузки.
|
| 117 |
+
sae_config : dict, optional
|
| 118 |
+
Уже загруженный конфиг.
|
| 119 |
+
**overrides
|
| 120 |
+
Переопределяют отдельные поля конфига (например, mp_threshold=0.05).
|
| 121 |
+
"""
|
| 122 |
+
import accelerate
|
| 123 |
+
from train_code.sae import SAE, MatchingPursuitSAE
|
| 124 |
+
|
| 125 |
+
if sae_config is None:
|
| 126 |
+
cfg = read_sae_config(checkpoint_path, config_path=config_path, **overrides)
|
| 127 |
+
else:
|
| 128 |
+
cfg = dict(sae_config)
|
| 129 |
+
cfg.update(overrides)
|
| 130 |
+
|
| 131 |
+
sae_type = cfg.get('sae_type', 'sae')
|
| 132 |
+
sae_input_dim: int = cfg['sae_input_dim']
|
| 133 |
+
inner_dim: int = cfg['inner_dim']
|
| 134 |
+
|
| 135 |
+
if sae_type == 'mp_sae':
|
| 136 |
+
model: torch.nn.Module = MatchingPursuitSAE(
|
| 137 |
+
in_dim=sae_input_dim,
|
| 138 |
+
inner_dim=inner_dim,
|
| 139 |
+
threshold=cfg.get('mp_threshold', 0.1),
|
| 140 |
+
normalize=cfg.get('mp_normalize', True),
|
| 141 |
+
)
|
| 142 |
+
else:
|
| 143 |
+
model = SAE(
|
| 144 |
+
in_dim=sae_input_dim,
|
| 145 |
+
inner_dim=inner_dim,
|
| 146 |
+
weight_norm_init=cfg.get('weight_norm_init', 0.3),
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
accelerate.load_checkpoint_in_model(model, checkpoint_path)
|
| 150 |
+
model.to(device, dtype)
|
| 151 |
+
model.eval()
|
| 152 |
+
return model
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def load_iqa_model(
|
| 156 |
+
layer_num: int,
|
| 157 |
+
device: str = 'cuda',
|
| 158 |
+
iqa_metric: str = 'arniqa-kadid',
|
| 159 |
+
swin_num: int = 2,
|
| 160 |
+
):
|
| 161 |
+
"""
|
| 162 |
+
Загружает IQA-модель и регистрирует hook на выбранный слой.
|
| 163 |
+
|
| 164 |
+
Поддерживаемые метрики:
|
| 165 |
+
- arniqa-kadid: hook на iqa.net.encoder[layer_num]
|
| 166 |
+
- maniqa: hook на iqa.net.swintransformer{swin_num}.layers[layer_num]
|
| 167 |
+
- liqe / liqe_mix: hook на iqa.net.clip_model.visual.transformer.resblocks[layer_num]
|
| 168 |
+
|
| 169 |
+
Возвращает
|
| 170 |
+
----------
|
| 171 |
+
iqa : загруженная IQA-модель
|
| 172 |
+
layer_name : строковый ключ, под которым активации хранятся в _iqa_activations
|
| 173 |
+
"""
|
| 174 |
+
import pyiqa
|
| 175 |
+
|
| 176 |
+
global _hook_handle
|
| 177 |
+
metric_key = iqa_metric.lower()
|
| 178 |
+
create_kwargs: Dict[str, Any] = {
|
| 179 |
+
'as_loss': False,
|
| 180 |
+
'device': device,
|
| 181 |
+
'loss_reduction': 'none',
|
| 182 |
+
}
|
| 183 |
+
if metric_key == 'maniqa':
|
| 184 |
+
create_kwargs['test_sample'] = 1
|
| 185 |
+
|
| 186 |
+
iqa = pyiqa.create_metric(metric_key, **create_kwargs)
|
| 187 |
+
if iqa_metric == 'qalign':
|
| 188 |
+
iqa = QAlignVisionOnlyWrapper(iqa)
|
| 189 |
+
iqa.eval()
|
| 190 |
+
|
| 191 |
+
if _hook_handle is not None:
|
| 192 |
+
_hook_handle.remove()
|
| 193 |
+
|
| 194 |
+
hook_layout = 'blc'
|
| 195 |
+
if metric_key == 'arniqa-kadid':
|
| 196 |
+
layer_name = f'arniqa_enc_{layer_num}'
|
| 197 |
+
target_layer = iqa.net.encoder[layer_num]
|
| 198 |
+
elif metric_key == 'maniqa':
|
| 199 |
+
layer_name = f'maniqa_swintransformer{swin_num}_layers_{layer_num}'
|
| 200 |
+
target_layer = getattr(iqa.net, f'swintransformer{swin_num}').layers[layer_num]
|
| 201 |
+
elif metric_key == 'qalign':
|
| 202 |
+
layer_name = 'visual_abstractor'
|
| 203 |
+
target_layer = iqa.visual_abstractor
|
| 204 |
+
elif metric_key in {'liqe', 'liqe_mix'}:
|
| 205 |
+
# Keep behavior close to train_script_liqe: skip layers after target block.
|
| 206 |
+
num_visual_layers = len(iqa.net.clip_model.visual.transformer.resblocks)
|
| 207 |
+
for i in range(layer_num + 1, num_visual_layers):
|
| 208 |
+
iqa.net.clip_model.visual.transformer.resblocks[i] = torch.nn.Identity()
|
| 209 |
+
layer_name = f'liqe_resblock_{layer_num}'
|
| 210 |
+
target_layer = iqa.net.clip_model.visual.transformer.resblocks[layer_num]
|
| 211 |
+
hook_layout = 'lbc'
|
| 212 |
+
else:
|
| 213 |
+
raise ValueError(
|
| 214 |
+
f'Unsupported iqa_metric={iqa_metric!r}; expected one of '
|
| 215 |
+
"('arniqa-kadid', 'maniqa', 'qalign', 'liqe', 'liqe_mix')"
|
| 216 |
+
)
|
| 217 |
+
if metric_key == 'qalign':
|
| 218 |
+
hook_layout = 'blc_drop_cls'
|
| 219 |
+
|
| 220 |
+
_hook_handle = target_layer.register_forward_hook(
|
| 221 |
+
_make_hook(layer_name, sequence_layout=hook_layout)
|
| 222 |
+
)
|
| 223 |
+
return iqa, layer_name
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _save_decoder_weight_norms_cache(cache_dir: str, norms: Mapping[int, float]) -> None:
|
| 227 |
+
cache_path = Path(cache_dir) / 'sae_decoder_weight_norms.parquet'
|
| 228 |
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 229 |
+
rows = [{'feature_id': int(k), 'norm': float(v)} for k, v in norms.items()]
|
| 230 |
+
pd.DataFrame(rows, columns=['feature_id', 'norm']).to_parquet(cache_path, index=False)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _load_decoder_weight_norms_cache(cache_dir: str) -> Optional[Dict[int, float]]:
|
| 234 |
+
cache_path = Path(cache_dir) / 'sae_decoder_weight_norms.parquet'
|
| 235 |
+
if not cache_path.exists():
|
| 236 |
+
return None
|
| 237 |
+
df = pd.read_parquet(cache_path)
|
| 238 |
+
if df.empty:
|
| 239 |
+
return None
|
| 240 |
+
return {int(row.feature_id): float(row.norm) for row in df.itertuples(index=False)}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def extract_decoder_weight_norms(checkpoint_path: str, cache_dir: str) -> Dict[int, float]:
|
| 244 |
+
cached_norms = _load_decoder_weight_norms_cache(cache_dir)
|
| 245 |
+
if cached_norms is not None:
|
| 246 |
+
return cached_norms
|
| 247 |
+
|
| 248 |
+
sae_model = load_sae(checkpoint_path=checkpoint_path, device='cpu')
|
| 249 |
+
try:
|
| 250 |
+
decoder_weight = sae_model.decoder.weight
|
| 251 |
+
except AttributeError:
|
| 252 |
+
decoder_weight = sae_model.W.mT
|
| 253 |
+
|
| 254 |
+
decoder_weight = decoder_weight.detach().cpu()
|
| 255 |
+
norms = decoder_weight.norm(dim=0).numpy()
|
| 256 |
+
norms_dict = {int(i): float(norms[i]) for i in range(int(norms.shape[0]))}
|
| 257 |
+
_save_decoder_weight_norms_cache(cache_dir, norms_dict)
|
| 258 |
+
return norms_dict
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def extract_model_hyperparameters(
|
| 262 |
+
sae_config: Optional[Dict[str, Any]],
|
| 263 |
+
checkpoint_path: str,
|
| 264 |
+
) -> Dict[str, Any]:
|
| 265 |
+
"""Extract model hyperparameters from runtime/config and SAE config json."""
|
| 266 |
+
hyperparams: Dict[str, Any] = {
|
| 267 |
+
'iqa_layer': 3,
|
| 268 |
+
'iqa_metric': 'arniqa-kadid',
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
if sae_config is None:
|
| 272 |
+
checkpoint = Path(checkpoint_path).resolve()
|
| 273 |
+
config_dir = checkpoint.parent if checkpoint.is_file() else checkpoint
|
| 274 |
+
config_path = config_dir / SAE_CONFIG_FILENAME
|
| 275 |
+
if not config_path.exists():
|
| 276 |
+
return hyperparams
|
| 277 |
+
try:
|
| 278 |
+
with config_path.open('r', encoding='utf-8') as f:
|
| 279 |
+
sae_config = json.load(f)
|
| 280 |
+
except Exception as exc:
|
| 281 |
+
print(f'[warn] Failed to load SAE config from {config_path}: {exc}')
|
| 282 |
+
return hyperparams
|
| 283 |
+
|
| 284 |
+
if sae_config and isinstance(sae_config, dict):
|
| 285 |
+
hyperparams['iqa_layer'] = sae_config.get('layer_num', hyperparams['iqa_layer'])
|
| 286 |
+
hyperparams['iqa_metric'] = sae_config.get('iqa_metric', hyperparams['iqa_metric'])
|
| 287 |
+
hyperparams['sae_type'] = sae_config.get('sae_type', 'sae')
|
| 288 |
+
hyperparams['lambda_param'] = sae_config.get('lambda_param', 'unknown')
|
| 289 |
+
hyperparams['sae_inner_dim'] = sae_config.get('inner_dim', 'unknown')
|
| 290 |
+
hyperparams['sae_input_dim'] = sae_config.get('sae_input_dim', 'unknown')
|
| 291 |
+
|
| 292 |
+
if hyperparams['sae_type'] == 'mp_sae':
|
| 293 |
+
hyperparams['mp_threshold'] = sae_config.get('mp_threshold', 0.1)
|
| 294 |
+
hyperparams['mp_normalize'] = sae_config.get('mp_normalize', True)
|
| 295 |
+
else:
|
| 296 |
+
hyperparams['weight_norm_init'] = sae_config.get('weight_norm_init', 0.3)
|
| 297 |
+
|
| 298 |
+
return hyperparams
|
analysis/qalign_utils.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Callable, Union
|
| 2 |
+
|
| 3 |
+
import pyiqa
|
| 4 |
+
import torch
|
| 5 |
+
from torchvision.transforms import functional as TF
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class QAlignVisionOnlyWrapper(torch.nn.Module):
|
| 9 |
+
"""Wrapper that only runs vision encoder up to visual_abstractor, skipping LLM.
|
| 10 |
+
|
| 11 |
+
Architecture path:
|
| 12 |
+
- base_model: InferenceModel
|
| 13 |
+
- base_model.net: QAlign
|
| 14 |
+
- base_model.net.model: MPLUGOwl2LlamaForCausalLM
|
| 15 |
+
- base_model.net.model.model: MPLUGOwl2LlamaModel
|
| 16 |
+
- vision_model: MplugOwlVisionModel
|
| 17 |
+
- visual_abstractor: MplugOwlVisualAbstractorModel
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, base_model: torch.nn.Module):
|
| 21 |
+
super().__init__()
|
| 22 |
+
self.base_model = base_model
|
| 23 |
+
self.vision_model = base_model.net.model.model.vision_model
|
| 24 |
+
self.visual_abstractor = base_model.net.model.model.visual_abstractor
|
| 25 |
+
|
| 26 |
+
self.base_model.eval()
|
| 27 |
+
for parameter in self.base_model.parameters():
|
| 28 |
+
parameter.requires_grad_(False)
|
| 29 |
+
|
| 30 |
+
def train(self, mode: bool = True):
|
| 31 |
+
super().train(mode)
|
| 32 |
+
self.base_model.eval()
|
| 33 |
+
return self
|
| 34 |
+
|
| 35 |
+
def eval(self):
|
| 36 |
+
super().eval()
|
| 37 |
+
self.base_model.eval()
|
| 38 |
+
return self
|
| 39 |
+
|
| 40 |
+
def forward(self, images: torch.Tensor):
|
| 41 |
+
device = next(self.vision_model.parameters()).device
|
| 42 |
+
if not isinstance(images, torch.Tensor):
|
| 43 |
+
images = torch.stack(list(images))
|
| 44 |
+
pixel_values = images.to(device=device, dtype=next(self.vision_model.parameters()).dtype)
|
| 45 |
+
|
| 46 |
+
with torch.no_grad():
|
| 47 |
+
hidden_states = self.vision_model(pixel_values).last_hidden_state
|
| 48 |
+
abstract_output = self.visual_abstractor(encoder_hidden_states=hidden_states)
|
| 49 |
+
|
| 50 |
+
return abstract_output
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def flatten_blc_drop_cls(hidden_states: torch.Tensor) -> torch.Tensor:
|
| 54 |
+
"""Drop the last CLS token from (B, L, C) and flatten to (B*L, C)."""
|
| 55 |
+
if hidden_states.dim() == 3 and hidden_states.shape[1] > 1:
|
| 56 |
+
hidden_states = hidden_states[:, :-1, :]
|
| 57 |
+
return hidden_states.reshape(-1, hidden_states.shape[-1])
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _center_crop_with_padding(image: torch.Tensor, crop_size: int) -> torch.Tensor:
|
| 61 |
+
_, height, width = image.shape
|
| 62 |
+
pad_height = max(0, crop_size - height)
|
| 63 |
+
pad_width = max(0, crop_size - width)
|
| 64 |
+
if pad_height > 0 or pad_width > 0:
|
| 65 |
+
padding = [pad_width // 2, pad_height // 2, pad_width - pad_width // 2, pad_height - pad_height // 2]
|
| 66 |
+
image = TF.pad(image, padding, fill=0, padding_mode="constant")
|
| 67 |
+
return TF.center_crop(image, [crop_size, crop_size])
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def qalign_image_transform(crop_size: int) -> Callable[[torch.Tensor], torch.Tensor]:
|
| 71 |
+
def _transform(image: torch.Tensor) -> torch.Tensor:
|
| 72 |
+
return _center_crop_with_padding(image, crop_size)
|
| 73 |
+
|
| 74 |
+
return _transform
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def create_qalign_model(device: Union[str, torch.device]) -> QAlignVisionOnlyWrapper:
|
| 78 |
+
"""Create Q-Align metric wrapped for vision-only activation extraction."""
|
| 79 |
+
base_model = pyiqa.api_helpers.create_metric("qalign")
|
| 80 |
+
base_model.to(device)
|
| 81 |
+
return QAlignVisionOnlyWrapper(base_model)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_visual_abstractor(model: torch.nn.Module):
|
| 85 |
+
"""Get visual_abstractor from a QAlignVisionOnlyWrapper."""
|
| 86 |
+
return model.visual_abstractor
|
analysis/utils.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Общие вспомогательные утилиты пакета analysis.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_top_images_for_feature(
|
| 14 |
+
features: FeatureMatrix,
|
| 15 |
+
meta: pd.DataFrame,
|
| 16 |
+
feature_id: int,
|
| 17 |
+
top_n: int = 10,
|
| 18 |
+
aggregation: str = 'mean_acts',
|
| 19 |
+
) -> List[int]:
|
| 20 |
+
"""
|
| 21 |
+
Возвращает индексы изображений, на которых признак feature_id активируется сильнее всего.
|
| 22 |
+
|
| 23 |
+
Активации патчей внутри одного изображения агрегируются в одно скалярное значение,
|
| 24 |
+
после чего изображения сортируются по убыванию.
|
| 25 |
+
|
| 26 |
+
Параметры
|
| 27 |
+
----------
|
| 28 |
+
features : CSR activations with global id per column
|
| 29 |
+
meta : DataFrame с колонкой 'image_idx' (один патч — одна строка)
|
| 30 |
+
feature_id : global SAE feature id
|
| 31 |
+
top_n : число возвращаемых изображений
|
| 32 |
+
aggregation : 'mean_acts' | 'max' | 'sum'
|
| 33 |
+
mean_acts — среднее по патчам с активацией > 0
|
| 34 |
+
max — максимальная активация среди патчей
|
| 35 |
+
sum — сумма всех активаций
|
| 36 |
+
|
| 37 |
+
Возвращает
|
| 38 |
+
----------
|
| 39 |
+
List[int] — image_idx в порядке убывания агрегированной активации
|
| 40 |
+
"""
|
| 41 |
+
assert aggregation in ('mean_acts', 'max', 'sum'), (
|
| 42 |
+
f"aggregation must be 'mean_acts', 'max' or 'sum', got {aggregation!r}"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
col = features.column_for(feature_id)
|
| 46 |
+
feature_acts = np.asarray(features.codes[:, col].todense()).ravel() # (n_patches,)
|
| 47 |
+
image_idx_arr = meta['image_idx'].values
|
| 48 |
+
|
| 49 |
+
unique_images = np.unique(image_idx_arr)
|
| 50 |
+
scores = np.empty(len(unique_images), dtype=np.float32)
|
| 51 |
+
|
| 52 |
+
for i, img_idx in enumerate(unique_images):
|
| 53 |
+
mask = image_idx_arr == img_idx
|
| 54 |
+
vals = feature_acts[mask]
|
| 55 |
+
if aggregation == 'mean_acts':
|
| 56 |
+
active = vals[vals > 0]
|
| 57 |
+
scores[i] = active.mean() if len(active) > 0 else 0.0
|
| 58 |
+
elif aggregation == 'max':
|
| 59 |
+
scores[i] = vals.max()
|
| 60 |
+
else:
|
| 61 |
+
scores[i] = vals.sum()
|
| 62 |
+
|
| 63 |
+
order = np.argsort(scores)[::-1]
|
| 64 |
+
return unique_images[order[:top_n]].tolist()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_top_images_for_feature_by_iou(
|
| 68 |
+
features: FeatureMatrix,
|
| 69 |
+
meta: pd.DataFrame,
|
| 70 |
+
feature_id: int,
|
| 71 |
+
top_n: int = 10,
|
| 72 |
+
dataset: str | None = None,
|
| 73 |
+
) -> List[int]:
|
| 74 |
+
"""
|
| 75 |
+
Возвращает индексы изображений с наибольшим IoU между бинарной картой
|
| 76 |
+
активаций признака и маской искажений для каждого изображения.
|
| 77 |
+
|
| 78 |
+
Требует, чтобы в `meta` были колонки `image_idx` и либо `patch_mask_label`,
|
| 79 |
+
либо `patch_is_distorted` (см. iou_utils._load_patch_mask_for_group).
|
| 80 |
+
"""
|
| 81 |
+
from analysis.metrics import iou_utils
|
| 82 |
+
|
| 83 |
+
col = features.column_for(feature_id)
|
| 84 |
+
feature_acts = np.asarray(features.codes[:, col].todense()).ravel()
|
| 85 |
+
image_groups = meta.groupby('image_idx')
|
| 86 |
+
|
| 87 |
+
scores = [] # list of (image_idx, iou)
|
| 88 |
+
for image_idx, group_df in image_groups:
|
| 89 |
+
sample_indices = group_df.index.to_numpy()
|
| 90 |
+
vals = feature_acts[sample_indices]
|
| 91 |
+
# binary activation map for this image (per-patch)
|
| 92 |
+
act_binary = (vals > 0).astype(np.uint8)
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
patch_masks = iou_utils._load_patch_mask_for_group(
|
| 96 |
+
group_df,
|
| 97 |
+
target_dist_type=None,
|
| 98 |
+
dataset=(dataset or ''),
|
| 99 |
+
)
|
| 100 |
+
except Exception:
|
| 101 |
+
# If no patch masks available, IoU is undefined — treat as 0
|
| 102 |
+
scores.append((int(image_idx), 0.0))
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
if patch_masks.shape[0] != act_binary.shape[0]:
|
| 106 |
+
# mismatch: skip or treat as 0
|
| 107 |
+
scores.append((int(image_idx), 0.0))
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
iou = float(iou_utils._compute_patch_iou(act_binary, patch_masks))
|
| 112 |
+
except Exception:
|
| 113 |
+
iou = 0.0
|
| 114 |
+
scores.append((int(image_idx), iou))
|
| 115 |
+
|
| 116 |
+
if not scores:
|
| 117 |
+
return []
|
| 118 |
+
|
| 119 |
+
scores.sort(key=lambda x: x[1], reverse=True)
|
| 120 |
+
return [img for img, _ in scores[:top_n]]
|
analysis/viz/__init__.py
ADDED
|
File without changes
|
analysis/viz/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (159 Bytes). View file
|
|
|
analysis/viz/__pycache__/umap_plot.cpython-310.pyc
ADDED
|
Binary file (6.62 kB). View file
|
|
|
analysis/viz/__pycache__/umap_utils.cpython-310.pyc
ADDED
|
Binary file (7.09 kB). View file
|
|
|
analysis/viz/__pycache__/vis_correlations.cpython-310.pyc
ADDED
|
Binary file (2.54 kB). View file
|
|
|
analysis/viz/__pycache__/vis_heatmaps.cpython-310.pyc
ADDED
|
Binary file (20.3 kB). View file
|
|
|
analysis/viz/__pycache__/vis_metrics.cpython-310.pyc
ADDED
|
Binary file (4.44 kB). View file
|
|
|
analysis/viz/__pycache__/vis_scatter.cpython-310.pyc
ADDED
|
Binary file (13 kB). View file
|
|
|
analysis/viz/umap_plot.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import base64
|
| 5 |
+
from typing import Any, List, Tuple
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
CATEGORY_PALETTE = [
|
| 13 |
+
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#17becf', '#bcbd22',
|
| 14 |
+
'#8c564b', '#e377c2', '#7f7f7f', '#9467bd', '#aec7e8', '#ffbb78', '#98df8a',
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _img_to_base64(img: Image.Image, fmt: str = 'JPEG', quality: int = 80) -> str:
|
| 19 |
+
buf = io.BytesIO()
|
| 20 |
+
img.save(buf, format=fmt, quality=quality)
|
| 21 |
+
return base64.b64encode(buf.getvalue()).decode('utf-8')
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def make_thumb_b64(img_idx: int, kadid_ds: Any, size: int = 140) -> str | None:
|
| 25 |
+
"""Return base64 thumbnail for image index using dataset object providing `images`.
|
| 26 |
+
|
| 27 |
+
`kadid_ds` is expected to expose sequence-like `.images` where each entry is a path.
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
p = str(kadid_ds.images[int(img_idx)])
|
| 31 |
+
img = Image.open(p).convert('RGB').resize((size, size), Image.LANCZOS)
|
| 32 |
+
return _img_to_base64(img)
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f"Error occurred while processing image {img_idx}: {e}")
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _align_umap_series_length(
|
| 39 |
+
emb: np.ndarray,
|
| 40 |
+
labels: np.ndarray,
|
| 41 |
+
customdata: np.ndarray | list | None,
|
| 42 |
+
hovertext: list[str] | None,
|
| 43 |
+
marker_sizes: np.ndarray | None,
|
| 44 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray | list | None, list[str] | None, np.ndarray | None]:
|
| 45 |
+
n = int(emb.shape[0])
|
| 46 |
+
labels_arr = np.asarray(labels)
|
| 47 |
+
if labels_arr.shape[0] == n:
|
| 48 |
+
return emb, labels_arr, customdata, hovertext, marker_sizes
|
| 49 |
+
|
| 50 |
+
n = min(n, int(labels_arr.shape[0]))
|
| 51 |
+
emb = emb[:n]
|
| 52 |
+
labels_arr = labels_arr[:n]
|
| 53 |
+
if customdata is not None:
|
| 54 |
+
customdata = list(customdata)[:n]
|
| 55 |
+
if hovertext is not None:
|
| 56 |
+
hovertext = list(hovertext)[:n]
|
| 57 |
+
if marker_sizes is not None:
|
| 58 |
+
marker_sizes = np.asarray(marker_sizes)[:n]
|
| 59 |
+
return emb, labels_arr, customdata, hovertext, marker_sizes
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def build_categorical_traces(
|
| 63 |
+
emb: np.ndarray,
|
| 64 |
+
labels: np.ndarray,
|
| 65 |
+
selected_category: str | None = None,
|
| 66 |
+
palette: List[str] | None = None,
|
| 67 |
+
customdata: np.ndarray | list | None = None,
|
| 68 |
+
hovertext: list[str] | None = None,
|
| 69 |
+
marker_sizes: np.ndarray | None = None,
|
| 70 |
+
) -> Tuple[list[go.Scatter], list[str]]:
|
| 71 |
+
emb, labels, customdata, hovertext, marker_sizes = _align_umap_series_length(
|
| 72 |
+
emb, labels, customdata, hovertext, marker_sizes
|
| 73 |
+
)
|
| 74 |
+
palette = CATEGORY_PALETTE if palette is None else palette
|
| 75 |
+
unique_cats = sorted(str(x) for x in np.unique(labels))
|
| 76 |
+
selected_category = None if selected_category in {None, ''} else str(selected_category)
|
| 77 |
+
if selected_category not in unique_cats:
|
| 78 |
+
selected_category = None
|
| 79 |
+
|
| 80 |
+
traces: list[go.Scatter] = []
|
| 81 |
+
for i, cat in enumerate(unique_cats):
|
| 82 |
+
if selected_category is not None and cat != selected_category:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
mask = np.asarray([str(v) == cat for v in labels], dtype=bool)
|
| 86 |
+
if not np.any(mask):
|
| 87 |
+
continue
|
| 88 |
+
img_indices = np.flatnonzero(mask).astype(np.int32)
|
| 89 |
+
|
| 90 |
+
# Prepare customdata as list of [img_idx, label]
|
| 91 |
+
if customdata is None:
|
| 92 |
+
trace_customdata = [[int(idx), str(labels[int(idx)])] for idx in img_indices]
|
| 93 |
+
else:
|
| 94 |
+
trace_customdata = [customdata[int(idx)] for idx in img_indices]
|
| 95 |
+
|
| 96 |
+
trace_hovertext = None
|
| 97 |
+
if hovertext is not None:
|
| 98 |
+
trace_hovertext = [hovertext[int(idx)] for idx in img_indices]
|
| 99 |
+
|
| 100 |
+
trace_sizes = None
|
| 101 |
+
if marker_sizes is not None:
|
| 102 |
+
trace_sizes = [float(marker_sizes[int(idx)]) for idx in img_indices]
|
| 103 |
+
|
| 104 |
+
color = palette[i % len(palette)]
|
| 105 |
+
traces.append(
|
| 106 |
+
go.Scatter(
|
| 107 |
+
x=emb[mask, 0],
|
| 108 |
+
y=emb[mask, 1],
|
| 109 |
+
mode='markers',
|
| 110 |
+
name=cat,
|
| 111 |
+
legendgroup=cat,
|
| 112 |
+
marker=dict(size=trace_sizes if trace_sizes is not None else 7, color=color, opacity=0.85, line=dict(width=0)),
|
| 113 |
+
customdata=trace_customdata,
|
| 114 |
+
hoverinfo='none',
|
| 115 |
+
hovertext=trace_hovertext if trace_hovertext is not None else [f"image_idx={int(idx)} | category={cat}" for idx in img_indices],
|
| 116 |
+
showlegend=True,
|
| 117 |
+
)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
return traces, unique_cats
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def build_umap_figure(
|
| 124 |
+
embedding_2d: np.ndarray,
|
| 125 |
+
labels: np.ndarray,
|
| 126 |
+
*,
|
| 127 |
+
title: str,
|
| 128 |
+
selected_category: str | None = None,
|
| 129 |
+
palette: List[str] | None = None,
|
| 130 |
+
customdata: np.ndarray | list | None = None,
|
| 131 |
+
hovertext: list[str] | None = None,
|
| 132 |
+
marker_sizes: np.ndarray | None = None,
|
| 133 |
+
) -> Tuple[go.Figure, list[str]]:
|
| 134 |
+
traces, unique_cats = build_categorical_traces(
|
| 135 |
+
embedding_2d,
|
| 136 |
+
labels,
|
| 137 |
+
selected_category=selected_category,
|
| 138 |
+
palette=palette,
|
| 139 |
+
customdata=customdata,
|
| 140 |
+
hovertext=hovertext,
|
| 141 |
+
marker_sizes=marker_sizes,
|
| 142 |
+
)
|
| 143 |
+
fig = go.Figure(data=traces)
|
| 144 |
+
fig.update_layout(
|
| 145 |
+
title=dict(text=title, x=0.01, xanchor='left', y=0.97, yanchor='top', font=dict(size=16)),
|
| 146 |
+
xaxis_title='UMAP 1',
|
| 147 |
+
yaxis_title='UMAP 2',
|
| 148 |
+
legend=dict(
|
| 149 |
+
orientation='h',
|
| 150 |
+
yanchor='top',
|
| 151 |
+
y=-0.16,
|
| 152 |
+
xanchor='left',
|
| 153 |
+
x=0.0,
|
| 154 |
+
title_text='',
|
| 155 |
+
font=dict(size=11),
|
| 156 |
+
tracegroupgap=8,
|
| 157 |
+
),
|
| 158 |
+
margin=dict(l=40, r=24, t=96, b=92),
|
| 159 |
+
hovermode='closest',
|
| 160 |
+
autosize=True,
|
| 161 |
+
)
|
| 162 |
+
return fig, unique_cats
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def plot_categorical_scatter_matplotlib(
|
| 166 |
+
emb: np.ndarray,
|
| 167 |
+
labels: np.ndarray,
|
| 168 |
+
title: str,
|
| 169 |
+
legend_title: str = 'category',
|
| 170 |
+
) -> None:
|
| 171 |
+
import matplotlib.pyplot as plt
|
| 172 |
+
|
| 173 |
+
unique_cats = sorted(dict.fromkeys(str(x) for x in labels))
|
| 174 |
+
cmap = plt.get_cmap('tab10')
|
| 175 |
+
n = max(1, len(unique_cats))
|
| 176 |
+
colors = [cmap(i % cmap.N) for i in range(n)]
|
| 177 |
+
plt.figure(figsize=(10, 7))
|
| 178 |
+
for i, cat in enumerate(unique_cats):
|
| 179 |
+
mask = np.array([str(v) == cat for v in labels])
|
| 180 |
+
if not np.any(mask):
|
| 181 |
+
continue
|
| 182 |
+
plt.scatter(
|
| 183 |
+
emb[mask, 0],
|
| 184 |
+
emb[mask, 1],
|
| 185 |
+
c=[colors[i]],
|
| 186 |
+
label=cat,
|
| 187 |
+
s=28,
|
| 188 |
+
alpha=0.85,
|
| 189 |
+
edgecolors='none',
|
| 190 |
+
)
|
| 191 |
+
plt.title(title)
|
| 192 |
+
plt.xlabel('UMAP 1')
|
| 193 |
+
plt.ylabel('UMAP 2')
|
| 194 |
+
plt.grid(True, alpha=0.3)
|
| 195 |
+
plt.legend(title=legend_title, bbox_to_anchor=(1.05, 1), loc='upper left')
|
| 196 |
+
plt.tight_layout()
|
| 197 |
+
plt.show()
|
analysis/viz/umap_utils.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import pickle
|
| 6 |
+
import re
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Callable, Dict, Hashable, Mapping, Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import umap
|
| 13 |
+
|
| 14 |
+
_UMAP_CACHE: Dict[Hashable, Dict[str, Any]] = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _json_default(value: object) -> object:
|
| 18 |
+
if isinstance(value, (np.integer, np.floating)):
|
| 19 |
+
return value.item()
|
| 20 |
+
if isinstance(value, np.ndarray):
|
| 21 |
+
return value.tolist()
|
| 22 |
+
return str(value)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _sanitize_cache_id(cache_id: str) -> str:
|
| 26 |
+
safe = re.sub(r'[^\w.\-]+', '_', str(cache_id).strip())
|
| 27 |
+
return safe or 'umap'
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def build_umap_cache_key(
|
| 31 |
+
*,
|
| 32 |
+
cache_id: str,
|
| 33 |
+
umap_params: Mapping[str, object],
|
| 34 |
+
signature: Mapping[str, object] | None = None,
|
| 35 |
+
x_shape: tuple[int, ...] | None = None,
|
| 36 |
+
) -> str:
|
| 37 |
+
"""Build a stable short hash for a UMAP embedding cache entry."""
|
| 38 |
+
payload: dict[str, object] = {
|
| 39 |
+
'cache_id': str(cache_id),
|
| 40 |
+
'umap_params': dict(umap_params),
|
| 41 |
+
}
|
| 42 |
+
if signature is not None:
|
| 43 |
+
payload['signature'] = dict(signature)
|
| 44 |
+
if x_shape is not None:
|
| 45 |
+
payload['x_shape'] = tuple(int(v) for v in x_shape)
|
| 46 |
+
raw = json.dumps(payload, sort_keys=True, default=_json_default).encode('utf-8')
|
| 47 |
+
return hashlib.sha256(raw).hexdigest()[:8]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def resolve_umap_cache_path(cache_dir: str | Path, cache_id: str, cache_key: str) -> Path:
|
| 51 |
+
"""Resolve on-disk cache path for one UMAP embedding."""
|
| 52 |
+
safe_id = _sanitize_cache_id(cache_id)
|
| 53 |
+
return Path(cache_dir) / f'{safe_id}_{cache_key}.npz'
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _load_umap_from_disk(cache_path: Path) -> Dict[str, Any] | None:
|
| 57 |
+
if not cache_path.exists():
|
| 58 |
+
return None
|
| 59 |
+
try:
|
| 60 |
+
with np.load(cache_path, allow_pickle=False) as data:
|
| 61 |
+
embedding_2d = np.asarray(data['embedding_2d'], dtype=np.float32)
|
| 62 |
+
seconds = float(np.asarray(data['seconds']).item())
|
| 63 |
+
x_shape = tuple(int(v) for v in np.asarray(data['x_shape']).tolist())
|
| 64 |
+
return {
|
| 65 |
+
'embedding_2d': embedding_2d,
|
| 66 |
+
'seconds': seconds,
|
| 67 |
+
'x_shape': x_shape,
|
| 68 |
+
}
|
| 69 |
+
except Exception as exc:
|
| 70 |
+
print(f'[cache][umap] invalid cache {cache_path}: {exc}. Recomputing...')
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _save_umap_to_disk(cache_path: Path, result: Mapping[str, Any]) -> None:
|
| 75 |
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
np.savez_compressed(
|
| 77 |
+
cache_path,
|
| 78 |
+
embedding_2d=np.asarray(result['embedding_2d'], dtype=np.float32),
|
| 79 |
+
seconds=np.float64(result.get('seconds', 0.0)),
|
| 80 |
+
x_shape=np.asarray(result['x_shape'], dtype=np.int64),
|
| 81 |
+
)
|
| 82 |
+
print(f'[cache][umap] saved: {cache_path}')
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _resolve_disk_cache_path(
|
| 86 |
+
*,
|
| 87 |
+
cache_dir: str | Path,
|
| 88 |
+
cache_id: str,
|
| 89 |
+
umap_params: Mapping[str, object],
|
| 90 |
+
signature: Mapping[str, object] | None,
|
| 91 |
+
x_shape: tuple[int, ...] | None,
|
| 92 |
+
) -> Path:
|
| 93 |
+
cache_key = build_umap_cache_key(
|
| 94 |
+
cache_id=cache_id,
|
| 95 |
+
umap_params=umap_params,
|
| 96 |
+
signature=signature,
|
| 97 |
+
x_shape=x_shape,
|
| 98 |
+
)
|
| 99 |
+
return resolve_umap_cache_path(cache_dir, cache_id, cache_key)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def compute_umap_from_features(features: np.ndarray, umap_params: Dict) -> Dict[str, Any]:
|
| 103 |
+
"""Compute a 2D UMAP embedding for the given feature matrix.
|
| 104 |
+
|
| 105 |
+
Returns a dict with keys: 'embedding_2d', 'seconds'.
|
| 106 |
+
"""
|
| 107 |
+
if umap is None:
|
| 108 |
+
raise RuntimeError("umap package is not available")
|
| 109 |
+
|
| 110 |
+
t0 = time.perf_counter()
|
| 111 |
+
reducer = umap.UMAP(**umap_params, verbose=False)
|
| 112 |
+
embedding_2d = reducer.fit_transform(features)
|
| 113 |
+
dt = time.perf_counter() - t0
|
| 114 |
+
|
| 115 |
+
return {"embedding_2d": embedding_2d.astype(np.float32), "seconds": float(dt)}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def get_or_compute_umap_with_builder(
|
| 119 |
+
key: Hashable,
|
| 120 |
+
build_features_fn: Callable[[], np.ndarray],
|
| 121 |
+
umap_params: Dict,
|
| 122 |
+
cache: Optional[Dict[Hashable, Dict[str, Any]]] = None,
|
| 123 |
+
*,
|
| 124 |
+
cache_dir: str | Path | None = None,
|
| 125 |
+
cache_id: str | None = None,
|
| 126 |
+
cache_signature: Mapping[str, object] | None = None,
|
| 127 |
+
) -> Dict[str, Any]:
|
| 128 |
+
"""Get or compute UMAP embedding using a builder function to produce features.
|
| 129 |
+
|
| 130 |
+
- ``key``: a hashable cache key (e.g. (feature_id, params_tuple)).
|
| 131 |
+
- ``build_features_fn``: zero-arg callable that returns a np.ndarray of shape (n_samples, n_features).
|
| 132 |
+
- ``umap_params``: parameters passed to ``umap.UMAP``.
|
| 133 |
+
- ``cache``: optional dict to use for caching; if None, module-level cache is used.
|
| 134 |
+
- ``cache_dir``: optional directory for on-disk NPZ cache (like feature filters).
|
| 135 |
+
- ``cache_id``: stable logical id used in cache filenames (defaults to ``str(key)``).
|
| 136 |
+
- ``cache_signature``: optional data fingerprint for disk cache lookup before feature build.
|
| 137 |
+
|
| 138 |
+
Returns a dict with embedding and timing and other meta fields. Adds 'cache_hit' flag.
|
| 139 |
+
"""
|
| 140 |
+
cache = _UMAP_CACHE if cache is None else cache
|
| 141 |
+
if key in cache:
|
| 142 |
+
cached = dict(cache[key])
|
| 143 |
+
cached["cache_hit"] = True
|
| 144 |
+
return cached
|
| 145 |
+
|
| 146 |
+
resolved_cache_id = _sanitize_cache_id(cache_id if cache_id is not None else str(key))
|
| 147 |
+
|
| 148 |
+
if cache_dir is not None and cache_signature is not None:
|
| 149 |
+
disk_cache_path = _resolve_disk_cache_path(
|
| 150 |
+
cache_dir=cache_dir,
|
| 151 |
+
cache_id=resolved_cache_id,
|
| 152 |
+
umap_params=umap_params,
|
| 153 |
+
signature=cache_signature,
|
| 154 |
+
x_shape=None,
|
| 155 |
+
)
|
| 156 |
+
loaded = _load_umap_from_disk(disk_cache_path)
|
| 157 |
+
if loaded is not None:
|
| 158 |
+
print(f'[cache][umap] hit: {disk_cache_path}')
|
| 159 |
+
result = {**loaded, "cache_hit": True, "disk_cache_hit": True}
|
| 160 |
+
cache[key] = dict(result)
|
| 161 |
+
return result
|
| 162 |
+
|
| 163 |
+
features = build_features_fn()
|
| 164 |
+
x_shape = tuple(features.shape)
|
| 165 |
+
|
| 166 |
+
if cache_dir is not None:
|
| 167 |
+
disk_cache_path = _resolve_disk_cache_path(
|
| 168 |
+
cache_dir=cache_dir,
|
| 169 |
+
cache_id=resolved_cache_id,
|
| 170 |
+
umap_params=umap_params,
|
| 171 |
+
signature=cache_signature,
|
| 172 |
+
x_shape=None if cache_signature is not None else x_shape,
|
| 173 |
+
)
|
| 174 |
+
loaded = _load_umap_from_disk(disk_cache_path)
|
| 175 |
+
if loaded is not None:
|
| 176 |
+
print(f'[cache][umap] hit: {disk_cache_path}')
|
| 177 |
+
result = {**loaded, "x_shape": x_shape, "cache_hit": True, "disk_cache_hit": True}
|
| 178 |
+
cache[key] = dict(result)
|
| 179 |
+
return result
|
| 180 |
+
else:
|
| 181 |
+
disk_cache_path = None
|
| 182 |
+
|
| 183 |
+
info = compute_umap_from_features(features, umap_params)
|
| 184 |
+
result = {**info, "x_shape": x_shape, "cache_hit": False, "disk_cache_hit": False}
|
| 185 |
+
cache[key] = dict(result)
|
| 186 |
+
|
| 187 |
+
if cache_dir is not None and disk_cache_path is not None:
|
| 188 |
+
try:
|
| 189 |
+
_save_umap_to_disk(disk_cache_path, result)
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
print(f'[cache][umap] failed to save {disk_cache_path}: {exc}')
|
| 192 |
+
|
| 193 |
+
return result
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def save_cache(path: str, cache: Optional[Dict[Hashable, Dict[str, Any]]] = None) -> None:
|
| 197 |
+
"""Save in-memory cache to disk using pickle."""
|
| 198 |
+
cache = _UMAP_CACHE if cache is None else cache
|
| 199 |
+
with open(path, "wb") as f:
|
| 200 |
+
pickle.dump(cache, f)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def load_cache(path: str) -> Dict[Hashable, Dict[str, Any]]:
|
| 204 |
+
"""Load in-memory cache from disk (returns the cache dict)."""
|
| 205 |
+
with open(path, "rb") as f:
|
| 206 |
+
data = pickle.load(f)
|
| 207 |
+
if not isinstance(data, dict):
|
| 208 |
+
raise ValueError("Cache file does not contain a dict")
|
| 209 |
+
return data
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def clear_cache(cache: Optional[Dict[Hashable, Dict[str, Any]]] = None) -> None:
|
| 213 |
+
cache = _UMAP_CACHE if cache is None else cache
|
| 214 |
+
cache.clear()
|
analysis/viz/vis_correlations.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualization helpers for correlation and metric heatmaps."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Optional, Tuple
|
| 5 |
+
|
| 6 |
+
import matplotlib.pyplot as plt
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import seaborn as sns
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def plot_correlation_heatmap(
|
| 12 |
+
corr_df: pd.DataFrame,
|
| 13 |
+
top_k: int = 30,
|
| 14 |
+
figsize: Optional[Tuple[int, int]] = None,
|
| 15 |
+
title: str = 'Feature-Distortion Correlation',
|
| 16 |
+
save_path: Optional[str] = None,
|
| 17 |
+
show: bool = True,
|
| 18 |
+
) -> Optional[str]:
|
| 19 |
+
"""
|
| 20 |
+
Heatmap correlations (n_categories x top_k_features).
|
| 21 |
+
|
| 22 |
+
Top-k features are selected by maximum absolute correlation
|
| 23 |
+
with at least one category.
|
| 24 |
+
"""
|
| 25 |
+
return plot_metric_heatmap(
|
| 26 |
+
metric_df=corr_df,
|
| 27 |
+
top_k=top_k,
|
| 28 |
+
figsize=figsize,
|
| 29 |
+
title=title,
|
| 30 |
+
metric_label='Pearson r',
|
| 31 |
+
use_abs_ranking=True,
|
| 32 |
+
center=0.0,
|
| 33 |
+
vmin=-1.0,
|
| 34 |
+
vmax=1.0,
|
| 35 |
+
save_path=save_path,
|
| 36 |
+
show=show,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def plot_metric_heatmap(
|
| 41 |
+
metric_df: pd.DataFrame,
|
| 42 |
+
top_k: int = 30,
|
| 43 |
+
figsize: Optional[Tuple[int, int]] = None,
|
| 44 |
+
title: str = 'Feature-Distortion Metric',
|
| 45 |
+
metric_label: str = 'Metric value',
|
| 46 |
+
use_abs_ranking: bool = True,
|
| 47 |
+
center: Optional[float] = None,
|
| 48 |
+
vmin: Optional[float] = None,
|
| 49 |
+
vmax: Optional[float] = None,
|
| 50 |
+
save_path: Optional[str] = None,
|
| 51 |
+
show: bool = True,
|
| 52 |
+
cmap: Optional[str] = None,
|
| 53 |
+
) -> Optional[str]:
|
| 54 |
+
"""Generic heatmap for correlations, MI, and other metrics."""
|
| 55 |
+
if use_abs_ranking:
|
| 56 |
+
rank_series = metric_df.abs().max(axis=0)
|
| 57 |
+
else:
|
| 58 |
+
rank_series = metric_df.max(axis=0)
|
| 59 |
+
|
| 60 |
+
top_features = rank_series.nlargest(top_k).index.tolist()
|
| 61 |
+
plot_data = metric_df.loc[:, top_features]
|
| 62 |
+
|
| 63 |
+
if figsize is None:
|
| 64 |
+
figsize = (max(12, top_k // 2), max(4, len(metric_df.index)))
|
| 65 |
+
|
| 66 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 67 |
+
annot = len(top_features) <= 20
|
| 68 |
+
kwargs = dict(
|
| 69 |
+
ax=ax,
|
| 70 |
+
cmap= cmap if cmap is not None else 'RdBu_r',
|
| 71 |
+
center=center,
|
| 72 |
+
vmin=vmin,
|
| 73 |
+
vmax=vmax,
|
| 74 |
+
linewidths=0.3,
|
| 75 |
+
annot=annot,
|
| 76 |
+
cbar_kws={'label': metric_label},
|
| 77 |
+
)
|
| 78 |
+
if annot:
|
| 79 |
+
kwargs['fmt'] = '.2f'
|
| 80 |
+
|
| 81 |
+
sns.heatmap(plot_data, **kwargs)
|
| 82 |
+
ax.set_xlabel('Feature ID')
|
| 83 |
+
ax.set_ylabel('Distortion category')
|
| 84 |
+
ax.set_title(title)
|
| 85 |
+
plt.tight_layout()
|
| 86 |
+
|
| 87 |
+
saved: Optional[str] = None
|
| 88 |
+
if save_path is not None:
|
| 89 |
+
save_path_obj = Path(save_path)
|
| 90 |
+
save_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
fig.savefig(save_path_obj, dpi=200, bbox_inches='tight')
|
| 92 |
+
saved = str(save_path_obj)
|
| 93 |
+
|
| 94 |
+
if show:
|
| 95 |
+
plt.show()
|
| 96 |
+
|
| 97 |
+
plt.close(fig)
|
| 98 |
+
return saved
|
analysis/viz/vis_heatmaps.py
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Визуализация тепловых карт SAE-признаков поверх изображений KADID-10k.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import io
|
| 6 |
+
import math
|
| 7 |
+
import re as _re
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 11 |
+
from typing import Dict, List, Optional, Union
|
| 12 |
+
from IPython.display import display
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
import scipy.sparse as sp
|
| 17 |
+
import torch
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
from matplotlib.patches import Patch, Rectangle
|
| 20 |
+
from PIL import Image
|
| 21 |
+
from torchvision import transforms
|
| 22 |
+
|
| 23 |
+
from overcomplete.visualization.plot_utils import show, interpolate_cv2, get_image_dimensions
|
| 24 |
+
from overcomplete.visualization.cmaps import VIRIDIS_ALPHA, TAB10_ALPHA
|
| 25 |
+
|
| 26 |
+
from analysis.datasets import (
|
| 27 |
+
QGROUND_DISTORTION_TYPES,
|
| 28 |
+
SRGROUND_DISTORTION_TYPES,
|
| 29 |
+
SRGROUND_LEGEND_LABELS,
|
| 30 |
+
available_distortions,
|
| 31 |
+
distortion_types_mapping,
|
| 32 |
+
_image_rel_from_meta_row,
|
| 33 |
+
srground_mask_rgb_for_meta_row,
|
| 34 |
+
srground_prominences_by_image_paths,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Show per-image filenames (and distortion subtitles where enabled) under heatmap panels.
|
| 38 |
+
SHOW_HEATMAP_IMAGE_TITLES = False
|
| 39 |
+
|
| 40 |
+
MASK_LEGEND_FONTSIZE = 20
|
| 41 |
+
MASK_LEGEND_HANDLE_HEIGHT = 1.15
|
| 42 |
+
MASK_LEGEND_HANDLE_LENGTH = 1.55
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _get_distortion_info(img_path: str):
|
| 46 |
+
"""
|
| 47 |
+
По имени файла KADID (I04_07_05.png) возвращает
|
| 48 |
+
(distortion_name, distortion_group) или (None, None) для оригиналов.
|
| 49 |
+
"""
|
| 50 |
+
name = Path(img_path).name
|
| 51 |
+
m = _re.match(r'I\d+_(\d+)_(\d+)\.png$', name, _re.IGNORECASE)
|
| 52 |
+
if m:
|
| 53 |
+
dist_id = int(m.group(1))
|
| 54 |
+
dist_name = distortion_types_mapping.get(dist_id, f'dist_{dist_id}')
|
| 55 |
+
dist_group = available_distortions.get(dist_name, '?')
|
| 56 |
+
return dist_name, dist_group
|
| 57 |
+
return None, None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _get_original_path(distorted_path: str) -> Optional[str]:
|
| 61 |
+
"""
|
| 62 |
+
Из пути к искажённому изображению KADID (I04_07_05.png)
|
| 63 |
+
возвращает путь к оригиналу (I04.png).
|
| 64 |
+
"""
|
| 65 |
+
p = Path(distorted_path)
|
| 66 |
+
match = _re.match(r'(I\d+)_\d+_\d+\.png$', p.name, _re.IGNORECASE)
|
| 67 |
+
if match:
|
| 68 |
+
return str(p.parent / f'{match.group(1)}.png')
|
| 69 |
+
|
| 70 |
+
# Local-KADID presaved naming: I04_001_dist.png -> I04.png
|
| 71 |
+
local_match = _re.match(r'(I\d+)_\d+_dist\.png$', p.name, _re.IGNORECASE)
|
| 72 |
+
if local_match:
|
| 73 |
+
return str(p.parent / f'{local_match.group(1)}.png')
|
| 74 |
+
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _meta_path_value(meta_row: Optional[pd.Series], column_name: str) -> Optional[str]:
|
| 79 |
+
if meta_row is None or column_name not in meta_row:
|
| 80 |
+
return None
|
| 81 |
+
value = meta_row.get(column_name)
|
| 82 |
+
if value is None or pd.isna(value):
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
value_str = str(value).strip()
|
| 86 |
+
return value_str or None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _build_image_title(
|
| 90 |
+
img_path: str,
|
| 91 |
+
meta_row: Optional[pd.Series] = None,
|
| 92 |
+
*,
|
| 93 |
+
include_filename: bool | None = None,
|
| 94 |
+
include_distortion_subtitle: bool = True,
|
| 95 |
+
) -> str:
|
| 96 |
+
show_filename = SHOW_HEATMAP_IMAGE_TITLES if include_filename is None else include_filename
|
| 97 |
+
title_lines: list[str] = []
|
| 98 |
+
if show_filename:
|
| 99 |
+
title_lines.append(Path(img_path).name)
|
| 100 |
+
|
| 101 |
+
if include_distortion_subtitle:
|
| 102 |
+
dist_name = _meta_path_value(meta_row, 'dist_type')
|
| 103 |
+
dist_group = _meta_path_value(meta_row, 'dist_group')
|
| 104 |
+
if dist_name and dist_name.lower() != 'background':
|
| 105 |
+
if dist_group and dist_group != dist_name:
|
| 106 |
+
title_lines.append(f'{dist_name} [{dist_group}]')
|
| 107 |
+
else:
|
| 108 |
+
title_lines.append(dist_name)
|
| 109 |
+
elif meta_row is not None:
|
| 110 |
+
ann_id = _meta_path_value(meta_row, 'qground_ann_id')
|
| 111 |
+
if ann_id:
|
| 112 |
+
title_lines.append(f'ann {ann_id}')
|
| 113 |
+
|
| 114 |
+
return '\n'.join(title_lines)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _set_image_subplot_title(
|
| 118 |
+
ax,
|
| 119 |
+
img_path: str,
|
| 120 |
+
meta_row: Optional[pd.Series] = None,
|
| 121 |
+
*,
|
| 122 |
+
include_filename: bool | None = None,
|
| 123 |
+
include_distortion_subtitle: bool = True,
|
| 124 |
+
) -> None:
|
| 125 |
+
title = _build_image_title(
|
| 126 |
+
img_path,
|
| 127 |
+
meta_row,
|
| 128 |
+
include_filename=include_filename,
|
| 129 |
+
include_distortion_subtitle=include_distortion_subtitle,
|
| 130 |
+
)
|
| 131 |
+
if title:
|
| 132 |
+
ax.set_title(title, fontsize=10)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _build_meta_lookup(meta: pd.DataFrame) -> dict[int, pd.Series]:
|
| 136 |
+
if 'image_idx' not in meta.columns:
|
| 137 |
+
return {}
|
| 138 |
+
|
| 139 |
+
first_rows = meta.groupby('image_idx', sort=False).first()
|
| 140 |
+
lookup: dict[int, pd.Series] = {}
|
| 141 |
+
for image_idx, row in first_rows.iterrows():
|
| 142 |
+
try:
|
| 143 |
+
lookup[int(image_idx)] = row
|
| 144 |
+
except (TypeError, ValueError):
|
| 145 |
+
continue
|
| 146 |
+
return lookup
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
FigureSaveResult = Union[str, bytes, None]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _finalize_figure(
|
| 153 |
+
fig,
|
| 154 |
+
*,
|
| 155 |
+
save_path: Optional[str],
|
| 156 |
+
show_img: bool,
|
| 157 |
+
dpi: int = 200,
|
| 158 |
+
**savefig_kw,
|
| 159 |
+
) -> FigureSaveResult:
|
| 160 |
+
"""Save figure to disk, return PNG bytes, or only display (notebook)."""
|
| 161 |
+
if save_path is not None:
|
| 162 |
+
save_path_obj = Path(save_path)
|
| 163 |
+
save_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
fig.savefig(save_path_obj, dpi=dpi, bbox_inches='tight', **savefig_kw)
|
| 165 |
+
result: FigureSaveResult = str(save_path_obj)
|
| 166 |
+
elif show_img:
|
| 167 |
+
result = None
|
| 168 |
+
else:
|
| 169 |
+
buffer = io.BytesIO()
|
| 170 |
+
fig.savefig(buffer, format='png', dpi=dpi, bbox_inches='tight', **savefig_kw)
|
| 171 |
+
result = buffer.getvalue()
|
| 172 |
+
|
| 173 |
+
if show_img:
|
| 174 |
+
display(fig)
|
| 175 |
+
|
| 176 |
+
plt.close(fig)
|
| 177 |
+
return result
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _append_heatmap_artifact(
|
| 181 |
+
artifacts: List[Dict[str, object]],
|
| 182 |
+
*,
|
| 183 |
+
kind: str,
|
| 184 |
+
feature_id: int,
|
| 185 |
+
saved: FigureSaveResult,
|
| 186 |
+
) -> None:
|
| 187 |
+
if saved is None:
|
| 188 |
+
return
|
| 189 |
+
entry: Dict[str, object] = {'kind': kind, 'feature_id': str(feature_id)}
|
| 190 |
+
if isinstance(saved, bytes):
|
| 191 |
+
entry['bytes'] = saved
|
| 192 |
+
else:
|
| 193 |
+
entry['path'] = saved
|
| 194 |
+
artifacts.append(entry)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _plot_heatmap_grid(
|
| 198 |
+
imgs_list: List[torch.Tensor],
|
| 199 |
+
codes_tensor: torch.Tensor,
|
| 200 |
+
feature_col: int,
|
| 201 |
+
image_paths: List[str],
|
| 202 |
+
img_size_inches: float = 4.0,
|
| 203 |
+
meta_rows: Optional[List[Optional[pd.Series]]] = None,
|
| 204 |
+
save_path: Optional[str] = None,
|
| 205 |
+
show_img: bool = True,
|
| 206 |
+
*,
|
| 207 |
+
title_feature_id: int | None = None,
|
| 208 |
+
) -> FigureSaveResult:
|
| 209 |
+
"""
|
| 210 |
+
Рисует сетку overlay-хитмапов для одного feature_id.
|
| 211 |
+
|
| 212 |
+
Параметры
|
| 213 |
+
----------
|
| 214 |
+
imgs_list : список тензоров изображений (C, H, W)
|
| 215 |
+
codes_tensor : тензор активаций (B, spatial, spatial, inner_dim)
|
| 216 |
+
feature_id : индекс признака SAE
|
| 217 |
+
image_paths : пути к PNG-файлам (для заголовков)
|
| 218 |
+
img_size_inches: размер одной ячейки в дюймах
|
| 219 |
+
"""
|
| 220 |
+
B = len(imgs_list)
|
| 221 |
+
inner_concepts = codes_tensor.shape[-1]
|
| 222 |
+
# cols = min(B, 5)
|
| 223 |
+
cols = B
|
| 224 |
+
# rows = math.ceil(B / cols)
|
| 225 |
+
rows = 1
|
| 226 |
+
|
| 227 |
+
display_id = int(title_feature_id) if title_feature_id is not None else int(feature_col)
|
| 228 |
+
if inner_concepts < 10:
|
| 229 |
+
cmap = TAB10_ALPHA[display_id % len(TAB10_ALPHA)]
|
| 230 |
+
else:
|
| 231 |
+
cmap = VIRIDIS_ALPHA
|
| 232 |
+
|
| 233 |
+
if meta_rows is None:
|
| 234 |
+
meta_rows = [None] * B
|
| 235 |
+
|
| 236 |
+
fig, axes = plt.subplots(rows, cols,
|
| 237 |
+
figsize=(img_size_inches * cols, img_size_inches * rows * 1.25))
|
| 238 |
+
axes_flat = np.array(axes).flatten() if B > 1 else [axes]
|
| 239 |
+
|
| 240 |
+
for ax, img_t, code_row, img_path, meta_row in zip(axes_flat, imgs_list, codes_tensor, image_paths, meta_rows):
|
| 241 |
+
plt.sca(ax)
|
| 242 |
+
width, height = get_image_dimensions(img_t)
|
| 243 |
+
heatmap_patch = code_row[:, :, feature_col]
|
| 244 |
+
if isinstance(heatmap_patch, torch.Tensor):
|
| 245 |
+
heatmap_patch = heatmap_patch.numpy()
|
| 246 |
+
heatmap = interpolate_cv2(heatmap_patch, (width, height))
|
| 247 |
+
show(img_t)
|
| 248 |
+
show(heatmap, cmap=cmap, alpha=1.0)
|
| 249 |
+
_set_image_subplot_title(ax, img_path, meta_row)
|
| 250 |
+
ax.axis('off')
|
| 251 |
+
|
| 252 |
+
for ax in axes_flat[B:]:
|
| 253 |
+
ax.axis('off')
|
| 254 |
+
|
| 255 |
+
fig.suptitle(f'Feature {display_id}', fontsize=13, x=0.5, ha='center')
|
| 256 |
+
plt.tight_layout()
|
| 257 |
+
return _finalize_figure(fig, save_path=save_path, show_img=show_img)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _plot_diff_grid(
|
| 261 |
+
imgs_list: List[torch.Tensor],
|
| 262 |
+
orig_tensors: List[Optional[torch.Tensor]],
|
| 263 |
+
feature_id: int,
|
| 264 |
+
image_paths: List[str],
|
| 265 |
+
img_size_inches: float = 4.0,
|
| 266 |
+
meta_rows: Optional[List[Optional[pd.Series]]] = None,
|
| 267 |
+
save_path: Optional[str] = None,
|
| 268 |
+
show_img: bool = True,
|
| 269 |
+
) -> FigureSaveResult:
|
| 270 |
+
"""
|
| 271 |
+
Рисует сетку |distorted − original| для одного feature_id.
|
| 272 |
+
|
| 273 |
+
Параметры
|
| 274 |
+
----------
|
| 275 |
+
imgs_list : список тензоров искажённых изображений (C, H, W)
|
| 276 |
+
orig_tensors : список тензоров оригиналов (или None, если файл не найден)
|
| 277 |
+
feature_id : индекс признака SAE (для заголовка)
|
| 278 |
+
image_paths : пути к PNG-файлам (для заголовков)
|
| 279 |
+
img_size_inches: размер одной ячейки в дюймах
|
| 280 |
+
"""
|
| 281 |
+
B = len(imgs_list)
|
| 282 |
+
fig_diff, axes = plt.subplots(1, B, figsize=(img_size_inches * B, img_size_inches * 1.25))
|
| 283 |
+
if B == 1:
|
| 284 |
+
axes = [axes]
|
| 285 |
+
if meta_rows is None:
|
| 286 |
+
meta_rows = [None] * B
|
| 287 |
+
|
| 288 |
+
for ax, dist_t, orig_t, img_path, meta_row in zip(axes, imgs_list, orig_tensors, image_paths, meta_rows):
|
| 289 |
+
if orig_t is not None:
|
| 290 |
+
diff = (dist_t.float() - orig_t.float()).abs()
|
| 291 |
+
diff_np = diff.permute(1, 2, 0).numpy().mean(axis=-1)
|
| 292 |
+
im = ax.imshow(diff_np, vmin=0, vmax=1)
|
| 293 |
+
fig_diff.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
| 294 |
+
_set_image_subplot_title(ax, img_path, meta_row)
|
| 295 |
+
else:
|
| 296 |
+
ax.text(0.5, 0.5, 'original not found', ha='center', va='center',
|
| 297 |
+
transform=ax.transAxes, fontsize=9)
|
| 298 |
+
ax.axis('off')
|
| 299 |
+
fig_diff.suptitle(f'|distorted − original| (feature {feature_id})',
|
| 300 |
+
fontsize=12, x=0.5, ha='center')
|
| 301 |
+
plt.tight_layout()
|
| 302 |
+
return _finalize_figure(fig_diff, save_path=save_path, show_img=show_img)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _plot_qground_mask_grid(
|
| 306 |
+
mask_paths: List[Optional[str]],
|
| 307 |
+
feature_id: int,
|
| 308 |
+
image_paths: List[str],
|
| 309 |
+
crop_size: int,
|
| 310 |
+
img_size_inches: float = 4.0,
|
| 311 |
+
meta_rows: Optional[List[Optional[pd.Series]]] = None,
|
| 312 |
+
save_path: Optional[str] = None,
|
| 313 |
+
show_img: bool = True,
|
| 314 |
+
) -> FigureSaveResult:
|
| 315 |
+
B = len(mask_paths)
|
| 316 |
+
fig, axes = plt.subplots(1, B, figsize=(img_size_inches * B, img_size_inches * 1.35))
|
| 317 |
+
if B == 1:
|
| 318 |
+
axes = [axes]
|
| 319 |
+
if meta_rows is None:
|
| 320 |
+
meta_rows = [None] * B
|
| 321 |
+
|
| 322 |
+
for ax, mask_path, img_path, meta_row in zip(axes, mask_paths, image_paths, meta_rows):
|
| 323 |
+
if mask_path is not None and Path(mask_path).exists():
|
| 324 |
+
mask_rgb = np.asarray(Image.open(mask_path).convert('RGB'), dtype=np.uint8)
|
| 325 |
+
ax.imshow(mask_rgb)
|
| 326 |
+
height, width = mask_rgb.shape[:2]
|
| 327 |
+
rect_width = float(crop_size)
|
| 328 |
+
rect_height = float(crop_size)
|
| 329 |
+
rect_x = (width - rect_width) / 2.0
|
| 330 |
+
rect_y = (height - rect_height) / 2.0
|
| 331 |
+
ax.add_patch(
|
| 332 |
+
Rectangle(
|
| 333 |
+
(rect_x, rect_y),
|
| 334 |
+
rect_width,
|
| 335 |
+
rect_height,
|
| 336 |
+
fill=False,
|
| 337 |
+
edgecolor='black',
|
| 338 |
+
linewidth=2.0,
|
| 339 |
+
linestyle='--',
|
| 340 |
+
)
|
| 341 |
+
)
|
| 342 |
+
_set_image_subplot_title(ax, img_path, meta_row)
|
| 343 |
+
else:
|
| 344 |
+
ax.text(0.5, 0.5, 'QGround mask not found', ha='center', va='center',
|
| 345 |
+
transform=ax.transAxes, fontsize=9)
|
| 346 |
+
ax.axis('off')
|
| 347 |
+
|
| 348 |
+
handles = [Patch(facecolor='black', edgecolor='none', label='background')]
|
| 349 |
+
for label_name, rgb in QGROUND_DISTORTION_TYPES.items():
|
| 350 |
+
color = tuple((np.asarray(rgb, dtype=np.float32) / 255.0).tolist())
|
| 351 |
+
handles.append(Patch(facecolor=color, edgecolor='none', label=label_name))
|
| 352 |
+
|
| 353 |
+
_add_mask_legend(fig, handles)
|
| 354 |
+
fig.suptitle(f'QGround annotation mask (feature {feature_id})', fontsize=12, x=0.5, ha='center')
|
| 355 |
+
fig.tight_layout(rect=(0, 0.14, 1, 1))
|
| 356 |
+
return _finalize_figure(fig, save_path=save_path, show_img=show_img)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _add_mask_legend(fig, handles: list[Patch], *, ncol: int = 3) -> None:
|
| 360 |
+
fig.legend(
|
| 361 |
+
handles=handles,
|
| 362 |
+
loc='lower center',
|
| 363 |
+
ncol=ncol,
|
| 364 |
+
frameon=False,
|
| 365 |
+
bbox_to_anchor=(0.5, -0.02),
|
| 366 |
+
fontsize=MASK_LEGEND_FONTSIZE,
|
| 367 |
+
handleheight=MASK_LEGEND_HANDLE_HEIGHT,
|
| 368 |
+
handlelength=MASK_LEGEND_HANDLE_LENGTH,
|
| 369 |
+
labelspacing=0.55,
|
| 370 |
+
borderpad=0.4,
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def srground_legend_handles(*, include_sr_artifact: bool = True) -> list[Patch]:
|
| 375 |
+
handles = [Patch(facecolor='black', edgecolor='none', label='background')]
|
| 376 |
+
for dist_name, rgb in SRGROUND_DISTORTION_TYPES.items():
|
| 377 |
+
if dist_name == 'sr_artifact' and not include_sr_artifact:
|
| 378 |
+
continue
|
| 379 |
+
color = tuple((np.asarray(rgb, dtype=np.float32) / 255.0).tolist())
|
| 380 |
+
label = SRGROUND_LEGEND_LABELS.get(dist_name, dist_name)
|
| 381 |
+
handles.append(Patch(facecolor=color, edgecolor='none', label=label))
|
| 382 |
+
return handles
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _plot_overlay_and_srground_mask_rows(
|
| 386 |
+
imgs_list: List[torch.Tensor],
|
| 387 |
+
codes_tensor: torch.Tensor,
|
| 388 |
+
feature_col: int,
|
| 389 |
+
image_paths: List[str],
|
| 390 |
+
mask_rgb_list: List[Optional[np.ndarray]],
|
| 391 |
+
img_size_inches: float,
|
| 392 |
+
meta_rows: Optional[List[Optional[pd.Series]]],
|
| 393 |
+
*,
|
| 394 |
+
title_feature_id: int,
|
| 395 |
+
include_sr_artifact: bool,
|
| 396 |
+
crop_size: int,
|
| 397 |
+
save_path: Optional[str] = None,
|
| 398 |
+
show_img: bool = False,
|
| 399 |
+
) -> FigureSaveResult:
|
| 400 |
+
B = len(imgs_list)
|
| 401 |
+
inner_concepts = codes_tensor.shape[-1]
|
| 402 |
+
display_id = int(title_feature_id)
|
| 403 |
+
|
| 404 |
+
if inner_concepts < 10:
|
| 405 |
+
cmap = TAB10_ALPHA[display_id % len(TAB10_ALPHA)]
|
| 406 |
+
else:
|
| 407 |
+
cmap = VIRIDIS_ALPHA
|
| 408 |
+
|
| 409 |
+
if meta_rows is None:
|
| 410 |
+
meta_rows = [None] * B
|
| 411 |
+
|
| 412 |
+
fig, axes = plt.subplots(2, B, figsize=(img_size_inches * B, img_size_inches * 2.55))
|
| 413 |
+
if B == 1:
|
| 414 |
+
overlay_axes = [axes[0]]
|
| 415 |
+
mask_axes = [axes[1]]
|
| 416 |
+
else:
|
| 417 |
+
overlay_axes = list(axes[0])
|
| 418 |
+
mask_axes = list(axes[1])
|
| 419 |
+
|
| 420 |
+
for ax, img_t, code_row, img_path, meta_row in zip(
|
| 421 |
+
overlay_axes, imgs_list, codes_tensor, image_paths, meta_rows
|
| 422 |
+
):
|
| 423 |
+
plt.sca(ax)
|
| 424 |
+
width, height = get_image_dimensions(img_t)
|
| 425 |
+
heatmap_patch = code_row[:, :, feature_col]
|
| 426 |
+
if isinstance(heatmap_patch, torch.Tensor):
|
| 427 |
+
heatmap_patch = heatmap_patch.numpy()
|
| 428 |
+
heatmap = interpolate_cv2(heatmap_patch, (width, height))
|
| 429 |
+
show(img_t)
|
| 430 |
+
show(heatmap, cmap=cmap, alpha=1.0)
|
| 431 |
+
_set_image_subplot_title(
|
| 432 |
+
ax,
|
| 433 |
+
img_path,
|
| 434 |
+
meta_row,
|
| 435 |
+
include_distortion_subtitle=False,
|
| 436 |
+
)
|
| 437 |
+
ax.axis('off')
|
| 438 |
+
|
| 439 |
+
for ax, mask_rgb in zip(mask_axes, mask_rgb_list):
|
| 440 |
+
if mask_rgb is not None:
|
| 441 |
+
ax.imshow(mask_rgb)
|
| 442 |
+
height, width = mask_rgb.shape[:2]
|
| 443 |
+
rect_size = float(crop_size)
|
| 444 |
+
rect_x = (width - rect_size) / 2.0
|
| 445 |
+
rect_y = (height - rect_size) / 2.0
|
| 446 |
+
ax.add_patch(
|
| 447 |
+
Rectangle(
|
| 448 |
+
(rect_x, rect_y),
|
| 449 |
+
rect_size,
|
| 450 |
+
rect_size,
|
| 451 |
+
fill=False,
|
| 452 |
+
edgecolor='black',
|
| 453 |
+
linewidth=2.0,
|
| 454 |
+
linestyle='--',
|
| 455 |
+
)
|
| 456 |
+
)
|
| 457 |
+
else:
|
| 458 |
+
ax.text(
|
| 459 |
+
0.5,
|
| 460 |
+
0.5,
|
| 461 |
+
'annotation mask not found',
|
| 462 |
+
ha='center',
|
| 463 |
+
va='center',
|
| 464 |
+
transform=ax.transAxes,
|
| 465 |
+
fontsize=9,
|
| 466 |
+
)
|
| 467 |
+
ax.axis('off')
|
| 468 |
+
|
| 469 |
+
_add_mask_legend(
|
| 470 |
+
fig,
|
| 471 |
+
srground_legend_handles(include_sr_artifact=include_sr_artifact),
|
| 472 |
+
)
|
| 473 |
+
fig.suptitle(f'Feature {display_id}', fontsize=13, x=0.5, ha='center')
|
| 474 |
+
fig.tight_layout(rect=(0, 0.16, 1, 0.96))
|
| 475 |
+
return _finalize_figure(fig, save_path=save_path, show_img=show_img)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def render_top_feature_panel_srground(
|
| 479 |
+
meta: pd.DataFrame,
|
| 480 |
+
features: FeatureMatrix,
|
| 481 |
+
image_indices: List[int],
|
| 482 |
+
image_paths: List[str],
|
| 483 |
+
feature_id: int,
|
| 484 |
+
*,
|
| 485 |
+
patches_per_image: Optional[int] = None,
|
| 486 |
+
crop_size: int = 224,
|
| 487 |
+
img_size_inches: float = 3.5,
|
| 488 |
+
include_sr_artifact: bool = True,
|
| 489 |
+
datasets_root: str | None = None,
|
| 490 |
+
) -> bytes | None:
|
| 491 |
+
"""Two-row panel: SAE heatmap overlays and SRGround annotation masks with legend."""
|
| 492 |
+
pil_images = [Image.open(p).convert('RGB') for p in image_paths]
|
| 493 |
+
preprocess = transforms.Compose(
|
| 494 |
+
[
|
| 495 |
+
transforms.CenterCrop(int(crop_size)),
|
| 496 |
+
transforms.ToTensor(),
|
| 497 |
+
]
|
| 498 |
+
)
|
| 499 |
+
imgs_list = [preprocess(img) for img in pil_images]
|
| 500 |
+
|
| 501 |
+
if patches_per_image is None:
|
| 502 |
+
patches_per_image = int(meta['patch_idx'].max()) + 1
|
| 503 |
+
spatial = int(math.isqrt(patches_per_image))
|
| 504 |
+
inner_dim = features.codes.shape[1]
|
| 505 |
+
image_idx_arr = meta['image_idx'].values
|
| 506 |
+
codes_list = []
|
| 507 |
+
for img_idx in image_indices:
|
| 508 |
+
row_mask = image_idx_arr == img_idx
|
| 509 |
+
codes_list.append(features.codes[row_mask].toarray().astype(np.float32))
|
| 510 |
+
codes_tensor = torch.from_numpy(np.stack(codes_list)).view(len(image_indices), spatial, spatial, inner_dim)
|
| 511 |
+
|
| 512 |
+
meta_lookup = _build_meta_lookup(meta)
|
| 513 |
+
meta_rows = [meta_lookup.get(int(img_idx)) for img_idx in image_indices]
|
| 514 |
+
image_rels = [_image_rel_from_meta_row(row) for row in meta_rows]
|
| 515 |
+
prom_map = srground_prominences_by_image_paths(
|
| 516 |
+
[rel for rel in image_rels if rel],
|
| 517 |
+
datasets_root=datasets_root,
|
| 518 |
+
)
|
| 519 |
+
mask_rgb_list = [
|
| 520 |
+
srground_mask_rgb_for_meta_row(
|
| 521 |
+
row,
|
| 522 |
+
datasets_root=datasets_root,
|
| 523 |
+
include_sr_artifact=include_sr_artifact,
|
| 524 |
+
crop_size=crop_size,
|
| 525 |
+
prominences=prom_map.get(image_rel) if image_rel else None,
|
| 526 |
+
)
|
| 527 |
+
for row, image_rel in zip(meta_rows, image_rels)
|
| 528 |
+
]
|
| 529 |
+
|
| 530 |
+
matrix_col = features.column_for(int(feature_id))
|
| 531 |
+
saved = _plot_overlay_and_srground_mask_rows(
|
| 532 |
+
imgs_list,
|
| 533 |
+
codes_tensor,
|
| 534 |
+
matrix_col,
|
| 535 |
+
image_paths,
|
| 536 |
+
mask_rgb_list,
|
| 537 |
+
img_size_inches,
|
| 538 |
+
meta_rows,
|
| 539 |
+
title_feature_id=int(feature_id),
|
| 540 |
+
include_sr_artifact=include_sr_artifact,
|
| 541 |
+
crop_size=crop_size,
|
| 542 |
+
save_path=None,
|
| 543 |
+
show_img=False,
|
| 544 |
+
)
|
| 545 |
+
return saved if isinstance(saved, bytes) else None
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def visualize_feature_heatmaps(
|
| 549 |
+
meta: pd.DataFrame,
|
| 550 |
+
features: FeatureMatrix,
|
| 551 |
+
image_indices: List[int],
|
| 552 |
+
image_paths: List[str],
|
| 553 |
+
feature_ids: List[int],
|
| 554 |
+
patches_per_image: Optional[int] = None,
|
| 555 |
+
crop_size: int = 224,
|
| 556 |
+
img_size_inches: float = 4.0,
|
| 557 |
+
show_diff: bool = True,
|
| 558 |
+
save_dir: Optional[str] = None,
|
| 559 |
+
file_prefix: str = '',
|
| 560 |
+
show_img: bool = True,
|
| 561 |
+
) -> List[Dict[str, object]]:
|
| 562 |
+
"""
|
| 563 |
+
Визуализирует тепловые карты SAE-признаков из предвычисленных sparse активаций.
|
| 564 |
+
|
| 565 |
+
Для каждого feature_id отображается:
|
| 566 |
+
- overlay-хитмап поверх искажённого изображения
|
| 567 |
+
- (опционально) |distorted − original| попиксельно
|
| 568 |
+
|
| 569 |
+
Параметры
|
| 570 |
+
----------
|
| 571 |
+
meta : DataFrame с метаданными (image_idx, patch_idx, ...)
|
| 572 |
+
features : CSR activations with global id per column
|
| 573 |
+
image_indices : индексы изображений (image_idx) для визуализации
|
| 574 |
+
image_paths : пути к соответствующим PNG-файлам
|
| 575 |
+
feature_ids : global SAE feature ids to visualize
|
| 576 |
+
patches_per_image: число патчей на изображ��ние; если None — из patch_idx
|
| 577 |
+
crop_size : размер кропа при кэшировании
|
| 578 |
+
img_size_inches : размер одного изображения на фигуре (в дюймах)
|
| 579 |
+
show_diff : показывать ли |distorted − original|
|
| 580 |
+
save_dir : директория для сохранения PNG; если None — PNG в памяти (ключ ``bytes``)
|
| 581 |
+
file_prefix : префикс имени файла (например, stage/agg)
|
| 582 |
+
show_img : показывать ли фигуры inline
|
| 583 |
+
|
| 584 |
+
Возвращает
|
| 585 |
+
----------
|
| 586 |
+
List[Dict[str, object]] — артефакты с ключом ``path`` (диск) или ``bytes`` (память)
|
| 587 |
+
"""
|
| 588 |
+
assert len(image_indices) == len(image_paths), \
|
| 589 |
+
"image_indices и image_paths должны иметь одинаковую длину"
|
| 590 |
+
|
| 591 |
+
codes = features.codes
|
| 592 |
+
inner_dim = codes.shape[1]
|
| 593 |
+
|
| 594 |
+
if patches_per_image is None:
|
| 595 |
+
patches_per_image = int(meta['patch_idx'].max()) + 1
|
| 596 |
+
|
| 597 |
+
spatial = int(math.isqrt(patches_per_image))
|
| 598 |
+
assert spatial * spatial == patches_per_image, (
|
| 599 |
+
f"Число патчей {patches_per_image} не является точным квадратом — "
|
| 600 |
+
f"проверьте crop_size или слой модели"
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
preprocess = transforms.Compose(
|
| 604 |
+
[
|
| 605 |
+
transforms.CenterCrop(crop_size),
|
| 606 |
+
transforms.ToTensor(),
|
| 607 |
+
]
|
| 608 |
+
)
|
| 609 |
+
|
| 610 |
+
pil_images = [Image.open(p).convert("RGB") for p in image_paths]
|
| 611 |
+
return visualize_feature_heatmaps_from_images(
|
| 612 |
+
meta=meta,
|
| 613 |
+
features=features,
|
| 614 |
+
image_indices=image_indices,
|
| 615 |
+
images=pil_images,
|
| 616 |
+
image_names=image_paths,
|
| 617 |
+
feature_ids=feature_ids,
|
| 618 |
+
patches_per_image=patches_per_image,
|
| 619 |
+
crop_size=crop_size,
|
| 620 |
+
img_size_inches=img_size_inches,
|
| 621 |
+
show_diff=show_diff,
|
| 622 |
+
save_dir=save_dir,
|
| 623 |
+
file_prefix=file_prefix,
|
| 624 |
+
show_img=show_img,
|
| 625 |
+
preprocess=preprocess,
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def visualize_feature_heatmaps_from_images(
|
| 630 |
+
meta: pd.DataFrame,
|
| 631 |
+
features: FeatureMatrix,
|
| 632 |
+
image_indices: List[int],
|
| 633 |
+
images: List[Image.Image],
|
| 634 |
+
image_names: List[str],
|
| 635 |
+
feature_ids: List[int],
|
| 636 |
+
patches_per_image: Optional[int] = None,
|
| 637 |
+
crop_size: int = 224,
|
| 638 |
+
img_size_inches: float = 4.0,
|
| 639 |
+
show_diff: bool = True,
|
| 640 |
+
save_dir: Optional[str] = None,
|
| 641 |
+
file_prefix: str = '',
|
| 642 |
+
show_img: bool = True,
|
| 643 |
+
*,
|
| 644 |
+
preprocess: Optional[object] = None,
|
| 645 |
+
) -> List[Dict[str, str]]:
|
| 646 |
+
"""Same as `visualize_feature_heatmaps`, but accepts already opened PIL images.
|
| 647 |
+
|
| 648 |
+
This is useful for Dash uploads to avoid writing temporary image files.
|
| 649 |
+
"""
|
| 650 |
+
assert len(image_indices) == len(images) == len(image_names), (
|
| 651 |
+
"image_indices, images, and image_names must have the same length"
|
| 652 |
+
)
|
| 653 |
+
|
| 654 |
+
codes = features.codes
|
| 655 |
+
inner_dim = codes.shape[1]
|
| 656 |
+
|
| 657 |
+
if patches_per_image is None:
|
| 658 |
+
patches_per_image = int(meta['patch_idx'].max()) + 1
|
| 659 |
+
|
| 660 |
+
spatial = int(math.isqrt(patches_per_image))
|
| 661 |
+
assert spatial * spatial == patches_per_image, (
|
| 662 |
+
f"Число патчей {patches_per_image} не является точным квадратом — "
|
| 663 |
+
f"проверьте crop_size или слой модели"
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
if preprocess is None:
|
| 667 |
+
preprocess = transforms.Compose(
|
| 668 |
+
[
|
| 669 |
+
transforms.CenterCrop(crop_size),
|
| 670 |
+
transforms.ToTensor(),
|
| 671 |
+
]
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
imgs_list = [preprocess(img.convert("RGB")) for img in images]
|
| 675 |
+
|
| 676 |
+
meta_lookup = _build_meta_lookup(meta)
|
| 677 |
+
meta_rows = [meta_lookup.get(int(img_idx)) for img_idx in image_indices]
|
| 678 |
+
|
| 679 |
+
orig_tensors: List[Optional[torch.Tensor]] = []
|
| 680 |
+
mask_paths: List[Optional[str]] = []
|
| 681 |
+
has_qground_masks = False
|
| 682 |
+
if show_diff:
|
| 683 |
+
for img_name, meta_row in zip(image_names, meta_rows):
|
| 684 |
+
orig_path = _get_original_path(img_name) or _meta_path_value(meta_row, 'original_img_path')
|
| 685 |
+
if orig_path and Path(orig_path).exists():
|
| 686 |
+
orig_tensors.append(preprocess(Image.open(orig_path).convert('RGB')))
|
| 687 |
+
else:
|
| 688 |
+
orig_tensors.append(None)
|
| 689 |
+
|
| 690 |
+
mask_path = _meta_path_value(meta_row, 'mask_path')
|
| 691 |
+
if mask_path and Path(mask_path).exists():
|
| 692 |
+
has_qground_masks = True
|
| 693 |
+
mask_paths.append(mask_path)
|
| 694 |
+
|
| 695 |
+
image_idx_arr = meta['image_idx'].values
|
| 696 |
+
codes_list = []
|
| 697 |
+
for img_idx in image_indices:
|
| 698 |
+
mask = image_idx_arr == img_idx
|
| 699 |
+
codes_list.append(codes[mask].toarray().astype(np.float32))
|
| 700 |
+
|
| 701 |
+
codes_np = np.stack(codes_list) # (B, P, inner_dim)
|
| 702 |
+
codes_tensor = torch.from_numpy(codes_np).view(
|
| 703 |
+
len(image_indices), spatial, spatial, inner_dim
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
save_root = Path(save_dir) if save_dir is not None else None
|
| 707 |
+
artifacts: List[Dict[str, object]] = []
|
| 708 |
+
|
| 709 |
+
for global_feature_id in feature_ids:
|
| 710 |
+
matrix_col = features.column_for(int(global_feature_id))
|
| 711 |
+
safe_prefix = f'{file_prefix}_' if file_prefix else ''
|
| 712 |
+
overlay_name = f'{safe_prefix}feature_{global_feature_id}_overlay.png'
|
| 713 |
+
overlay_path = str(save_root / overlay_name) if save_root is not None else None
|
| 714 |
+
saved_overlay = _plot_heatmap_grid(
|
| 715 |
+
imgs_list,
|
| 716 |
+
codes_tensor,
|
| 717 |
+
matrix_col,
|
| 718 |
+
image_names,
|
| 719 |
+
img_size_inches,
|
| 720 |
+
meta_rows=meta_rows,
|
| 721 |
+
save_path=overlay_path,
|
| 722 |
+
show_img=show_img,
|
| 723 |
+
title_feature_id=int(global_feature_id),
|
| 724 |
+
)
|
| 725 |
+
_append_heatmap_artifact(
|
| 726 |
+
artifacts,
|
| 727 |
+
kind='overlay',
|
| 728 |
+
feature_id=int(global_feature_id),
|
| 729 |
+
saved=saved_overlay,
|
| 730 |
+
)
|
| 731 |
+
if show_diff and any(t is not None for t in orig_tensors):
|
| 732 |
+
diff_name = f'{safe_prefix}feature_{global_feature_id}_diff.png'
|
| 733 |
+
diff_path = str(save_root / diff_name) if save_root is not None else None
|
| 734 |
+
saved_diff = _plot_diff_grid(
|
| 735 |
+
imgs_list,
|
| 736 |
+
orig_tensors,
|
| 737 |
+
global_feature_id,
|
| 738 |
+
image_names,
|
| 739 |
+
img_size_inches,
|
| 740 |
+
meta_rows=meta_rows,
|
| 741 |
+
save_path=diff_path,
|
| 742 |
+
show_img=show_img,
|
| 743 |
+
)
|
| 744 |
+
_append_heatmap_artifact(
|
| 745 |
+
artifacts,
|
| 746 |
+
kind='diff',
|
| 747 |
+
feature_id=int(global_feature_id),
|
| 748 |
+
saved=saved_diff,
|
| 749 |
+
)
|
| 750 |
+
elif show_diff and has_qground_masks:
|
| 751 |
+
mask_name = f'{safe_prefix}feature_{global_feature_id}_mask.png'
|
| 752 |
+
mask_path = str(save_root / mask_name) if save_root is not None else None
|
| 753 |
+
saved_mask = _plot_qground_mask_grid(
|
| 754 |
+
mask_paths,
|
| 755 |
+
global_feature_id,
|
| 756 |
+
image_names,
|
| 757 |
+
meta_rows=meta_rows,
|
| 758 |
+
img_size_inches=img_size_inches,
|
| 759 |
+
crop_size=crop_size,
|
| 760 |
+
save_path=mask_path,
|
| 761 |
+
show_img=show_img,
|
| 762 |
+
)
|
| 763 |
+
_append_heatmap_artifact(
|
| 764 |
+
artifacts,
|
| 765 |
+
kind='mask',
|
| 766 |
+
feature_id=int(global_feature_id),
|
| 767 |
+
saved=saved_mask,
|
| 768 |
+
)
|
| 769 |
+
|
| 770 |
+
return artifacts
|
analysis/viz/vis_metrics.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generalized visualization for relationship metrics (correlation, MI, etc.)."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Optional, Tuple
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
from analysis.viz.vis_correlations import plot_metric_heatmap
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def plot_similarity_heatmap(
|
| 12 |
+
metric_df: pd.DataFrame,
|
| 13 |
+
metric_type: str = 'custom',
|
| 14 |
+
top_k: int = 30,
|
| 15 |
+
figsize: Optional[Tuple[int, int]] = None,
|
| 16 |
+
title: str = 'Feature-Relationship Heatmap',
|
| 17 |
+
metric_label: Optional[str] = None,
|
| 18 |
+
use_abs_ranking: Optional[bool] = None,
|
| 19 |
+
center: Optional[float] = None,
|
| 20 |
+
vmin: Optional[float] = None,
|
| 21 |
+
vmax: Optional[float] = None,
|
| 22 |
+
cmap: Optional[str] = None,
|
| 23 |
+
save_path: Optional[str] = None,
|
| 24 |
+
show: bool = True,
|
| 25 |
+
) -> Optional[str]:
|
| 26 |
+
"""
|
| 27 |
+
Generic heatmap visualization for feature-relationship metrics.
|
| 28 |
+
|
| 29 |
+
Adapts visualization parameters (colormap, center, vmin/vmax, ranking)
|
| 30 |
+
based on metric type, with ability to override for custom types.
|
| 31 |
+
|
| 32 |
+
Parameters
|
| 33 |
+
----------
|
| 34 |
+
metric_df : pd.DataFrame
|
| 35 |
+
DataFrame with shape (n_categories, n_features), values are metric scores.
|
| 36 |
+
metric_type : {'correlation', 'mutual_information', 'custom'}, default 'custom'
|
| 37 |
+
Type of metric, determines automatic parameter selection:
|
| 38 |
+
- 'correlation': uses RdBu_r colormap, center=0.0, vmin=-1, vmax=1, abs ranking
|
| 39 |
+
- 'mutual_information': uses viridis colormap, center=None, vmin=0, vmax=max, regular ranking
|
| 40 |
+
- 'custom': uses explicit parameters (use_abs_ranking, center, vmin, vmax, cmap)
|
| 41 |
+
top_k : int, default 30
|
| 42 |
+
Number of top features to display (selected by importance rank).
|
| 43 |
+
figsize : tuple, optional
|
| 44 |
+
Figure size (width, height). If None, computed automatically.
|
| 45 |
+
title : str, default 'Feature-Relationship Heatmap'
|
| 46 |
+
Plot title.
|
| 47 |
+
metric_label : str, optional
|
| 48 |
+
Colorbar label. If None, defaults based on metric_type.
|
| 49 |
+
use_abs_ranking : bool, optional
|
| 50 |
+
If True, ranks features by absolute max value. If None, defaults based on metric_type.
|
| 51 |
+
center : float, optional
|
| 52 |
+
Value to center colormap symmetrically (for diverging maps like RdBu_r).
|
| 53 |
+
If None, colormap is not centered.
|
| 54 |
+
vmin : float, optional
|
| 55 |
+
Minimum value for colormap scale. If None, defaults based on metric_type.
|
| 56 |
+
vmax : float, optional
|
| 57 |
+
Maximum value for colormap scale. If None, defaults based on metric_type.
|
| 58 |
+
cmap : str, optional
|
| 59 |
+
Matplotlib colormap name. If None, defaults based on metric_type.
|
| 60 |
+
save_path : str, optional
|
| 61 |
+
If provided, save figure to this path.
|
| 62 |
+
show : bool, default True
|
| 63 |
+
If True, display the figure in the notebook/interface.
|
| 64 |
+
|
| 65 |
+
Returns
|
| 66 |
+
-------
|
| 67 |
+
str or None
|
| 68 |
+
Path to saved figure if save_path was provided, otherwise None.
|
| 69 |
+
|
| 70 |
+
Examples
|
| 71 |
+
--------
|
| 72 |
+
>>> # Correlation heatmap (auto-configured)
|
| 73 |
+
>>> plot_similarity_heatmap(corr_df, metric_type='correlation', top_k=30, title='Correlations')
|
| 74 |
+
>>>
|
| 75 |
+
>>> # Mutual Information heatmap (auto-configured)
|
| 76 |
+
>>> plot_similarity_heatmap(mi_df, metric_type='mutual_information', top_k=30, title='MI')
|
| 77 |
+
>>>
|
| 78 |
+
>>> # Custom metric with explicit colormap parameters
|
| 79 |
+
>>> plot_similarity_heatmap(
|
| 80 |
+
... custom_metric_df,
|
| 81 |
+
... metric_type='custom',
|
| 82 |
+
... cmap='coolwarm',
|
| 83 |
+
... center=0.5,
|
| 84 |
+
... vmin=0,
|
| 85 |
+
... vmax=1,
|
| 86 |
+
... use_abs_ranking=False
|
| 87 |
+
... )
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
# Auto-configure parameters based on metric_type
|
| 91 |
+
if metric_type == 'correlation':
|
| 92 |
+
if metric_label is None:
|
| 93 |
+
metric_label = 'Pearson r'
|
| 94 |
+
if use_abs_ranking is None:
|
| 95 |
+
use_abs_ranking = True
|
| 96 |
+
if center is None:
|
| 97 |
+
center = 0.0
|
| 98 |
+
if vmin is None:
|
| 99 |
+
vmin = -1.0
|
| 100 |
+
if vmax is None:
|
| 101 |
+
vmax = 1.0
|
| 102 |
+
if cmap is None:
|
| 103 |
+
cmap = 'RdBu_r'
|
| 104 |
+
|
| 105 |
+
elif metric_type == 'mutual_information':
|
| 106 |
+
if metric_label is None:
|
| 107 |
+
metric_label = 'Mutual Information (nats)'
|
| 108 |
+
if use_abs_ranking is None:
|
| 109 |
+
use_abs_ranking = False
|
| 110 |
+
if center is None:
|
| 111 |
+
center = None
|
| 112 |
+
if vmin is None:
|
| 113 |
+
vmin = 0.0
|
| 114 |
+
if vmax is None:
|
| 115 |
+
vmax = metric_df.max().max()
|
| 116 |
+
if cmap is None:
|
| 117 |
+
cmap = 'viridis'
|
| 118 |
+
|
| 119 |
+
elif metric_type == 'custom':
|
| 120 |
+
# For custom type, use explicit parameters or raise if required ones missing
|
| 121 |
+
if metric_label is None:
|
| 122 |
+
metric_label = 'Metric value'
|
| 123 |
+
if use_abs_ranking is None:
|
| 124 |
+
use_abs_ranking = True
|
| 125 |
+
if cmap is None:
|
| 126 |
+
cmap = 'viridis'
|
| 127 |
+
# center, vmin, vmax can remain None for auto-scaling
|
| 128 |
+
|
| 129 |
+
else:
|
| 130 |
+
available = "'correlation', 'mutual_information', 'custom'"
|
| 131 |
+
raise ValueError(f"Unknown metric_type: {metric_type!r}. Available: {available}")
|
| 132 |
+
|
| 133 |
+
# Delegate to the core plotting function
|
| 134 |
+
return plot_metric_heatmap(
|
| 135 |
+
metric_df=metric_df,
|
| 136 |
+
top_k=top_k,
|
| 137 |
+
figsize=figsize,
|
| 138 |
+
title=title,
|
| 139 |
+
metric_label=metric_label,
|
| 140 |
+
use_abs_ranking=use_abs_ranking,
|
| 141 |
+
center=center,
|
| 142 |
+
vmin=vmin,
|
| 143 |
+
vmax=vmax,
|
| 144 |
+
cmap=cmap,
|
| 145 |
+
save_path=save_path,
|
| 146 |
+
show=show,
|
| 147 |
+
)
|
analysis/viz/vis_scatter.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scatter-plot активаций SAE-признаков, часть функций тоже из PatchSAE:
|
| 3 |
+
log(sparsity) × log(mean_acts), окрашенные по энтропии меток искажений.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from typing import Dict, List, Optional, Tuple
|
| 9 |
+
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
import scipy.sparse as sp
|
| 14 |
+
import torch
|
| 15 |
+
import plotly.express as px
|
| 16 |
+
|
| 17 |
+
from analysis.features.feature_indexing import FeatureMatrix
|
| 18 |
+
from analysis.metrics.iou_utils import compute_iou_per_feature
|
| 19 |
+
from analysis.metrics.precision_recall import compute_distortion_precision, compute_distortion_recall
|
| 20 |
+
from analysis.metrics.roc_auc import compute_distortion_roc_auc
|
| 21 |
+
|
| 22 |
+
def calculate_entropy(
|
| 23 |
+
top_val: torch.Tensor,
|
| 24 |
+
top_label: torch.Tensor,
|
| 25 |
+
ignore_label_idx: Optional[int] = None,
|
| 26 |
+
eps: float = 1e-9,
|
| 27 |
+
) -> torch.Tensor:
|
| 28 |
+
dict_size = top_label.shape[0]
|
| 29 |
+
entropy = torch.zeros(dict_size)
|
| 30 |
+
|
| 31 |
+
for i in range(dict_size):
|
| 32 |
+
unique_labels, counts = top_label[i].unique(return_counts=True)
|
| 33 |
+
if ignore_label_idx is not None:
|
| 34 |
+
mask = unique_labels != ignore_label_idx
|
| 35 |
+
counts = counts[mask]
|
| 36 |
+
unique_labels = unique_labels[mask]
|
| 37 |
+
|
| 38 |
+
if len(unique_labels) == 0 or counts.sum().item() < 10:
|
| 39 |
+
entropy[i] = -1
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
summed_probs = torch.zeros(len(unique_labels), dtype=top_val.dtype)
|
| 43 |
+
for j, label in enumerate(unique_labels):
|
| 44 |
+
summed_probs[j] = top_val[i][top_label[i] == label].sum().item()
|
| 45 |
+
|
| 46 |
+
summed_probs = summed_probs / summed_probs.sum()
|
| 47 |
+
entropy[i] = -torch.sum(summed_probs * torch.log(summed_probs + eps))
|
| 48 |
+
|
| 49 |
+
return entropy
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_top_k_patches(
|
| 53 |
+
codes: sp.csr_matrix,
|
| 54 |
+
meta: pd.DataFrame,
|
| 55 |
+
top_k: Optional[int] = 50,
|
| 56 |
+
label_col: str = 'dist_group',
|
| 57 |
+
) -> Tuple[torch.Tensor, torch.Tensor, List[str]]:
|
| 58 |
+
"""
|
| 59 |
+
Для каждого признака SAE выбирает top-K патчей с наибольшими активациями.
|
| 60 |
+
|
| 61 |
+
Параметры
|
| 62 |
+
----------
|
| 63 |
+
codes : CSR матрица (n_patches, n_features)
|
| 64 |
+
meta : DataFrame с колонкой label_col
|
| 65 |
+
top_k : число патчей; None — все ненулевые (с паддингом до max nnz)
|
| 66 |
+
label_col : 'dist_group' или 'dist_type'
|
| 67 |
+
|
| 68 |
+
Возвращает
|
| 69 |
+
----------
|
| 70 |
+
top_val : FloatTensor (n_features, effective_k)
|
| 71 |
+
top_label : LongTensor (n_features, effective_k)
|
| 72 |
+
label_names : List[str] — расшифровка числовых меток
|
| 73 |
+
"""
|
| 74 |
+
n_patches, n_features = codes.shape
|
| 75 |
+
|
| 76 |
+
raw_labels = meta[label_col].values
|
| 77 |
+
label_names, label_int = np.unique(raw_labels, return_inverse=True)
|
| 78 |
+
label_int = torch.from_numpy(label_int.astype(np.int64))
|
| 79 |
+
|
| 80 |
+
codes_csc = codes.tocsc()
|
| 81 |
+
|
| 82 |
+
if top_k is None:
|
| 83 |
+
nnz_per_feat = np.diff(codes_csc.indptr)
|
| 84 |
+
effective_k = int(nnz_per_feat.max()) if codes_csc.nnz > 0 else 1
|
| 85 |
+
else:
|
| 86 |
+
effective_k = min(top_k, n_patches)
|
| 87 |
+
|
| 88 |
+
top_val = torch.zeros(n_features, effective_k, dtype=torch.float32)
|
| 89 |
+
top_label = torch.zeros(n_features, effective_k, dtype=torch.long)
|
| 90 |
+
|
| 91 |
+
for feat_idx in range(n_features):
|
| 92 |
+
col = codes_csc.getcol(feat_idx)
|
| 93 |
+
col_data = col.data
|
| 94 |
+
col_rows = col.indices
|
| 95 |
+
|
| 96 |
+
if len(col_data) == 0:
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
if top_k is None:
|
| 100 |
+
k = len(col_data)
|
| 101 |
+
order = np.argsort(col_data)[::-1]
|
| 102 |
+
else:
|
| 103 |
+
k = min(effective_k, len(col_data))
|
| 104 |
+
order = np.argpartition(col_data, -k)[-k:]
|
| 105 |
+
order = order[np.argsort(col_data[order])[::-1]]
|
| 106 |
+
|
| 107 |
+
top_val[feat_idx, :k] = torch.from_numpy(col_data[order[:k]].astype(np.float32))
|
| 108 |
+
top_label[feat_idx, :k] = label_int[col_rows[order[:k]]]
|
| 109 |
+
|
| 110 |
+
return top_val, top_label, list(label_names)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def prepare_scatter_stats(
|
| 114 |
+
codes: sp.csr_matrix,
|
| 115 |
+
meta: pd.DataFrame,
|
| 116 |
+
top_k: Optional[int] = 50,
|
| 117 |
+
label_col: str = 'dist_group',
|
| 118 |
+
group_filter: Optional[str] = None,
|
| 119 |
+
) -> Tuple[Dict[str, torch.Tensor], List[str]]:
|
| 120 |
+
"""
|
| 121 |
+
Собирает словарь статистик, совместимый с get_stats_scatter_plot.
|
| 122 |
+
|
| 123 |
+
Параметры
|
| 124 |
+
----------
|
| 125 |
+
codes : CSR матрица (n_patches, n_features)
|
| 126 |
+
meta : DataFrame с метаданными
|
| 127 |
+
top_k : число top-патчей для расчёта энтропии
|
| 128 |
+
label_col : 'dist_group' или 'dist_type'
|
| 129 |
+
group_filter : если задана — фильтрует строки по dist_group == group_filter
|
| 130 |
+
|
| 131 |
+
Возвращает
|
| 132 |
+
----------
|
| 133 |
+
stats : dict с ключами 'sparsity', 'mean_acts', 'top_entropy' — FloatTensors
|
| 134 |
+
label_names : List[str]
|
| 135 |
+
"""
|
| 136 |
+
if group_filter is not None:
|
| 137 |
+
row_mask = (meta['dist_group'] == group_filter).values
|
| 138 |
+
codes = codes[row_mask]
|
| 139 |
+
meta = meta[row_mask].reset_index(drop=True)
|
| 140 |
+
|
| 141 |
+
codes_f32 = codes.astype(np.float32)
|
| 142 |
+
sparsity = torch.from_numpy(np.asarray((codes_f32 > 0).mean(axis=0)).ravel())
|
| 143 |
+
mean_acts = torch.from_numpy(np.asarray(codes_f32.mean(axis=0)).ravel())
|
| 144 |
+
|
| 145 |
+
top_val, top_label, label_names = get_top_k_patches(codes, meta, top_k=top_k, label_col=label_col)
|
| 146 |
+
top_entropy = calculate_entropy(top_val, top_label)
|
| 147 |
+
|
| 148 |
+
stats = {
|
| 149 |
+
'sparsity': sparsity.float(),
|
| 150 |
+
'mean_acts': mean_acts.float(),
|
| 151 |
+
'top_entropy': top_entropy.float(),
|
| 152 |
+
}
|
| 153 |
+
return stats, label_names
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _metric_df_to_feature_vector(
|
| 157 |
+
metric_df: pd.DataFrame,
|
| 158 |
+
features: FeatureMatrix,
|
| 159 |
+
category_aggregation: str = 'max',
|
| 160 |
+
) -> np.ndarray:
|
| 161 |
+
n_features = features.n_features
|
| 162 |
+
metric_values = np.full((n_features,), np.nan, dtype=np.float32)
|
| 163 |
+
if metric_df.empty:
|
| 164 |
+
return metric_values
|
| 165 |
+
category_aggregation = str(category_aggregation).strip().lower()
|
| 166 |
+
if category_aggregation not in {'max', 'mean'}:
|
| 167 |
+
raise ValueError(f"Unsupported category aggregation: {category_aggregation!r}. Allowed: ('max', 'mean')")
|
| 168 |
+
global_to_col = {int(gid): col for col, gid in enumerate(features.column_feature_ids)}
|
| 169 |
+
numeric_df = metric_df.apply(pd.to_numeric, errors='coerce')
|
| 170 |
+
for column in numeric_df.columns:
|
| 171 |
+
try:
|
| 172 |
+
global_id = int(column)
|
| 173 |
+
except (TypeError, ValueError):
|
| 174 |
+
continue
|
| 175 |
+
col = global_to_col.get(global_id)
|
| 176 |
+
if col is None:
|
| 177 |
+
continue
|
| 178 |
+
column_values = numeric_df[column].to_numpy(dtype=np.float32)
|
| 179 |
+
finite_values = column_values[np.isfinite(column_values)]
|
| 180 |
+
if finite_values.size == 0:
|
| 181 |
+
continue
|
| 182 |
+
if category_aggregation == 'max':
|
| 183 |
+
aggregated_value = float(np.max(finite_values))
|
| 184 |
+
else:
|
| 185 |
+
aggregated_value = float(np.mean(finite_values))
|
| 186 |
+
metric_values[col] = aggregated_value
|
| 187 |
+
return metric_values
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _compute_color_metric_values(color_metric: str,
|
| 191 |
+
features: FeatureMatrix,
|
| 192 |
+
meta: pd.DataFrame,
|
| 193 |
+
label_col: str,
|
| 194 |
+
stats: Dict[str, torch.Tensor],
|
| 195 |
+
category_aggregation: str = 'max',
|
| 196 |
+
activation_threshold: float = 0.0,
|
| 197 |
+
cache_path: Optional[str] = None,
|
| 198 |
+
*,
|
| 199 |
+
dataset: str) -> torch.Tensor:
|
| 200 |
+
'''
|
| 201 |
+
Вычисляет вектор значений метрики для окрашивания точек на scatter plot.
|
| 202 |
+
Поддерживает 'entropy', 'roc_auc', 'iou', 'precision' и 'recall'.
|
| 203 |
+
Для табличных метрик возвращает вектор длины n_features,
|
| 204 |
+
агрегируя значения по категориям из label_col.
|
| 205 |
+
'''
|
| 206 |
+
color_metric = str(color_metric).strip().lower()
|
| 207 |
+
if color_metric == 'entropy':
|
| 208 |
+
return stats['top_entropy']
|
| 209 |
+
if color_metric == 'roc_auc':
|
| 210 |
+
auc_df = compute_distortion_roc_auc(
|
| 211 |
+
meta=meta,
|
| 212 |
+
features=features,
|
| 213 |
+
group_col=label_col,
|
| 214 |
+
level='patch',
|
| 215 |
+
cache_path=cache_path,
|
| 216 |
+
)
|
| 217 |
+
metric_values = _metric_df_to_feature_vector(
|
| 218 |
+
metric_df=auc_df,
|
| 219 |
+
features=features,
|
| 220 |
+
category_aggregation=category_aggregation,
|
| 221 |
+
)
|
| 222 |
+
return torch.from_numpy(metric_values).float()
|
| 223 |
+
if color_metric == 'iou':
|
| 224 |
+
spatial_shape = 1, meta.groupby('image_idx').size().max()
|
| 225 |
+
iou_df = compute_iou_per_feature(
|
| 226 |
+
features=features,
|
| 227 |
+
meta_df=meta,
|
| 228 |
+
spatial_shape=spatial_shape,
|
| 229 |
+
group_col=label_col,
|
| 230 |
+
cache_path=cache_path,
|
| 231 |
+
dataset=dataset,
|
| 232 |
+
)
|
| 233 |
+
metric_values = _metric_df_to_feature_vector(
|
| 234 |
+
metric_df=iou_df,
|
| 235 |
+
features=features,
|
| 236 |
+
category_aggregation=category_aggregation,
|
| 237 |
+
)
|
| 238 |
+
return torch.from_numpy(metric_values).float()
|
| 239 |
+
if color_metric == 'precision':
|
| 240 |
+
precision_df = compute_distortion_precision(
|
| 241 |
+
meta=meta,
|
| 242 |
+
features=features,
|
| 243 |
+
group_col=label_col,
|
| 244 |
+
level='patch',
|
| 245 |
+
activation_threshold=activation_threshold,
|
| 246 |
+
cache_path=cache_path,
|
| 247 |
+
)
|
| 248 |
+
metric_values = _metric_df_to_feature_vector(
|
| 249 |
+
metric_df=precision_df,
|
| 250 |
+
features=features,
|
| 251 |
+
category_aggregation=category_aggregation,
|
| 252 |
+
)
|
| 253 |
+
return torch.from_numpy(metric_values).float()
|
| 254 |
+
if color_metric == 'recall':
|
| 255 |
+
recall_df = compute_distortion_recall(
|
| 256 |
+
meta=meta,
|
| 257 |
+
features=features,
|
| 258 |
+
group_col=label_col,
|
| 259 |
+
level='patch',
|
| 260 |
+
activation_threshold=activation_threshold,
|
| 261 |
+
cache_path=cache_path,
|
| 262 |
+
)
|
| 263 |
+
metric_values = _metric_df_to_feature_vector(
|
| 264 |
+
metric_df=recall_df,
|
| 265 |
+
features=features,
|
| 266 |
+
category_aggregation=category_aggregation,
|
| 267 |
+
)
|
| 268 |
+
return torch.from_numpy(metric_values).float()
|
| 269 |
+
raise ValueError(
|
| 270 |
+
f"Unsupported scatter color metric: {color_metric!r}. Allowed: ('entropy', 'iou', 'roc_auc', 'precision', 'recall')"
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ---------------------------------------------------------------------------
|
| 275 |
+
# Scatter plot
|
| 276 |
+
# ---------------------------------------------------------------------------
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def get_stats_scatter_plot(stats: Dict[str, torch.Tensor], color_metric_values: Optional[torch.Tensor] = None, color_metric_label: str = 'entropy', mask: Optional[torch.Tensor] = None, backend: str = 'plotly', save_directory: Optional[str] = None, file_stem: str = 'scatter_plot', save_html: bool = False, show: bool = True, eps: float = 1e-9, title: Optional[str] = None) -> Dict[str, Optional[str]]:
|
| 280 |
+
if color_metric_values is None:
|
| 281 |
+
color_metric_values = stats['top_entropy']
|
| 282 |
+
if color_metric_values.shape != stats['sparsity'].shape:
|
| 283 |
+
raise ValueError('color_metric_values must match stats feature dimension: {} vs {}'.format(tuple(color_metric_values.shape), tuple(stats['sparsity'].shape)))
|
| 284 |
+
if mask is None:
|
| 285 |
+
mask = torch.ones_like(stats['sparsity'], dtype=torch.bool)
|
| 286 |
+
indices = torch.where(mask)[0]
|
| 287 |
+
plotting_data = torch.stack([torch.log10(stats['sparsity'][mask] + eps), torch.log10(stats['mean_acts'][mask] + eps), color_metric_values[mask], indices.float()], dim=0).T
|
| 288 |
+
x_label = 'log10(sparsity)'
|
| 289 |
+
y_label = 'log10(mean_acts)'
|
| 290 |
+
color_label = str(color_metric_label)
|
| 291 |
+
hover_label = 'index'
|
| 292 |
+
df = pd.DataFrame(plotting_data.numpy(), columns=[x_label, y_label, color_label, hover_label])
|
| 293 |
+
backend = str(backend).lower().strip()
|
| 294 |
+
if backend not in {'plotly', 'matplotlib'}:
|
| 295 |
+
raise ValueError(f"backend must be 'plotly' or 'matplotlib', got {backend!r}")
|
| 296 |
+
saved_png: Optional[str] = None
|
| 297 |
+
saved_html: Optional[str] = None
|
| 298 |
+
if backend == 'plotly':
|
| 299 |
+
fig = px.scatter(df, x=x_label, y=y_label, color=color_label, marginal_x='histogram', marginal_y='histogram', opacity=0.5, hover_data=[hover_label])
|
| 300 |
+
if title is not None:
|
| 301 |
+
fig.update_layout(title=title)
|
| 302 |
+
if save_directory is not None:
|
| 303 |
+
save_dir = Path(save_directory)
|
| 304 |
+
save_dir.mkdir(parents=True, exist_ok=True)
|
| 305 |
+
png_path = save_dir / f'{file_stem}.png'
|
| 306 |
+
try:
|
| 307 |
+
fig.write_image(png_path)
|
| 308 |
+
saved_png = str(png_path)
|
| 309 |
+
except Exception as exc:
|
| 310 |
+
print(f'[warn] Failed to save Plotly PNG to {png_path}: {exc}')
|
| 311 |
+
print('[warn] Install kaleido to enable Plotly PNG export.')
|
| 312 |
+
if save_html:
|
| 313 |
+
html_path = save_dir / f'{file_stem}.html'
|
| 314 |
+
fig.write_html(html_path, include_plotlyjs='cdn')
|
| 315 |
+
saved_html = str(html_path)
|
| 316 |
+
if show:
|
| 317 |
+
fig.show()
|
| 318 |
+
else:
|
| 319 |
+
fig, ax = plt.subplots(figsize=(10, 7))
|
| 320 |
+
scatter = ax.scatter(df[x_label], df[y_label], c=df[color_label], cmap='viridis', alpha=0.6, s=14, edgecolors='none')
|
| 321 |
+
ax.set_xlabel(x_label)
|
| 322 |
+
ax.set_ylabel(y_label)
|
| 323 |
+
ax.set_title(title or 'Scatter of SAE feature stats')
|
| 324 |
+
cbar = fig.colorbar(scatter, ax=ax)
|
| 325 |
+
cbar.set_label(color_label)
|
| 326 |
+
fig.tight_layout()
|
| 327 |
+
if save_directory is not None:
|
| 328 |
+
save_dir = Path(save_directory)
|
| 329 |
+
save_dir.mkdir(parents=True, exist_ok=True)
|
| 330 |
+
png_path = save_dir / f'{file_stem}.png'
|
| 331 |
+
fig.savefig(png_path, dpi=200, bbox_inches='tight')
|
| 332 |
+
saved_png = str(png_path)
|
| 333 |
+
if save_html:
|
| 334 |
+
print('[warn] save_html=True ignored for matplotlib backend.')
|
| 335 |
+
if show:
|
| 336 |
+
plt.show()
|
| 337 |
+
else:
|
| 338 |
+
plt.close(fig)
|
| 339 |
+
return {'png_path': saved_png, 'html_path': saved_html}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def scatter_plot_by_distortion(features: FeatureMatrix,
|
| 343 |
+
meta: pd.DataFrame,
|
| 344 |
+
mode: str = 'group',
|
| 345 |
+
group_name: Optional[str] = None,
|
| 346 |
+
top_k: Optional[int] = 50,
|
| 347 |
+
color_metric: str = 'entropy',
|
| 348 |
+
per_category: bool = True,
|
| 349 |
+
activation_threshold: float = 0.0,
|
| 350 |
+
backend: str = 'plotly',
|
| 351 |
+
save_directory: Optional[str] = None,
|
| 352 |
+
file_stem: Optional[str] = None,
|
| 353 |
+
save_html: bool = False,
|
| 354 |
+
show: bool = True,
|
| 355 |
+
cache_path: Optional[str] = None,
|
| 356 |
+
*,
|
| 357 |
+
dataset: str,
|
| 358 |
+
title: Optional[str] = None) -> Dict[str, Optional[str]]:
|
| 359 |
+
if mode == 'group':
|
| 360 |
+
label_col, group_filter = 'dist_group', None
|
| 361 |
+
title_suffix = 'by distortion group'
|
| 362 |
+
elif mode == 'type_in_group':
|
| 363 |
+
if group_name is None:
|
| 364 |
+
raise ValueError("mode='type_in_group' requires group_name")
|
| 365 |
+
label_col, group_filter = 'dist_type', group_name
|
| 366 |
+
title_suffix = f'by distortion type within "{group_name}"'
|
| 367 |
+
else:
|
| 368 |
+
raise ValueError(f"mode must be 'group' or 'type_in_group', got {mode!r}")
|
| 369 |
+
|
| 370 |
+
print(f'Preparing stats ({title_suffix})...')
|
| 371 |
+
codes = features.codes
|
| 372 |
+
stats, label_names = prepare_scatter_stats(codes, meta, top_k=top_k, label_col=label_col, group_filter=group_filter)
|
| 373 |
+
|
| 374 |
+
if group_filter is None:
|
| 375 |
+
features_slice = features
|
| 376 |
+
meta_slice = meta
|
| 377 |
+
else:
|
| 378 |
+
row_mask = (meta['dist_group'] == group_filter).values
|
| 379 |
+
features_slice = features.with_row_mask(row_mask)
|
| 380 |
+
meta_slice = meta[row_mask].reset_index(drop=True)
|
| 381 |
+
|
| 382 |
+
print(f' Labels ({len(label_names)}): {label_names}')
|
| 383 |
+
print(f' Features: {stats["sparsity"].shape[0]}')
|
| 384 |
+
print(f' Color metric: {str(color_metric).lower()} (per_category={per_category})')
|
| 385 |
+
|
| 386 |
+
if file_stem is None:
|
| 387 |
+
if mode == 'type_in_group' and group_name is not None:
|
| 388 |
+
normalized_group = group_name.lower().replace(' ', '_')
|
| 389 |
+
file_stem = f'scatter_{mode}_{normalized_group}'
|
| 390 |
+
else:
|
| 391 |
+
file_stem = f'scatter_{mode}'
|
| 392 |
+
|
| 393 |
+
plot_title = title
|
| 394 |
+
if plot_title is None:
|
| 395 |
+
plot_title = f'Scatter of SAE feature stats ({title_suffix})'
|
| 396 |
+
|
| 397 |
+
metric_name = str(color_metric).strip().lower()
|
| 398 |
+
|
| 399 |
+
# Per-category plotting for table metrics
|
| 400 |
+
global_to_col = {
|
| 401 |
+
int(gid): col for col, gid in enumerate(features.column_feature_ids)
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
if per_category and metric_name in {'roc_auc', 'iou', 'precision', 'recall'}:
|
| 405 |
+
if metric_name == 'roc_auc':
|
| 406 |
+
metric_df = compute_distortion_roc_auc(
|
| 407 |
+
meta=meta_slice,
|
| 408 |
+
features=features_slice,
|
| 409 |
+
group_col=label_col,
|
| 410 |
+
level='patch',
|
| 411 |
+
cache_path=cache_path,
|
| 412 |
+
)
|
| 413 |
+
elif metric_name == 'iou':
|
| 414 |
+
spatial_shape = 1, meta_slice.groupby('image_idx').size().max()
|
| 415 |
+
metric_df = compute_iou_per_feature(
|
| 416 |
+
features=features_slice,
|
| 417 |
+
meta_df=meta_slice,
|
| 418 |
+
spatial_shape=spatial_shape,
|
| 419 |
+
group_col=label_col,
|
| 420 |
+
cache_path=cache_path,
|
| 421 |
+
dataset=dataset,
|
| 422 |
+
)
|
| 423 |
+
elif metric_name == 'precision':
|
| 424 |
+
metric_df = compute_distortion_precision(
|
| 425 |
+
meta=meta_slice,
|
| 426 |
+
features=features_slice,
|
| 427 |
+
group_col=label_col,
|
| 428 |
+
level='patch',
|
| 429 |
+
activation_threshold=activation_threshold,
|
| 430 |
+
cache_path=cache_path,
|
| 431 |
+
)
|
| 432 |
+
else:
|
| 433 |
+
metric_df = compute_distortion_recall(
|
| 434 |
+
meta=meta_slice,
|
| 435 |
+
features=features_slice,
|
| 436 |
+
group_col=label_col,
|
| 437 |
+
level='patch',
|
| 438 |
+
activation_threshold=activation_threshold,
|
| 439 |
+
cache_path=cache_path,
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
results = {'png_path': None, 'html_path': None}
|
| 443 |
+
n_cols = features.n_features
|
| 444 |
+
for category in metric_df.index.tolist():
|
| 445 |
+
row = metric_df.loc[category]
|
| 446 |
+
metric_values = np.full((n_cols,), np.nan, dtype=np.float32)
|
| 447 |
+
for col_name in metric_df.columns:
|
| 448 |
+
try:
|
| 449 |
+
global_id = int(col_name)
|
| 450 |
+
except Exception:
|
| 451 |
+
continue
|
| 452 |
+
matrix_col = global_to_col.get(global_id)
|
| 453 |
+
if matrix_col is None:
|
| 454 |
+
continue
|
| 455 |
+
val = row[col_name]
|
| 456 |
+
metric_values[matrix_col] = np.nan if pd.isna(val) else float(val)
|
| 457 |
+
|
| 458 |
+
color_tensor = torch.from_numpy(metric_values).float()
|
| 459 |
+
category_label = str(category)
|
| 460 |
+
cat_file_stem = f"{file_stem}_{category_label}" if file_stem is not None else f"scatter_{metric_name}_{category_label}"
|
| 461 |
+
|
| 462 |
+
out = get_stats_scatter_plot(stats, color_metric_values=color_tensor, color_metric_label=f"{metric_name}:{category_label}", mask=None, backend=backend, save_directory=save_directory, file_stem=cat_file_stem, save_html=save_html, show=show, title=f'{plot_title} | {category_label}')
|
| 463 |
+
if out.get('png_path'):
|
| 464 |
+
results['png_path'] = out['png_path']
|
| 465 |
+
if out.get('html_path'):
|
| 466 |
+
results['html_path'] = out['html_path']
|
| 467 |
+
|
| 468 |
+
return results
|
| 469 |
+
|
| 470 |
+
# Default single-color-metric path
|
| 471 |
+
color_metric_values = _compute_color_metric_values(
|
| 472 |
+
color_metric=color_metric,
|
| 473 |
+
features=features_slice,
|
| 474 |
+
meta=meta_slice,
|
| 475 |
+
label_col=label_col,
|
| 476 |
+
stats=stats,
|
| 477 |
+
activation_threshold=activation_threshold,
|
| 478 |
+
cache_path=cache_path,
|
| 479 |
+
dataset=dataset,
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
category_label = 'all' if metric_name == 'entropy' else label_col
|
| 483 |
+
return get_stats_scatter_plot(stats, color_metric_values=color_metric_values, color_metric_label=str(color_metric).lower(), mask=None, backend=backend, save_directory=save_directory, file_stem=file_stem, save_html=save_html, show=show, title=f'{plot_title} | {category_label}')
|
| 484 |
+
|
assets/examples
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/data/examples
|
assets/style.css
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #f6f8fb;
|
| 3 |
+
--bg-soft: rgba(245, 247, 250, 0.9);
|
| 4 |
+
--panel: #ffffff;
|
| 5 |
+
--panel-strong: #f8fafc;
|
| 6 |
+
--border: rgba(15, 23, 42, 0.06);
|
| 7 |
+
--text: #0b1220;
|
| 8 |
+
--text-soft: #4b5563;
|
| 9 |
+
--accent: #2563eb;
|
| 10 |
+
--accent-strong: #1e40af;
|
| 11 |
+
--shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
html,
|
| 15 |
+
body {
|
| 16 |
+
margin: 0;
|
| 17 |
+
min-height: 100%;
|
| 18 |
+
background: linear-gradient(180deg, var(--bg) 0%, #ffffff 100%);
|
| 19 |
+
color: var(--text);
|
| 20 |
+
font-family: "Inter", "DM Sans", "Segoe UI", sans-serif;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.app-shell {
|
| 24 |
+
position: relative;
|
| 25 |
+
max-width: 1480px;
|
| 26 |
+
margin: 0 auto;
|
| 27 |
+
padding: 40px 24px 56px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.page-back-nav {
|
| 31 |
+
position: absolute;
|
| 32 |
+
top: 12px;
|
| 33 |
+
left: 24px;
|
| 34 |
+
z-index: 30;
|
| 35 |
+
margin: 0;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.back-link {
|
| 39 |
+
display: inline-flex;
|
| 40 |
+
align-items: center;
|
| 41 |
+
padding: 8px 14px;
|
| 42 |
+
font-size: 0.9rem;
|
| 43 |
+
font-weight: 500;
|
| 44 |
+
color: var(--text-soft);
|
| 45 |
+
text-decoration: none;
|
| 46 |
+
background: var(--panel);
|
| 47 |
+
border: 1px solid var(--border);
|
| 48 |
+
border-radius: 999px;
|
| 49 |
+
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
| 50 |
+
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.back-link:hover {
|
| 54 |
+
color: var(--accent);
|
| 55 |
+
border-color: rgba(37, 99, 235, 0.28);
|
| 56 |
+
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.1);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.hero-card,
|
| 60 |
+
.form-card,
|
| 61 |
+
.mock-card,
|
| 62 |
+
.placeholder-card {
|
| 63 |
+
background: var(--panel);
|
| 64 |
+
border: 1px solid var(--border);
|
| 65 |
+
box-shadow: var(--shadow);
|
| 66 |
+
border-radius: 14px;
|
| 67 |
+
white-space: pre-wrap;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.hero-card {
|
| 71 |
+
padding: 28px 30px 24px;
|
| 72 |
+
margin-bottom: 22px;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.page-back-nav + .hero-card {
|
| 76 |
+
padding-top: 48px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.app-kicker {
|
| 80 |
+
font-size: 0.78rem;
|
| 81 |
+
text-transform: uppercase;
|
| 82 |
+
letter-spacing: 0.14em;
|
| 83 |
+
color: var(--accent);
|
| 84 |
+
margin-bottom: 10px;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.app-title {
|
| 88 |
+
margin: 0;
|
| 89 |
+
font-size: clamp(2rem, 4vw, 3.45rem);
|
| 90 |
+
line-height: 0.98;
|
| 91 |
+
letter-spacing: -0.04em;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.home-grid,
|
| 95 |
+
.mock-grid {
|
| 96 |
+
display: grid;
|
| 97 |
+
gap: 18px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.home-grid {
|
| 101 |
+
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr);
|
| 102 |
+
align-items: start;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.mock-grid {
|
| 106 |
+
grid-template-columns: minmax(260px, 0.7fr) minmax(0, 1.3fr);
|
| 107 |
+
align-items: start;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.form-card,
|
| 111 |
+
.info-card,
|
| 112 |
+
.mock-card,
|
| 113 |
+
.placeholder-card {
|
| 114 |
+
padding: 24px;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.form-block {
|
| 118 |
+
margin-bottom: 18px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.section-label,
|
| 122 |
+
.panel-label,
|
| 123 |
+
.preview-label {
|
| 124 |
+
display: block;
|
| 125 |
+
margin-bottom: 10px;
|
| 126 |
+
color: var(--text-soft);
|
| 127 |
+
font-size: 0.78rem;
|
| 128 |
+
text-transform: uppercase;
|
| 129 |
+
letter-spacing: 0.16em;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.metric-switch {
|
| 133 |
+
display: flex;
|
| 134 |
+
gap: 10px;
|
| 135 |
+
flex-wrap: wrap;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.metric-pill {
|
| 139 |
+
display: inline-flex;
|
| 140 |
+
align-items: center;
|
| 141 |
+
justify-content: center;
|
| 142 |
+
min-width: 110px;
|
| 143 |
+
padding: 10px 14px;
|
| 144 |
+
border-radius: 999px;
|
| 145 |
+
border: 1px solid rgba(37, 99, 235, 0.12);
|
| 146 |
+
background: rgba(37, 99, 235, 0.06);
|
| 147 |
+
color: var(--text);
|
| 148 |
+
cursor: pointer;
|
| 149 |
+
transition: transform 120ms ease, border-color 120ms ease, color 120ms ease, background 120ms ease;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.metric-pill:hover {
|
| 153 |
+
transform: translateY(-1px);
|
| 154 |
+
border-color: rgba(125, 211, 252, 0.36);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.metric-descriptions {
|
| 158 |
+
margin-top: 14px;
|
| 159 |
+
display: flex;
|
| 160 |
+
flex-direction: column;
|
| 161 |
+
gap: 10px;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.metric-desc-block {
|
| 165 |
+
margin-top: 2px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.metric-desc-name {
|
| 169 |
+
font-weight: 600;
|
| 170 |
+
color: var(--text);
|
| 171 |
+
letter-spacing: 0.02em;
|
| 172 |
+
font-size: 0.9rem;
|
| 173 |
+
margin-bottom: 6px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.metric-desc-text {
|
| 177 |
+
margin: 0 0 8px;
|
| 178 |
+
font-size: 0.88rem;
|
| 179 |
+
line-height: 1.45;
|
| 180 |
+
color: var(--text-soft);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.metric-desc-text:last-child {
|
| 184 |
+
margin-bottom: 0;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.dash-dropdown .Select-control,
|
| 188 |
+
.dash-dropdown .Select-menu-outer,
|
| 189 |
+
.dash-dropdown .VirtualizedSelectOption,
|
| 190 |
+
.dash-dropdown .Select-value-label,
|
| 191 |
+
.dash-dropdown .Select-placeholder {
|
| 192 |
+
background-color: transparent !important;
|
| 193 |
+
color: var(--text) !important;
|
| 194 |
+
border-color: var(--border) !important;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.dash-dropdown .Select-control {
|
| 198 |
+
min-height: 58px;
|
| 199 |
+
border-radius: 16px !important;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.dash-dropdown .Select-input>input {
|
| 203 |
+
color: var(--text) !important;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.model-dropdown .Select-menu-outer {
|
| 207 |
+
padding: 8px 0;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/* Model option label classes (moved from inline styles in Python) */
|
| 211 |
+
.dash-dropdown .model-option {
|
| 212 |
+
padding: 0.15rem 0;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.dash-dropdown .model-option-exp {
|
| 216 |
+
font-size: 1.05rem;
|
| 217 |
+
font-weight: 300;
|
| 218 |
+
line-height: 1.1;
|
| 219 |
+
color: var(--text) !important;
|
| 220 |
+
letter-spacing: 0.01em;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.dash-dropdown .model-option-meta {
|
| 224 |
+
font-size: 0.78rem;
|
| 225 |
+
color: var(--text-soft) !important;
|
| 226 |
+
margin-top: 0.2rem;
|
| 227 |
+
line-height: 1.2;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.selection-preview {
|
| 231 |
+
margin: 16px 0 18px;
|
| 232 |
+
padding: 14px 16px;
|
| 233 |
+
border-radius: 12px;
|
| 234 |
+
background: var(--panel-strong);
|
| 235 |
+
border: 1px solid rgba(15, 23, 42, 0.04);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.preview-value {
|
| 239 |
+
white-space: pre-wrap;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.preview-value,
|
| 243 |
+
.home-status,
|
| 244 |
+
.mock-summary-meta,
|
| 245 |
+
.mock-placeholder {
|
| 246 |
+
color: var(--text-soft);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.preview-value {
|
| 250 |
+
font-size: 0.95rem;
|
| 251 |
+
line-height: 1.45;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.action-row {
|
| 255 |
+
display: flex;
|
| 256 |
+
align-items: center;
|
| 257 |
+
gap: 14px;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.confirm-button {
|
| 261 |
+
border: 0;
|
| 262 |
+
border-radius: 999px;
|
| 263 |
+
padding: 12px 20px;
|
| 264 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
| 265 |
+
color: #ffffff;
|
| 266 |
+
font-weight: 700;
|
| 267 |
+
cursor: pointer;
|
| 268 |
+
letter-spacing: 0.02em;
|
| 269 |
+
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.12);
|
| 270 |
+
text-decoration: none;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.confirm-button:hover,
|
| 274 |
+
.confirm-button:visited,
|
| 275 |
+
.confirm-button:focus {
|
| 276 |
+
color: #ffffff;
|
| 277 |
+
text-decoration: none;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.confirm-button:hover {
|
| 281 |
+
transform: translateY(-1px);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.confirm-button:disabled {
|
| 285 |
+
opacity: 0.65;
|
| 286 |
+
cursor: wait;
|
| 287 |
+
transform: none;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/* Upload area styled like a button */
|
| 291 |
+
.upload-card {
|
| 292 |
+
position: relative;
|
| 293 |
+
display: flex;
|
| 294 |
+
align-items: center;
|
| 295 |
+
justify-content: center;
|
| 296 |
+
gap: 10px;
|
| 297 |
+
padding: 18px 18px 14px;
|
| 298 |
+
border-radius: 12px;
|
| 299 |
+
border: 1px dashed var(--border);
|
| 300 |
+
background: var(--panel-strong);
|
| 301 |
+
cursor: pointer;
|
| 302 |
+
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.upload-card:hover {
|
| 306 |
+
transform: translateY(-2px);
|
| 307 |
+
border-color: rgba(37, 99, 235, 0.2);
|
| 308 |
+
background: rgba(37, 99, 235, 0.03);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.upload-card a {
|
| 312 |
+
display: inline-block;
|
| 313 |
+
padding: 8px 14px;
|
| 314 |
+
border-radius: 999px;
|
| 315 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
| 316 |
+
color: #ffffff;
|
| 317 |
+
font-weight: 700;
|
| 318 |
+
text-decoration: none;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.upload-card a:hover {
|
| 322 |
+
filter: brightness(0.95);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.upload-help {
|
| 326 |
+
position: absolute;
|
| 327 |
+
top: 10px;
|
| 328 |
+
right: 10px;
|
| 329 |
+
padding: 3px 8px;
|
| 330 |
+
border-radius: 999px;
|
| 331 |
+
display: inline-flex;
|
| 332 |
+
align-items: center;
|
| 333 |
+
justify-content: center;
|
| 334 |
+
background: rgba(37, 99, 235, 0.12);
|
| 335 |
+
color: var(--accent-strong);
|
| 336 |
+
font-size: 0.68rem;
|
| 337 |
+
font-weight: 800;
|
| 338 |
+
line-height: 1;
|
| 339 |
+
border: 1px solid rgba(37, 99, 235, 0.2);
|
| 340 |
+
cursor: help;
|
| 341 |
+
user-select: none;
|
| 342 |
+
white-space: nowrap;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.upload-help:hover::after {
|
| 346 |
+
content: attr(data-tooltip);
|
| 347 |
+
position: absolute;
|
| 348 |
+
top: calc(100% + 8px);
|
| 349 |
+
right: 0;
|
| 350 |
+
z-index: 2;
|
| 351 |
+
min-width: 220px;
|
| 352 |
+
max-width: 280px;
|
| 353 |
+
padding: 10px 12px;
|
| 354 |
+
border-radius: 12px;
|
| 355 |
+
background: rgba(15, 23, 42, 0.95);
|
| 356 |
+
color: #ffffff;
|
| 357 |
+
font-size: 0.82rem;
|
| 358 |
+
font-weight: 600;
|
| 359 |
+
line-height: 1.35;
|
| 360 |
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.2);
|
| 361 |
+
white-space: normal;
|
| 362 |
+
text-align: left;
|
| 363 |
+
pointer-events: none;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.upload-help:hover::before {
|
| 367 |
+
content: "";
|
| 368 |
+
position: absolute;
|
| 369 |
+
top: calc(100% + 2px);
|
| 370 |
+
right: 14px;
|
| 371 |
+
border: 6px solid transparent;
|
| 372 |
+
border-bottom-color: rgba(15, 23, 42, 0.95);
|
| 373 |
+
pointer-events: none;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
/* Ensure the feature overlay image is left-aligned rather than centered */
|
| 377 |
+
#fm-overlay-img {
|
| 378 |
+
display: block;
|
| 379 |
+
margin: 0 auto;
|
| 380 |
+
/* center horizontally */
|
| 381 |
+
width: auto;
|
| 382 |
+
max-width: 720px;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.home-nav-loading {
|
| 386 |
+
min-height: 52px;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.home-status.is-loading::before {
|
| 390 |
+
content: "Loading";
|
| 391 |
+
display: inline-block;
|
| 392 |
+
margin-right: 10px;
|
| 393 |
+
padding: 4px 10px;
|
| 394 |
+
border-radius: 999px;
|
| 395 |
+
background: rgba(37, 99, 235, 0.12);
|
| 396 |
+
color: var(--accent-strong);
|
| 397 |
+
font-size: 0.76rem;
|
| 398 |
+
font-weight: 700;
|
| 399 |
+
letter-spacing: 0.04em;
|
| 400 |
+
text-transform: uppercase;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.page-transition-loading {
|
| 404 |
+
position: relative;
|
| 405 |
+
min-height: 55vh;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.page-transition-loading>._dash-loading-callback {
|
| 409 |
+
display: flex !important;
|
| 410 |
+
align-items: center;
|
| 411 |
+
justify-content: center;
|
| 412 |
+
min-height: 55vh;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
.skeleton-line {
|
| 416 |
+
height: 0.9rem;
|
| 417 |
+
margin-bottom: 8px;
|
| 418 |
+
border-radius: 6px;
|
| 419 |
+
background: rgba(15, 23, 42, 0.07);
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.skeleton-line-short {
|
| 423 |
+
width: 55%;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.viz-model-info-loading {
|
| 427 |
+
min-height: 88px;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.umap-graph-loading {
|
| 431 |
+
flex: 1 1 auto;
|
| 432 |
+
min-height: 430px;
|
| 433 |
+
overflow: hidden;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.feature-detail-body-loading {
|
| 437 |
+
min-height: 140px;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
.info-list {
|
| 441 |
+
margin: 0;
|
| 442 |
+
padding-left: 18px;
|
| 443 |
+
color: var(--text-soft);
|
| 444 |
+
line-height: 1.6;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.mock-summary-lines {
|
| 448 |
+
display: grid;
|
| 449 |
+
gap: 8px;
|
| 450 |
+
margin: 10px 0 14px;
|
| 451 |
+
color: var(--text);
|
| 452 |
+
line-height: 1.5;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.mock-summary-hyperparams {
|
| 456 |
+
font-size: 0.96rem;
|
| 457 |
+
line-height: 1.55;
|
| 458 |
+
color: var(--text-soft);
|
| 459 |
+
white-space: pre-wrap;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.viz-summary-divider {
|
| 463 |
+
margin: 14px 0 12px;
|
| 464 |
+
border: 0;
|
| 465 |
+
border-top: 1px solid var(--border);
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.viz-sae-card,
|
| 469 |
+
.viz-umap-card {
|
| 470 |
+
position: relative;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.viz-sae-help-wrap,
|
| 474 |
+
.viz-umap-help-wrap {
|
| 475 |
+
position: absolute;
|
| 476 |
+
top: 22px;
|
| 477 |
+
right: 22px;
|
| 478 |
+
z-index: 3;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.viz-sae-help-wrap .viz-sae-help-trigger {
|
| 482 |
+
position: relative;
|
| 483 |
+
top: auto;
|
| 484 |
+
right: auto;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.viz-sae-help-popover {
|
| 488 |
+
display: none;
|
| 489 |
+
position: absolute;
|
| 490 |
+
top: calc(100% + 8px);
|
| 491 |
+
right: 0;
|
| 492 |
+
z-index: 4;
|
| 493 |
+
min-width: 260px;
|
| 494 |
+
max-width: 340px;
|
| 495 |
+
padding: 12px 14px;
|
| 496 |
+
border-radius: 12px;
|
| 497 |
+
background: rgba(15, 23, 42, 0.95);
|
| 498 |
+
color: #ffffff;
|
| 499 |
+
font-size: 0.86rem;
|
| 500 |
+
line-height: 1.45;
|
| 501 |
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.2);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.viz-sae-help-wrap:hover .viz-sae-help-popover,
|
| 505 |
+
.viz-sae-help-wrap:focus-within .viz-sae-help-popover {
|
| 506 |
+
display: block;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.viz-sae-help-popover::before {
|
| 510 |
+
content: "";
|
| 511 |
+
position: absolute;
|
| 512 |
+
top: -6px;
|
| 513 |
+
right: 14px;
|
| 514 |
+
border: 6px solid transparent;
|
| 515 |
+
border-bottom-color: rgba(15, 23, 42, 0.95);
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.viz-help-popover-title {
|
| 519 |
+
margin-bottom: 8px;
|
| 520 |
+
font-size: 0.8rem;
|
| 521 |
+
font-weight: 700;
|
| 522 |
+
letter-spacing: 0.02em;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.viz-help-popover-lead {
|
| 526 |
+
margin-bottom: 8px;
|
| 527 |
+
font-size: 0.8rem;
|
| 528 |
+
font-weight: 500;
|
| 529 |
+
color: rgba(255, 255, 255, 0.9);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.viz-help-popover-subtitle {
|
| 533 |
+
margin: 10px 0 6px;
|
| 534 |
+
font-size: 0.76rem;
|
| 535 |
+
font-weight: 700;
|
| 536 |
+
letter-spacing: 0.04em;
|
| 537 |
+
text-transform: uppercase;
|
| 538 |
+
color: rgba(255, 255, 255, 0.72);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.viz-umap-help-popover {
|
| 542 |
+
min-width: 280px;
|
| 543 |
+
max-width: 400px;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.viz-sae-help-popover .help-desc-line {
|
| 547 |
+
margin-bottom: 6px;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.viz-sae-help-popover .help-desc-line:last-child {
|
| 551 |
+
margin-bottom: 0;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.viz-sae-help-popover strong,
|
| 555 |
+
.viz-sae-help-popover .help-desc-line strong {
|
| 556 |
+
font-weight: 700;
|
| 557 |
+
color: #ffffff;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.viz-sae-help-popover .help-desc {
|
| 561 |
+
font-weight: 400;
|
| 562 |
+
color: rgba(255, 255, 255, 0.82);
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
.help-desc {
|
| 566 |
+
font-weight: 400;
|
| 567 |
+
color: var(--text-soft);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.viz-filter-intro strong,
|
| 571 |
+
.viz-filter-intro-list strong,
|
| 572 |
+
.viz-filter-line strong {
|
| 573 |
+
font-weight: 600;
|
| 574 |
+
color: var(--text);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.viz-filter-intro {
|
| 578 |
+
margin-bottom: 12px;
|
| 579 |
+
font-size: 0.98rem;
|
| 580 |
+
line-height: 1.55;
|
| 581 |
+
color: var(--text-soft);
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.viz-filter-intro-lead {
|
| 585 |
+
margin: 0 0 8px;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.viz-filter-intro-list {
|
| 589 |
+
margin: 0 0 10px;
|
| 590 |
+
padding-left: 1.15rem;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.viz-filter-intro-list li {
|
| 594 |
+
margin-bottom: 6px;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
.viz-filter-summary {
|
| 598 |
+
display: grid;
|
| 599 |
+
gap: 8px;
|
| 600 |
+
font-size: 0.98rem;
|
| 601 |
+
line-height: 1.55;
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
.viz-filter-line {
|
| 605 |
+
margin: 0;
|
| 606 |
+
color: var(--text);
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.mock-placeholder {
|
| 610 |
+
margin-top: 14px;
|
| 611 |
+
padding: 14px;
|
| 612 |
+
border-radius: 12px;
|
| 613 |
+
border: 1px dashed rgba(15, 23, 42, 0.06);
|
| 614 |
+
background: var(--panel-strong);
|
| 615 |
+
min-height: 500px;
|
| 616 |
+
display: flex;
|
| 617 |
+
flex-direction: column;
|
| 618 |
+
gap: 8px;
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.feature-panel {
|
| 622 |
+
display: grid;
|
| 623 |
+
gap: 14px;
|
| 624 |
+
margin-top: 18px;
|
| 625 |
+
padding: 18px;
|
| 626 |
+
border-radius: 18px;
|
| 627 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.98));
|
| 628 |
+
border: 1px solid rgba(37, 99, 235, 0.08);
|
| 629 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.feature-workbench {
|
| 633 |
+
display: grid;
|
| 634 |
+
gap: 14px;
|
| 635 |
+
margin-top: 18px;
|
| 636 |
+
width: 100%;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
.feature-workbench-title-row {
|
| 640 |
+
position: relative;
|
| 641 |
+
padding-right: 40px;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
.feature-workbench-title-row .panel-label {
|
| 645 |
+
margin-bottom: 0;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.viz-feature-workbench-help-wrap {
|
| 649 |
+
top: 0;
|
| 650 |
+
right: 0;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.viz-feature-workbench-help-popover {
|
| 654 |
+
min-width: 280px;
|
| 655 |
+
max-width: 400px;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.feature-workbench-header {
|
| 659 |
+
display: grid;
|
| 660 |
+
gap: 10px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.feature-workbench-toolbar {
|
| 664 |
+
display: grid;
|
| 665 |
+
grid-template-columns: minmax(220px, 1.35fr) minmax(280px, 1.15fr) minmax(170px, 0.75fr);
|
| 666 |
+
gap: 14px;
|
| 667 |
+
align-items: end;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.feature-workbench-controls {
|
| 671 |
+
display: grid;
|
| 672 |
+
gap: 10px;
|
| 673 |
+
padding: 14px 16px;
|
| 674 |
+
border-radius: 18px;
|
| 675 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.95));
|
| 676 |
+
border: 1px solid rgba(37, 99, 235, 0.08);
|
| 677 |
+
box-shadow: var(--shadow);
|
| 678 |
+
min-width: 0;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.feature-workbench-controls .section-label {
|
| 682 |
+
margin-bottom: 8px;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.feature-workbench-controls-sort .dash-dropdown,
|
| 686 |
+
.feature-workbench-controls-ranking .dash-dropdown {
|
| 687 |
+
width: 100%;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.feature-nav-row {
|
| 691 |
+
display: grid;
|
| 692 |
+
grid-template-columns: auto minmax(180px, 1fr) auto;
|
| 693 |
+
gap: 10px;
|
| 694 |
+
align-items: center;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
.nav-button {
|
| 698 |
+
border: 1px solid rgba(37, 99, 235, 0.16);
|
| 699 |
+
border-radius: 999px;
|
| 700 |
+
padding: 10px 16px;
|
| 701 |
+
background: #ffffff;
|
| 702 |
+
color: var(--accent-strong);
|
| 703 |
+
font-weight: 700;
|
| 704 |
+
cursor: pointer;
|
| 705 |
+
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.nav-button:hover {
|
| 709 |
+
transform: translateY(-1px);
|
| 710 |
+
border-color: rgba(37, 99, 235, 0.34);
|
| 711 |
+
background: rgba(37, 99, 235, 0.04);
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.feature-jump-input .Select-control,
|
| 715 |
+
.feature-jump-input input {
|
| 716 |
+
min-height: 42px;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
/* Hide +/- steppers in feature id number input (browser-native spinners). */
|
| 720 |
+
.feature-jump-input input[type="number"] {
|
| 721 |
+
-moz-appearance: textfield;
|
| 722 |
+
appearance: textfield;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.feature-jump-input input[type="number"]::-webkit-outer-spin-button,
|
| 726 |
+
.feature-jump-input input[type="number"]::-webkit-inner-spin-button {
|
| 727 |
+
-webkit-appearance: none;
|
| 728 |
+
margin: 0;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.baseline-grid {
|
| 732 |
+
display: grid;
|
| 733 |
+
/* allow more columns so the baseline box appears more horizontal */
|
| 734 |
+
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
| 735 |
+
gap: 12px;
|
| 736 |
+
margin-top: 6px;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
/* Page-specific helper to vertically stack the input card above overlay controls */
|
| 740 |
+
.mock-grid-vertical {
|
| 741 |
+
grid-template-columns: 1fr;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.baseline-thumb {
|
| 745 |
+
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 746 |
+
background: rgba(248, 250, 252, 0.8);
|
| 747 |
+
border-radius: 14px;
|
| 748 |
+
padding: 10px;
|
| 749 |
+
cursor: pointer;
|
| 750 |
+
text-align: left;
|
| 751 |
+
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.baseline-thumb:hover {
|
| 755 |
+
transform: translateY(-1px);
|
| 756 |
+
border-color: rgba(37, 99, 235, 0.25);
|
| 757 |
+
background: rgba(37, 99, 235, 0.06);
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
.baseline-thumb-img {
|
| 761 |
+
width: 100%;
|
| 762 |
+
height: 120px;
|
| 763 |
+
object-fit: cover;
|
| 764 |
+
border-radius: 10px;
|
| 765 |
+
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 766 |
+
background: #ffffff;
|
| 767 |
+
display: block;
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
.baseline-thumb-meta {
|
| 771 |
+
margin-top: 8px;
|
| 772 |
+
display: grid;
|
| 773 |
+
gap: 4px;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.baseline-thumb-label {
|
| 777 |
+
font-size: 0.82rem;
|
| 778 |
+
color: var(--text);
|
| 779 |
+
overflow: hidden;
|
| 780 |
+
text-overflow: ellipsis;
|
| 781 |
+
white-space: nowrap;
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
.baseline-thumb-badge {
|
| 785 |
+
font-size: 0.72rem;
|
| 786 |
+
color: var(--text-soft);
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
.feature-status {
|
| 790 |
+
min-height: 20px;
|
| 791 |
+
color: var(--text-soft);
|
| 792 |
+
font-size: 0.92rem;
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.feature-detail-card {
|
| 796 |
+
display: grid;
|
| 797 |
+
gap: 14px;
|
| 798 |
+
min-height: 420px;
|
| 799 |
+
padding: 20px;
|
| 800 |
+
border-radius: 18px;
|
| 801 |
+
background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(255, 255, 255, 0.98) 42%);
|
| 802 |
+
border: 1px solid rgba(37, 99, 235, 0.1);
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
.feature-detail-hero {
|
| 806 |
+
display: grid;
|
| 807 |
+
gap: 6px;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
.feature-detail-hero-header {
|
| 811 |
+
display: flex;
|
| 812 |
+
align-items: flex-start;
|
| 813 |
+
justify-content: space-between;
|
| 814 |
+
gap: 16px;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.feature-image-ranking-dropdown {
|
| 818 |
+
width: 100%;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
.feature-detail-kicker,
|
| 822 |
+
.feature-placeholder-title {
|
| 823 |
+
color: var(--text-soft);
|
| 824 |
+
text-transform: uppercase;
|
| 825 |
+
letter-spacing: 0.14em;
|
| 826 |
+
font-size: 0.72rem;
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
.feature-detail-number {
|
| 830 |
+
font-size: clamp(2.5rem, 6vw, 4.8rem);
|
| 831 |
+
line-height: 0.95;
|
| 832 |
+
letter-spacing: -0.05em;
|
| 833 |
+
font-weight: 800;
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
.feature-detail-subtitle {
|
| 837 |
+
color: var(--text-soft);
|
| 838 |
+
font-size: 0.98rem;
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
.feature-placeholder {
|
| 842 |
+
display: grid;
|
| 843 |
+
gap: 8px;
|
| 844 |
+
min-height: 124px;
|
| 845 |
+
padding: 16px;
|
| 846 |
+
border-radius: 16px;
|
| 847 |
+
border: 1px dashed rgba(37, 99, 235, 0.18);
|
| 848 |
+
background: rgba(255, 255, 255, 0.7);
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
.feature-placeholder-copy,
|
| 852 |
+
.feature-empty {
|
| 853 |
+
color: var(--text-soft);
|
| 854 |
+
line-height: 1.5;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.feature-comparison-block {
|
| 858 |
+
display: grid;
|
| 859 |
+
gap: 12px;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
.feature-comparison-title {
|
| 863 |
+
color: var(--text-soft);
|
| 864 |
+
text-transform: uppercase;
|
| 865 |
+
letter-spacing: 0.14em;
|
| 866 |
+
font-size: 0.72rem;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
.feature-comparison-grid {
|
| 870 |
+
display: grid;
|
| 871 |
+
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
| 872 |
+
gap: 12px;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
/* Top images grid */
|
| 876 |
+
.feature-top-images-grid {
|
| 877 |
+
display: grid;
|
| 878 |
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
| 879 |
+
gap: 12px;
|
| 880 |
+
align-items: start;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.feature-image-card {
|
| 884 |
+
position: relative;
|
| 885 |
+
border-radius: 12px;
|
| 886 |
+
overflow: hidden;
|
| 887 |
+
background: #fff;
|
| 888 |
+
border: 1px solid rgba(15, 23, 42, 0.06);
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
.feature-image-card img {
|
| 892 |
+
width: 100%;
|
| 893 |
+
height: auto;
|
| 894 |
+
display: block;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.feature-image-caption {
|
| 898 |
+
padding: 6px 8px;
|
| 899 |
+
font-size: 0.82rem;
|
| 900 |
+
color: var(--text-soft);
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
.feature-image-caption:empty {
|
| 904 |
+
display: none;
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
.comparison-card {
|
| 908 |
+
display: grid;
|
| 909 |
+
gap: 8px;
|
| 910 |
+
min-height: 110px;
|
| 911 |
+
padding: 14px 16px;
|
| 912 |
+
border-radius: 16px;
|
| 913 |
+
border: 1px solid rgba(15, 23, 42, 0.07);
|
| 914 |
+
background: rgba(255, 255, 255, 0.88);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.comparison-card-active {
|
| 918 |
+
border-color: rgba(37, 99, 235, 0.3);
|
| 919 |
+
background: linear-gradient(135deg, rgba(37, 99, 235, 0.09), rgba(255, 255, 255, 0.96));
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
.comparison-card-header {
|
| 923 |
+
display: flex;
|
| 924 |
+
align-items: center;
|
| 925 |
+
justify-content: space-between;
|
| 926 |
+
gap: 10px;
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
.comparison-selector-name {
|
| 930 |
+
font-size: 0.92rem;
|
| 931 |
+
font-weight: 700;
|
| 932 |
+
color: var(--text);
|
| 933 |
+
overflow: hidden;
|
| 934 |
+
text-overflow: ellipsis;
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
.comparison-score {
|
| 938 |
+
font-size: 1.25rem;
|
| 939 |
+
font-weight: 800;
|
| 940 |
+
letter-spacing: -0.03em;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
.comparison-rank {
|
| 944 |
+
color: var(--text-soft);
|
| 945 |
+
font-size: 0.88rem;
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
.umap-controls {
|
| 949 |
+
display: grid;
|
| 950 |
+
gap: 14px;
|
| 951 |
+
margin-bottom: 12px;
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
.umap-mode-primary {
|
| 955 |
+
display: grid;
|
| 956 |
+
gap: 10px;
|
| 957 |
+
padding: 14px 16px;
|
| 958 |
+
border: 1px solid var(--border);
|
| 959 |
+
border-radius: 12px;
|
| 960 |
+
background: var(--panel-strong);
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
.umap-mode-label {
|
| 964 |
+
font-size: 0.82rem;
|
| 965 |
+
text-transform: uppercase;
|
| 966 |
+
letter-spacing: 0.14em;
|
| 967 |
+
color: var(--accent-strong);
|
| 968 |
+
font-weight: 700;
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
.umap-mode-switch {
|
| 972 |
+
display: flex;
|
| 973 |
+
flex-wrap: wrap;
|
| 974 |
+
gap: 10px;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
.umap-mode-pill {
|
| 978 |
+
display: inline-flex;
|
| 979 |
+
align-items: center;
|
| 980 |
+
gap: 8px;
|
| 981 |
+
padding: 10px 16px;
|
| 982 |
+
border: 1px solid var(--border);
|
| 983 |
+
border-radius: 999px;
|
| 984 |
+
background: #fff;
|
| 985 |
+
cursor: pointer;
|
| 986 |
+
font-size: 0.95rem;
|
| 987 |
+
font-weight: 600;
|
| 988 |
+
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
.umap-mode-input {
|
| 992 |
+
accent-color: var(--accent);
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
.umap-mode-pill:has(.umap-mode-input:checked) {
|
| 996 |
+
border-color: rgba(37, 99, 235, 0.35);
|
| 997 |
+
background: rgba(37, 99, 235, 0.08);
|
| 998 |
+
color: var(--accent-strong);
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
.umap-controls-secondary {
|
| 1002 |
+
padding-left: 2px;
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
.umap-controls-row {
|
| 1006 |
+
display: flex;
|
| 1007 |
+
flex-wrap: wrap;
|
| 1008 |
+
align-items: flex-end;
|
| 1009 |
+
gap: 16px 20px;
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
.umap-control-block {
|
| 1013 |
+
display: grid;
|
| 1014 |
+
gap: 6px;
|
| 1015 |
+
min-width: 180px;
|
| 1016 |
+
flex: 1 1 180px;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.umap-features-color-fixed {
|
| 1020 |
+
padding: 10px 12px;
|
| 1021 |
+
border: 1px solid var(--border);
|
| 1022 |
+
border-radius: 10px;
|
| 1023 |
+
background: var(--panel-strong);
|
| 1024 |
+
font-size: 0.92rem;
|
| 1025 |
+
font-weight: 600;
|
| 1026 |
+
color: var(--accent-strong);
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
.umap-status {
|
| 1030 |
+
margin-bottom: 10px;
|
| 1031 |
+
font-size: 0.82rem;
|
| 1032 |
+
color: var(--text-soft);
|
| 1033 |
+
line-height: 1.4;
|
| 1034 |
+
}
|
| 1035 |
+
|
| 1036 |
+
.umap-status.is-loading::before {
|
| 1037 |
+
content: "Updating UMAP…";
|
| 1038 |
+
display: inline-block;
|
| 1039 |
+
margin-right: 10px;
|
| 1040 |
+
padding: 4px 10px;
|
| 1041 |
+
border-radius: 999px;
|
| 1042 |
+
background: rgba(37, 99, 235, 0.12);
|
| 1043 |
+
color: var(--accent-strong);
|
| 1044 |
+
font-size: 0.76rem;
|
| 1045 |
+
font-weight: 700;
|
| 1046 |
+
letter-spacing: 0.04em;
|
| 1047 |
+
text-transform: uppercase;
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
.umap-hover-shell {
|
| 1051 |
+
position: relative;
|
| 1052 |
+
display: flex;
|
| 1053 |
+
flex-direction: column;
|
| 1054 |
+
flex: 1 1 auto;
|
| 1055 |
+
min-height: 0;
|
| 1056 |
+
overflow: hidden;
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
.loading-block {
|
| 1060 |
+
width: 100%;
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
.feature-detail-loading {
|
| 1064 |
+
min-height: 220px;
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
.feature-images-loading {
|
| 1068 |
+
min-height: 180px;
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
#feature-top-images[data-dash-is-loading="true"]::before {
|
| 1072 |
+
content: "Loading...";
|
| 1073 |
+
display: inline-block;
|
| 1074 |
+
margin-bottom: 10px;
|
| 1075 |
+
padding: 4px 10px;
|
| 1076 |
+
border-radius: 999px;
|
| 1077 |
+
background: rgba(37, 99, 235, 0.12);
|
| 1078 |
+
color: var(--accent-strong);
|
| 1079 |
+
font-size: 0.76rem;
|
| 1080 |
+
font-weight: 700;
|
| 1081 |
+
letter-spacing: 0.04em;
|
| 1082 |
+
text-transform: uppercase;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
#feature-top-images[data-dash-is-loading="true"]::before {
|
| 1086 |
+
content: "Loading top images...";
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
.umap-graph {
|
| 1090 |
+
flex: 1 1 auto;
|
| 1091 |
+
min-height: 430px;
|
| 1092 |
+
overflow: hidden;
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
.umap-graph .js-plotly-plot,
|
| 1096 |
+
.umap-graph .plot-container,
|
| 1097 |
+
.umap-graph .svg-container {
|
| 1098 |
+
height: 100% !important;
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
@media (max-width: 980px) {
|
| 1102 |
+
|
| 1103 |
+
.home-grid,
|
| 1104 |
+
.mock-grid {
|
| 1105 |
+
grid-template-columns: 1fr;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
.app-shell {
|
| 1109 |
+
padding: 24px 14px 42px;
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
.page-back-nav {
|
| 1113 |
+
top: 8px;
|
| 1114 |
+
left: 14px;
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
.feature-stat-grid {
|
| 1118 |
+
grid-template-columns: 1fr 1fr;
|
| 1119 |
+
}
|
| 1120 |
+
|
| 1121 |
+
.feature-nav-row {
|
| 1122 |
+
grid-template-columns: 1fr;
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
.feature-workbench-toolbar {
|
| 1126 |
+
grid-template-columns: 1fr;
|
| 1127 |
+
align-items: stretch;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
.mock-placeholder {
|
| 1131 |
+
min-height: 420px;
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
.umap-graph {
|
| 1135 |
+
min-height: 360px;
|
| 1136 |
+
}
|
| 1137 |
+
}
|