Coverage for cafl4ds/measurements.py: 91%

125 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-17 14:39 +0000

1"""Representation-health instruments (Phase 0 — *Instrument*). 

2 

3This is the **measurement apparatus** of the project: the "thermometer" the rest of the 

4study reads. Every function here is *standalone* — it depends on nothing but embedding 

5tensors (``Z``, shape ``[N, d]``) or a passed-in ``encoder`` callable. There is no stream, 

6no SSL model, and no training in this module, by design: per the plan's *Strategic 

7ordering*, the instruments are built and verified in isolation first, because everything 

8downstream depends on them being correct. 

9 

10Signal catalogue implemented here (see ``docs/project-plan/metrics.md`` → *Signal catalogue*): 

11 

12Geometry / collapse (label-free): 

13 * :func:`rankme` / :func:`effective_rank` — exponential entropy of the normalized 

14 singular-value spectrum; the core collapse readout (Roy & Vetterli 2007; Garrido 

15 et al. 2023). 

16 * :func:`feature_variance` — per-dimension variance; flags dimensions collapsing to a 

17 constant (VICReg variance term, Bardes et al. 2022). 

18 * :func:`offdiag_covariance` — sum of squared off-diagonal covariances; flags 

19 informational (redundancy) collapse (VICReg covariance term, Bardes et al. 2022). 

20 * :func:`alignment` / :func:`uniformity` — positive-pair closeness and spread on the 

21 hypersphere, for the joint-embedding collapse demo (Wang & Isola 2020). 

22 

23Dynamics / stability (label-free): 

24 * :func:`linear_cka` / :func:`cka_drift` — representation drift of a fixed probe set 

25 across checkpoints (CKA, Kornblith et al. 2019). Rotation/isotropic-scale invariant. 

26 * :func:`cosine_drift` — rotation-*sensitive* per-sample churn; the direct readout of 

27 "how fast the coordinate frame moves" (the moving-reference problem). 

28 

29Downstream / forgetting (labels used HERE ONLY — never in any SSL/training path): 

30 * :func:`knn_probe` — nearest-neighbour accuracy on frozen features (Wu et al. 2018; 

31 Caron et al. 2021). 

32 * :func:`linear_probe` — linear-head accuracy on frozen features (standard SSL eval). 

33 * :func:`frozen_baseline` — evaluate a never-updated encoder (baseline **B5**); 

34 init-matched (frozen-pretrained *or* frozen-random). 

35 

36Conventions: 

37 * Embedding matrices are ``[N, d]`` (rows are samples). Inputs may be ``torch.Tensor`` 

38 or ``numpy.ndarray``; everything is computed in ``float`` on CPU. 

39 * Scalar metrics return Python ``float``; per-dimension metrics return a 1-D 

40 ``torch.Tensor`` of length ``d``. 

41 * An ``encoder`` is any callable ``Tensor -> Tensor`` mapping a batch of inputs to a 

42 batch of embeddings. 

43""" 

44 

45from collections.abc import Callable, Sequence 

46from typing import TypeAlias, cast 

47 

48import numpy as np 

49import torch 

50from sklearn.linear_model import LogisticRegression 

51from sklearn.neighbors import KNeighborsClassifier 

52 

53# Anything we accept as an embedding matrix / label vector. 

54TensorLike: TypeAlias = torch.Tensor | np.ndarray 

55 

56# A callable mapping a batch of inputs to a batch of embeddings. 

57Encoder: TypeAlias = Callable[[torch.Tensor], torch.Tensor] 

58 

59# A ``(inputs, labels)`` pair for the probe instruments. Labels are used HERE ONLY. 

60Dataset: TypeAlias = tuple[TensorLike, TensorLike] 

61 

62 

63def _as_tensor(x: TensorLike) -> torch.Tensor: 

64 """Return ``x`` as a detached, contiguous float ``torch.Tensor`` on the CPU. 

65 

66 Args: 

67 x: A ``torch.Tensor`` or ``numpy.ndarray``. 

68 

69 Returns: 

70 A 2-D-preserving float tensor (dtype ``float32``) detached from any graph. 

71 """ 

72 if isinstance(x, np.ndarray): 

73 t = torch.from_numpy(x) 

74 elif isinstance(x, torch.Tensor): 

75 t = x.detach() 

76 else: # pragma: no cover - defensive; callers pass tensors/arrays. 

