dvarfe commited on
Commit
00a0ce5
·
1 Parent(s): 8b95d51

init repo

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +2 -0
  2. Dockerfile +24 -0
  3. analysis/__init__.py +0 -0
  4. analysis/cache_utils.py +787 -0
  5. analysis/config.py +332 -0
  6. analysis/datasets.py +1148 -0
  7. analysis/features/__init__.py +0 -0
  8. analysis/features/__pycache__/__init__.cpython-310.pyc +0 -0
  9. analysis/features/__pycache__/feature_filters.cpython-310.pyc +0 -0
  10. analysis/features/__pycache__/feature_indexing.cpython-310.pyc +0 -0
  11. analysis/features/__pycache__/feature_matrix.cpython-310.pyc +0 -0
  12. analysis/features/__pycache__/feature_selectors.cpython-310.pyc +0 -0
  13. analysis/features/feature_filters.py +437 -0
  14. analysis/features/feature_indexing.py +116 -0
  15. analysis/features/feature_matrix.py +100 -0
  16. analysis/features/feature_selectors.py +918 -0
  17. analysis/features/feature_stats.py +111 -0
  18. analysis/metrics/__init__.py +0 -0
  19. analysis/metrics/__pycache__/__init__.cpython-310.pyc +0 -0
  20. analysis/metrics/__pycache__/correlations.cpython-310.pyc +0 -0
  21. analysis/metrics/__pycache__/iou_utils.cpython-310.pyc +0 -0
  22. analysis/metrics/__pycache__/mutual_information.cpython-310.pyc +0 -0
  23. analysis/metrics/__pycache__/paired_deltas.cpython-310.pyc +0 -0
  24. analysis/metrics/__pycache__/precision_recall.cpython-310.pyc +0 -0
  25. analysis/metrics/__pycache__/roc_auc.cpython-310.pyc +0 -0
  26. analysis/metrics/correlations.py +121 -0
  27. analysis/metrics/iou_utils.py +341 -0
  28. analysis/metrics/mutual_information.py +115 -0
  29. analysis/metrics/paired_deltas.py +210 -0
  30. analysis/metrics/precision_recall.py +154 -0
  31. analysis/metrics/roc_auc.py +86 -0
  32. analysis/models.py +298 -0
  33. analysis/qalign_utils.py +86 -0
  34. analysis/utils.py +120 -0
  35. analysis/viz/__init__.py +0 -0
  36. analysis/viz/__pycache__/__init__.cpython-310.pyc +0 -0
  37. analysis/viz/__pycache__/umap_plot.cpython-310.pyc +0 -0
  38. analysis/viz/__pycache__/umap_utils.cpython-310.pyc +0 -0
  39. analysis/viz/__pycache__/vis_correlations.cpython-310.pyc +0 -0
  40. analysis/viz/__pycache__/vis_heatmaps.cpython-310.pyc +0 -0
  41. analysis/viz/__pycache__/vis_metrics.cpython-310.pyc +0 -0
  42. analysis/viz/__pycache__/vis_scatter.cpython-310.pyc +0 -0
  43. analysis/viz/umap_plot.py +197 -0
  44. analysis/viz/umap_utils.py +214 -0
  45. analysis/viz/vis_correlations.py +98 -0
  46. analysis/viz/vis_heatmaps.py +770 -0
  47. analysis/viz/vis_metrics.py +147 -0
  48. analysis/viz/vis_scatter.py +484 -0
  49. assets/examples +1 -0
  50. 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
+ }