77 t = torch.as_tensor(x) 

78 return t.to(dtype=torch.float32).cpu().contiguous() 

79 

80 

81def _to_float(x: torch.Tensor) -> float: 

82 """Extract a Python ``float`` from a scalar tensor (keeps mypy strict happy).""" 

83 return cast(float, x.item()) 

84 

85 

86def _check_matrix(z: torch.Tensor, name: str = "Z") -> torch.Tensor: 

87 """Validate that ``z`` is a 2-D ``[N, d]`` embedding matrix. 

88 

89 Args: 

90 z: Candidate embedding matrix. 

91 name: Name used in error messages. 

92 

93 Returns: 

94 ``z`` unchanged, once validated. 

95 

96 Raises: 

97 ValueError: If ``z`` is not 2-dimensional or is empty. 

98 """ 

99 if z.ndim != 2: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 raise ValueError(f"{name} must be 2-D [N, d]; got shape {tuple(z.shape)}.") 

101 if z.shape[0] == 0 or z.shape[1] == 0: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 raise ValueError(f"{name} must be non-empty; got shape {tuple(z.shape)}.") 

103 return z 

104 

105 

106# -------------------------------------------------------------------------------------- 

107# Geometry / collapse (label-free) 

108# -------------------------------------------------------------------------------------- 

109 

110 

111def _singular_values(z: TensorLike) -> torch.Tensor: 

112 """Return the singular values of an embedding matrix in descending order. 

113 

114 Args: 

115 z: Embedding matrix ``[N, d]``. 

116 

117 Returns: 

118 A 1-D tensor of ``min(N, d)`` singular values, sorted descending. 

119 """ 

120 zt = _check_matrix(_as_tensor(z)) 

121 return cast(torch.Tensor, torch.linalg.svdvals(zt)) 

122 

123 

124def effective_rank(z: TensorLike) -> float: 

125 """Effective rank: exponential of the entropy of the normalized singular-value spectrum. 

126 

127 Roy & Vetterli (2007). Given singular values ``s`` of ``Z``, form the probability 

128 distribution ``p = s / sum(s)`` and return ``exp(H(p))`` where ``H`` is the Shannon 

129 entropy in nats. Reads out how many embedding dimensions are *actually in use*: a 

130 rank-1 (fully collapsed) representation scores ~1; ``k`` equal directions score ~``k``. 

131 

132 Args: 

133 z: Embedding matrix ``[N, d]``. 

134 

135 Returns: 

136 The effective rank, a float in ``[1, min(N, d)]``. 

137 """ 

138 s = _singular_values(z) 

139 total = s.sum() 

140 if total <= 0: # pragma: no cover - all-zero embeddings; degenerate but well-defined. 

141 return 1.0 

142 # Drop exact-zero singular values (from rank-deficient matrices) before the log so they 

143 # contribute 0 to the entropy (the -p*log(p) -> 0 limit) rather than producing NaNs. 

144 p = s[s > 0] / total 

145 entropy = -(p * p.log()).sum() 

146 return _to_float(torch.exp(entropy)) 

147 

148 

149def rankme(z: TensorLike, eps: float = 1e-7) -> float: 

150 """RankMe (Garrido et al. 2023): effective rank with the paper's numerical smoothing. 

151 

152 Identical in spirit to :func:`effective_rank`, using the RankMe formulation 

153 ``p_k = s_k / ||s||_1 + eps`` before taking the exponential entropy. The ``eps`` term 

154 keeps the log well-defined and matches the reference implementation. 

155 

156 Args: 

157 z: Embedding matrix ``[N, d]``. 

158 eps: Numerical-stability constant added to each normalized singular value. 

159 

160 Returns: 

161 The RankMe score, a float in ``[1, min(N, d)]``. 

162 """ 

163 s = _singular_values(z) 

164 total = s.sum() 

165 if total <= 0: # pragma: no cover - all-zero embeddings. 

166 return 1.0 

167 p = s / total + eps 

168 entropy = -(p * p.log()).sum() 

169 return _to_float(torch.exp(entropy)) 

170 

171 

172def feature_variance(z: TensorLike, unbiased: bool = True) -> torch.Tensor: 

173 """Per-dimension variance of the embeddings (VICReg variance term, Bardes et al. 2022). 

174 

175 Flags dimensions that have collapsed to a (near-)constant value: such a dimension 

176 reads ~0. Returned per-dimension rather than aggregated so callers can inspect *which* 

177 dimensions are collapsing. 

178 

179 Args: 

180 z: Embedding matrix ``[N, d]``. 

181 unbiased: Whether to use Bessel's correction (``ddof=1``), as VICReg does. 

182 

183 Returns: 

184 A 1-D tensor of length ``d`` giving the variance of each embedding dimension. 

185 """ 

186 zt = _check_matrix(_as_tensor(z)) 

187 return zt.var(dim=0, unbiased=unbiased) 

188 

189 

190def offdiag_covariance(z: TensorLike) -> float: 

191 """VICReg covariance term: mean squared off-diagonal covariance (Bardes et al. 2022). 

192 

193 Computes the covariance matrix ``C`` of the (feature-centered) embeddings and returns 

194 ``sum_{i != j} C[i, j]^2 / d``. Flags *informational* (redundancy) collapse: when 

195 dimensions become linearly redundant the off-diagonal mass grows; a decorrelated 

196 representation scores ~0. 

197 

198 Args: 

199 z: Embedding matrix ``[N, d]`` with ``N >= 2``. 

200 

201 Returns: 

202 The mean squared off-diagonal covariance, a non-negative float. 

203 

204 Raises: 

205 ValueError: If ``N < 2`` (covariance is undefined for a single sample). 

206 """ 

207 zt = _check_matrix(_as_tensor(z)) 

208 n, d = zt.shape 

209 if n < 2: 

210 raise ValueError(f"offdiag_covariance needs N >= 2 samples; got N={n}.") 

211 zc = zt - zt.mean(dim=0, keepdim=True) 

212 cov = (zc.T @ zc) / (n - 1) 

213 off_diag_sq = cov.pow(2).sum() - cov.diagonal().pow(2).sum() 

214 return _to_float(off_diag_sq / d) 

215 

216 

217def _l2_normalize(z: torch.Tensor, eps: float = 1e-12) -> torch.Tensor: 

218 """Row-wise L2-normalize an embedding matrix onto the unit hypersphere. 

219 

220 Args: 

221 z: Embedding matrix ``[N, d]``. 

222 eps: Lower bound on the norm to avoid division by zero. 

223 

224 Returns: 

225 ``z`` with each row scaled to unit L2 norm. 

226 """ 

227 return cast(torch.Tensor, z / z.norm(dim=1, keepdim=True).clamp_min(eps)) 

228 

229 

230def alignment( 

231 pos_a: TensorLike | Sequence[TensorLike], 

232 pos_b: TensorLike | None = None, 

233 *, 

234 alpha: float = 2.0, 

235) -> float: 

236 """Alignment of positive pairs on the hypersphere (Wang & Isola 2020). 

237 

238 ``E[ ||f(x) - f(y)||_2^alpha ]`` over positive pairs ``(x, y)``, both L2-normalized to 

239 the unit sphere. Lower is better (positives map close together); it *rises* as a 

240 joint-embedding model's positives drift apart. 

241 

242 Args: 

243 pos_a: Either the first matrix of a positive pair ``[N, d]``, or — when ``pos_b`` is 

244 omitted — a ``(z_a, z_b)`` sequence of the two matrices. 

245 pos_b: The second matrix of the positive pair ``[N, d]``, aligned row-for-row with 

246 ``pos_a``. Omit when passing the pair as ``pos_a``. 

247 alpha: Exponent on the pairwise distance (Wang & Isola use ``alpha = 2``). 

248 

249 Returns: 

250 The mean aligned distance, a non-negative float. 

251 

252 Raises: 

253 ValueError: If the two matrices differ in shape. 

254 """ 

255 if pos_b is None: 

256 pair = cast("Sequence[TensorLike]", pos_a) # unpack the (z_a, z_b) sequence. 

257 a_mat, b_mat = pair[0], pair[1] 

258 else: 

259 a_mat, b_mat = cast(TensorLike, pos_a), pos_b 

260 za = _check_matrix(_as_tensor(a_mat), "pos_a") 

261 zb = _check_matrix(_as_tensor(b_mat), "pos_b") 

262 if za.shape != zb.shape: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 raise ValueError(f"positive pairs must share a shape; got {tuple(za.shape)} vs {tuple(zb.shape)}.") 

264 za, zb = _l2_normalize(za), _l2_normalize(zb) 

265 dist = (za - zb).norm(dim=1) 

266 return _to_float(dist.pow(alpha).mean()) 

267 

268 

269def uniformity(z: TensorLike, t: float = 2.0) -> float: 

270 """Uniformity of embeddings on the hypersphere (Wang & Isola 2020). 

271 

272 ``log E_{x,y}[ exp(-t * ||x - y||_2^2) ]`` over all distinct pairs, with rows 

273 L2-normalized to the unit sphere. *Lower* (more negative) means the embeddings are 

274 spread more uniformly; the value climbs toward 0 as embeddings clump together 

275 (a collapse signature). 

276 

277 Args: 

278 z: Embedding matrix ``[N, d]`` with ``N >= 2``. 

279 t: Temperature of the Gaussian potential (Wang & Isola use ``t = 2``). 

280 

281 Returns: 

282 The uniformity loss, a float (typically negative). 

283 

284 Raises: 

285 ValueError: If ``N < 2`` (no pairs exist). 

286 """ 

287 zt = _check_matrix(_as_tensor(z)) 

288 if zt.shape[0] < 2: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 raise ValueError(f"uniformity needs N >= 2 samples; got N={zt.shape[0]}.") 

290 zt = _l2_normalize(zt) 

291 # pdist gives the condensed vector of pairwise L2 distances (upper triangle, i < j). 

292 sq_dist = torch.pdist(zt).pow(2) 

293 return _to_float(torch.log(torch.exp(-t * sq_dist).mean())) 

294 

295 

296# -------------------------------------------------------------------------------------- 

297# Dynamics / stability (label-free) 

298# -------------------------------------------------------------------------------------- 

299 

300 

301def linear_cka(x: TensorLike, y: TensorLike) -> float: 

302 """Linear Centered Kernel Alignment between two representations (Kornblith et al. 2019). 

303 

304 Both matrices describe the *same* set of ``N`` samples under two representations 

305 (e.g. a fixed probe set at two checkpoints). Returns a similarity in ``[0, 1]`` that is 

306 invariant to orthogonal transforms and isotropic scaling of either representation 

307 (that invariance is the point of CKA — it compares representational *content*, not the 

308 coordinate frame; see :func:`cosine_drift` for a frame-sensitive companion). 

309 

310 Args: 

311 x: First representation ``[N, d_x]``. 

312 y: Second representation ``[N, d_y]`` (same ``N`` as ``x``). 

313 

314 Returns: 

315 The linear CKA similarity, a float in ``[0, 1]`` (1.0 = identical up to 

316 rotation/scaling). 

317 

318 Raises: 

319 ValueError: If ``x`` and ``y`` have a different number of samples. 

320 """ 

321 xt = _check_matrix(_as_tensor(x), "x") 

322 yt = _check_matrix(_as_tensor(y), "y") 

323 if xt.shape[0] != yt.shape[0]: 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 raise ValueError(f"CKA needs matching sample counts; got {xt.shape[0]} vs {yt.shape[0]}.") 

325 xc = xt - xt.mean(dim=0, keepdim=True) 

326 yc = yt - yt.mean(dim=0, keepdim=True) 

327 # Linear HSIC via the feature-space cross-covariance: ||Yc^T Xc||_F^2. 

328 hsic_xy = (yc.T @ xc).pow(2).sum() 

329 hsic_xx = (xc.T @ xc).pow(2).sum() 

330 hsic_yy = (yc.T @ yc).pow(2).sum() 

331 denom = torch.sqrt(hsic_xx * hsic_yy) 

332 if denom <= 0: # pragma: no cover - a constant representation; similarity undefined. 

333 return 0.0 

334 return _to_float(hsic_xy / denom) 

335 

336 

337def cka_drift(z_ref_t0: TensorLike, z_ref_t: TensorLike) -> float: 

338 """Representation drift of a fixed probe set via linear CKA (``1 - CKA``). 

339 

340 Measures how much the *content* of a fixed probe set's representation has changed 

341 between two checkpoints. ``0`` means unchanged (identical up to rotation/scaling); 

342 larger means more drift. Because linear CKA is rotation-invariant, a pure change of 

343 coordinate frame reads ~0 here — use :func:`cosine_drift` to detect that. 

344 

345 Args: 

346 z_ref_t0: Probe-set embeddings at the reference checkpoint ``[N, d0]``. 

347 z_ref_t: Probe-set embeddings at a later checkpoint ``[N, dt]`` (same ``N``). 

348 

349 Returns: 

350 The drift ``1 - linear_cka``, a float in ``[0, 1]``. 

351 """ 

352 return 1.0 - linear_cka(z_ref_t0, z_ref_t) 

353 

354 

355def cosine_drift(z_ref_t0: TensorLike, z_ref_t: TensorLike) -> float: 

356 """Per-sample cosine churn of a fixed probe set (``1 - mean cosine similarity``). 

357 

358 Unlike :func:`cka_drift`, this is *sensitive* to rotation of the embedding coordinate 

359 frame: it compares each sample's embedding to its own embedding at the reference 

360 checkpoint. This is the direct readout of "how fast the coordinate frame moves" — the 

361 moving-reference problem that destabilizes a fixed novelty reference. Requires the two 

362 checkpoints to share an embedding dimensionality (same coordinate axes). 

363 

364 Args: 

365 z_ref_t0: Probe-set embeddings at the reference checkpoint ``[N, d]``. 

366 z_ref_t: Probe-set embeddings at a later checkpoint ``[N, d]`` (same ``N`` and ``d``). 

367 

368 Returns: 

369 The mean per-sample cosine drift, a float in ``[0, 2]`` (0 = unchanged direction). 

370 

371 Raises: 

372 ValueError: If the two matrices do not share a shape. 

373 """ 

374 z0 = _check_matrix(_as_tensor(z_ref_t0), "z_ref_t0") 

375 zt = _check_matrix(_as_tensor(z_ref_t), "z_ref_t") 

376 if z0.shape != zt.shape: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 raise ValueError(f"cosine_drift needs matching shapes; got {tuple(z0.shape)} vs {tuple(zt.shape)}.") 

378 cos = torch.nn.functional.cosine_similarity(z0, zt, dim=1) 

379 return _to_float(1.0 - cos.mean()) 

380 

381 

382# -------------------------------------------------------------------------------------- 

383# Downstream / forgetting (labels used HERE ONLY) 

384# -------------------------------------------------------------------------------------- 

385 

386 

387def _encode(encoder: Encoder, inputs: TensorLike) -> np.ndarray: 

388 """Run ``encoder`` over ``inputs`` (no grad) and return a 2-D ``float`` numpy array. 

389 

390 Args: 

391 encoder: Callable mapping a batch of inputs to a batch of embeddings. 

392 inputs: Model inputs accepted by ``encoder``. 

393 

394 Returns: 

395 The embeddings as a ``[N, d]`` numpy array. 

396 """ 

397 with torch.no_grad(): 

398 z = encoder(_as_tensor(inputs)) 

399 zt = _as_tensor(z) 

400 return _check_matrix(zt, "encoder output").numpy() 

401 

402 

403def _labels(y: TensorLike) -> np.ndarray: 

404 """Coerce a label vector to a 1-D numpy array. 

405 

406 Args: 

407 y: Label vector (tensor or array). 

408 

409 Returns: 

410 A 1-D numpy array of labels. 

411 """ 

412 arr = y.detach().cpu().numpy() if isinstance(y, torch.Tensor) else np.asarray(y) 

413 return arr.reshape(-1) 

414 

415 

416def knn_probe( 

417 encoder: Encoder, 

418 support: Dataset, 

419 query: Dataset, 

420 k: int = 20, 

421 *, 

422 metric: str = "cosine", 

423 weights: str = "distance", 

424) -> float: 

425 """k-NN probe accuracy on frozen features (Wu et al. 2018; Caron et al. 2021). 

426 

427 Encodes the ``support`` and ``query`` inputs with the (frozen) ``encoder``, fits a 

428 nearest-neighbour classifier on the support embeddings/labels, and reports accuracy on 

429 the query set. Labels are used HERE ONLY — this is an evaluation probe, never a 

430 training signal. 

431 

432 Args: 

433 encoder: Frozen encoder callable ``inputs -> embeddings``. 

434 support: ``(inputs, labels)`` used as the neighbour database. 

435 query: ``(inputs, labels)`` to classify and score. 

436 k: Number of neighbours. Clamped to the support-set size if larger. 

437 metric: Distance metric passed to scikit-learn (default cosine, the SSL k-NN norm). 

438 weights: Neighbour weighting (``"distance"`` or ``"uniform"``). 

439 

440 Returns: 

441 Top-1 query accuracy in ``[0, 1]``. 

442 """ 

443 xs, ys = _encode(encoder, support[0]), _labels(support[1]) 

444 xq, yq = _encode(encoder, query[0]), _labels(query[1]) 

445 n_neighbors = max(1, min(k, xs.shape[0])) 

446 clf = KNeighborsClassifier(n_neighbors=n_neighbors, metric=metric, weights=weights) 

447 clf.fit(xs, ys) 

448 return float((clf.predict(xq) == yq).mean()) 

449 

450 

451def linear_probe( 

452 encoder: Encoder, 

453 support: Dataset, 

454 query: Dataset, 

455 *, 

456 max_iter: int = 1000, 

457 C: float = 1.0, 

458 standardize: bool = True, 

459) -> float: 

460 """Linear-probe accuracy on frozen features (standard SSL linear-evaluation protocol). 

461 

462 Encodes ``support`` and ``query`` with the (frozen) ``encoder`` and fits a multinomial 

463 logistic-regression head on the support embeddings, then scores the query set. Only a 

464 *linear* head is trained; the encoder is never updated. Labels are used HERE ONLY. 

465 

466 Args: 

467 encoder: Frozen encoder callable ``inputs -> embeddings``. 

468 support: ``(inputs, labels)`` used to fit the linear head. 

469 query: ``(inputs, labels)`` to score. 

470 max_iter: Maximum solver iterations for the logistic-regression head. 

471 C: Inverse L2-regularization strength for the linear head. 

472 standardize: Whether to zero-mean/unit-variance the features (fit on support) before 

473 the linear head — the usual, more stable linear-probe recipe. 

474 

475 Returns: 

476 Top-1 query accuracy in ``[0, 1]``. 

477 """ 

478 xs, ys = _encode(encoder, support[0]), _labels(support[1]) 

479 xq, yq = _encode(encoder, query[0]), _labels(query[1]) 

480 if standardize: 480 ↛ 486line 480 didn't jump to line 486 because the condition on line 480 was always true

481 mean = xs.mean(axis=0, keepdims=True) 

482 std = xs.std(axis=0, keepdims=True) 

483 std[std == 0] = 1.0 

484 xs = (xs - mean) / std 

485 xq = (xq - mean) / std 

486 clf = LogisticRegression(max_iter=max_iter, C=C) 

487 clf.fit(xs, ys) 

488 return float((clf.predict(xq) == yq).mean()) 

489 

490 

491def frozen_baseline( 

492 encoder_init: Encoder, 

493 support: Dataset, 

494 query: Dataset, 

495 *, 

496 probe: str = "knn", 

497 **probe_kwargs: object, 

498) -> float: 

499 """Baseline **B5**: evaluate a never-updated encoder (init-matched frozen backbone). 

500 

501 B5 is the "no adaptation" floor of the study — a backbone that never sees a gradient 

502 step. It is *init-matched*: pass a frozen-pretrained encoder in the pretrained regime, 

503 or a frozen-random encoder in the from-scratch regime. This is a thin wrapper that runs 

504 the chosen probe on ``encoder_init``; the point is the frozen encoder, not new logic. 

505 

506 Args: 

507 encoder_init: The frozen encoder (pretrained or randomly initialized) to evaluate. 

508 support: ``(inputs, labels)`` for the probe's support/training split. 

509 query: ``(inputs, labels)`` for the probe's query/eval split. 

510 probe: Which downstream probe to run: ``"knn"`` or ``"linear"``. 

511 **probe_kwargs: Forwarded to :func:`knn_probe` / :func:`linear_probe`. 

512 

513 Returns: 

514 The probe's top-1 query accuracy in ``[0, 1]``. 

515 

516 Raises: 

517 ValueError: If ``probe`` is not ``"knn"`` or ``"linear"``. 

518 """ 

519 if probe == "knn": 

520 return knn_probe(encoder_init, support, query, **probe_kwargs) # type: ignore[arg-type] 

521 if probe == "linear": 

522 return linear_probe(encoder_init, support, query, **probe_kwargs) # type: ignore[arg-type] 

523 raise ValueError(f"probe must be 'knn' or 'linear'; got {probe!r}.")