Coverage for src\cmvk\metrics.py: 19%

1"""

2 CMVK Distance Metrics Module

3

4 Provides configurable distance metrics for embedding verification.

5 All functions are pure (no side effects) and use only numpy/scipy.

6

7 Supported Metrics:

8 - cosine: Cosine distance (default, normalizes vectors)

9 - euclidean: Euclidean distance (preserves magnitude)

10 - manhattan: Manhattan/L1 distance

11 - chebyshev: Chebyshev/L∞ distance (max absolute difference)

12 - mahalanobis: Mahalanobis distance (accounts for correlation)

13"""

14

15 from __future__ import annotations

16

17 from collections.abc import Callable

18 from dataclasses import dataclass

19 from enum import Enum

20 from typing import Any

21

22 import numpy as np

23 from numpy.typing import ArrayLike

24

25 try:

26 from scipy import spatial

27

28 HAS_SCIPY = True

29 except ImportError:

30 HAS_SCIPY = False

31

32

33 class DistanceMetric(Enum):

34 """Supported distance metrics for embedding comparison."""

35

36 COSINE = "cosine"

37 EUCLIDEAN = "euclidean"

38 MANHATTAN = "manhattan"

39 CHEBYSHEV = "chebyshev"

40 MAHALANOBIS = "mahalanobis"

41

42

43@dataclass(frozen=True)

44 class MetricResult:

45 """

46 Result of a distance metric calculation.

47

48 Attributes:

49 distance: Raw distance value

50 normalized: Distance normalized to [0, 1] range

51 metric: The metric used

52 details: Additional metric-specific information

53 """

54

55 distance: float

56 normalized: float

57 metric: DistanceMetric

58 details: dict

59

60

61 def cosine_distance(vec_a: np.ndarray, vec_b: np.ndarray) -> MetricResult:

62 """

63 Calculate cosine distance between two vectors.

64

65 Cosine distance = 1 - cosine_similarity

66 Range: [0, 2] where 0 = identical direction, 1 = orthogonal, 2 = opposite

67

68 Note: Cosine distance normalizes vectors, losing magnitude information.

69 Use Euclidean distance when magnitude matters.

70

71 Args:

72 vec_a: First vector

73 vec_b: Second vector

74

75 Returns:

76 MetricResult with cosine distance

77 """

78 if HAS_SCIPY:

79 dist = spatial.distance.cosine(vec_a, vec_b)

80 else:

81 dot = np.dot(vec_a, vec_b)

82 norm_a = np.linalg.norm(vec_a)

83 norm_b = np.linalg.norm(vec_b)

84 dist = 1.0 if norm_a == 0 or norm_b == 0 else 1.0 - dot / (norm_a * norm_b)

85

86 # Handle numerical precision issues

87 dist = float(np.clip(dist, 0.0, 2.0))

88

89 return MetricResult(

90 distance=dist,

91 normalized=dist / 2.0, # Normalize to [0, 1]

92 metric=DistanceMetric.COSINE,

93 details={

94 "cosine_similarity": 1.0 - dist,

95 "norm_a": float(np.linalg.norm(vec_a)),

96 "norm_b": float(np.linalg.norm(vec_b)),

97 },

98 )

99

100

101 def euclidean_distance(

102 vec_a: np.ndarray, vec_b: np.ndarray, expected_range: tuple[float, float] | None = None

103) -> MetricResult:

104 """

105 Calculate Euclidean (L2) distance between two vectors.

106

107 Unlike cosine distance, Euclidean distance preserves magnitude information.

108 This is critical for detecting drift in absolute values (e.g., NDVI scores).

109

110 Args:

111 vec_a: First vector

112 vec_b: Second vector

113 expected_range: Optional (min, max) expected range for normalization.

114 If None, uses vector dimension for normalization.

115

116 Returns:

117 MetricResult with Euclidean distance

118 """

119 if HAS_SCIPY:

120 dist = spatial.distance.euclidean(vec_a, vec_b)

121 else:

122 dist = float(np.linalg.norm(vec_a - vec_b))

123

124 # Calculate per-dimension differences

125 dim_diffs = np.abs(vec_a - vec_b)

126 max_diff_dim = int(np.argmax(dim_diffs))

127 max_diff_value = float(dim_diffs[max_diff_dim])

128

129 # Normalization

130 if expected_range is not None:

131 min_val, max_val = expected_range

132 max_possible_dist = np.sqrt(len(vec_a)) * (max_val - min_val)

133 else:

134 # Assume normalized embeddings in [-1, 1]

135 max_possible_dist = np.sqrt(len(vec_a) * 4)

136

137 normalized = min(dist / max_possible_dist, 1.0) if max_possible_dist > 0 else 0.0

138

139 return MetricResult(

140 distance=dist,

141 normalized=float(normalized),

142 metric=DistanceMetric.EUCLIDEAN,

143 details={

144 "max_diff_dimension": max_diff_dim,

145 "max_diff_value": max_diff_value,

146 "per_dimension_diff": dim_diffs.tolist() if len(dim_diffs) <= 20 else None,

147 "mean_diff": float(np.mean(dim_diffs)),

148 "std_diff": float(np.std(dim_diffs)),

149 },

150 )

151

152

153 def manhattan_distance(

154 vec_a: np.ndarray, vec_b: np.ndarray, expected_range: tuple[float, float] | None = None

155) -> MetricResult:

156 """

157 Calculate Manhattan (L1/city-block) distance between two vectors.

158

159 Sum of absolute differences across all dimensions.

160 More robust to outliers than Euclidean distance.

161

162 Args:

163 vec_a: First vector

164 vec_b: Second vector

165 expected_range: Optional (min, max) expected range for normalization.

166

167 Returns:

168 MetricResult with Manhattan distance

169 """

170 if HAS_SCIPY:

171 dist = spatial.distance.cityblock(vec_a, vec_b)

172 else:

173 dist = float(np.sum(np.abs(vec_a - vec_b)))

174

175 # Per-dimension contributions

176 dim_diffs = np.abs(vec_a - vec_b)

177 contributions = dim_diffs / dist if dist > 0 else np.zeros_like(dim_diffs)

178

179 # Normalization

180 if expected_range is not None:

181 min_val, max_val = expected_range

182 max_possible_dist = len(vec_a) * (max_val - min_val)

183 else:

184 max_possible_dist = len(vec_a) * 2 # Assuming [-1, 1] range

185

186 normalized = min(dist / max_possible_dist, 1.0) if max_possible_dist > 0 else 0.0

187

188 return MetricResult(

189 distance=float(dist),

190 normalized=float(normalized),

191 metric=DistanceMetric.MANHATTAN,

192 details={

193 "per_dimension_contributions": (

194 contributions.tolist() if len(contributions) <= 20 else None

195 ),

196 "mean_contribution": float(np.mean(dim_diffs)),

197 "total_dimensions": len(vec_a),

198 },

199 )

200

201

202 def chebyshev_distance(

203 vec_a: np.ndarray, vec_b: np.ndarray, expected_range: tuple[float, float] | None = None

204) -> MetricResult:

205 """

206 Calculate Chebyshev (L∞) distance between two vectors.

207

208 Maximum absolute difference across any single dimension.

209 Useful for detecting if ANY dimension has significant drift.

210

211 Args:

212 vec_a: First vector

213 vec_b: Second vector

214 expected_range: Optional (min, max) expected range for normalization.

215

216 Returns:

217 MetricResult with Chebyshev distance

218 """

219 if HAS_SCIPY:

220 dist = spatial.distance.chebyshev(vec_a, vec_b)

221 else:

222 dist = float(np.max(np.abs(vec_a - vec_b)))

223

224 dim_diffs = np.abs(vec_a - vec_b)

225 max_diff_dim = int(np.argmax(dim_diffs))

226

227 # Normalization

228 if expected_range is not None:

229 min_val, max_val = expected_range

230 max_possible_dist = max_val - min_val

231 else:

232 max_possible_dist = 2.0 # Assuming [-1, 1] range

233

234 normalized = min(dist / max_possible_dist, 1.0) if max_possible_dist > 0 else 0.0

235

236 return MetricResult(

237 distance=float(dist),

238 normalized=float(normalized),

239 metric=DistanceMetric.CHEBYSHEV,

240 details={

241 "max_diff_dimension": max_diff_dim,

242 "max_diff_value": float(dim_diffs[max_diff_dim]),

243 "second_max_diff": (

244 float(np.partition(dim_diffs, -2)[-2]) if len(dim_diffs) > 1 else 0.0

245 ),

246 },

247 )

248

249

250 def mahalanobis_distance(

251 vec_a: np.ndarray, vec_b: np.ndarray, cov_inv: np.ndarray | None = None

252) -> MetricResult:

253 """

254 Calculate Mahalanobis distance between two vectors.

255

256 Accounts for correlation between dimensions using covariance matrix.

257 Useful when dimensions are correlated (e.g., related environmental factors).

258

259 Args:

260 vec_a: First vector

261 vec_b: Second vector

262 cov_inv: Inverse of covariance matrix. If None, uses identity (= Euclidean).

263

264 Returns:

265 MetricResult with Mahalanobis distance

266 """

267 diff = vec_a - vec_b

268

269 if cov_inv is None:

270 # Without covariance info, use identity matrix (equivalent to Euclidean)

271 cov_inv = np.eye(len(vec_a))

272 using_identity = True

273 else:

274 using_identity = False

275

276 if HAS_SCIPY:

277 try:

278 dist = spatial.distance.mahalanobis(vec_a, vec_b, cov_inv)

279 except Exception:

280 # Fallback if scipy fails

281 dist = float(np.sqrt(diff @ cov_inv @ diff))

282 else:

283 dist = float(np.sqrt(diff @ cov_inv @ diff))

284

285 # For Mahalanobis, normalization depends on the covariance structure

286 # Chi-squared with n degrees of freedom has expected value n

287 n_dims = len(vec_a)

288 # Normalize using expected value under null hypothesis

289 normalized = min(dist / np.sqrt(n_dims), 1.0)

290

291 return MetricResult(

292 distance=float(dist),

293 normalized=float(normalized),

294 metric=DistanceMetric.MAHALANOBIS,

295 details={

296 "using_identity_covariance": using_identity,

297 "dimensions": n_dims,

298 "squared_distance": float(dist**2),

299 },

300 )

301

302

303 def calculate_distance(

304 vec_a: ArrayLike,

305 vec_b: ArrayLike,

306 metric: str | DistanceMetric = "cosine",

307 **kwargs: Any,

308) -> MetricResult:

309 """

310 Calculate distance between two vectors using specified metric.

311

312 This is the primary entry point for distance calculations.

313

314 Args:

315 vec_a: First vector

316 vec_b: Second vector

317 metric: Distance metric to use. One of:

318 - "cosine": Cosine distance (default)

319 - "euclidean": Euclidean/L2 distance

320 - "manhattan": Manhattan/L1 distance

321 - "chebyshev": Chebyshev/L∞ distance

322 - "mahalanobis": Mahalanobis distance

323 **kwargs: Additional arguments passed to the specific metric function

324

325 Returns:

326 MetricResult with distance and normalized score

327

328 Raises:

329 ValueError: If metric is not supported or vectors have different shapes

330 """

331 vec_a = np.asarray(vec_a, dtype=np.float64)

332 vec_b = np.asarray(vec_b, dtype=np.float64)

333

334 if vec_a.shape != vec_b.shape:

335 raise ValueError(f"Shape mismatch: {vec_a.shape} vs {vec_b.shape}")

336

337 # Convert string to enum

338 if isinstance(metric, str):

339 try:

340 metric = DistanceMetric(metric.lower())

341 except ValueError:

342 valid_metrics = [m.value for m in DistanceMetric]

343 raise ValueError(f"Unknown metric '{metric}'. Valid metrics: {valid_metrics}")

344

345 # Dispatch to appropriate function

346 metric_functions: dict[DistanceMetric, Callable[..., MetricResult]] = {

347 DistanceMetric.COSINE: cosine_distance,

348 DistanceMetric.EUCLIDEAN: euclidean_distance,

349 DistanceMetric.MANHATTAN: manhattan_distance,

350 DistanceMetric.CHEBYSHEV: chebyshev_distance,

351 DistanceMetric.MAHALANOBIS: mahalanobis_distance,

352 }

353

354 func = metric_functions[metric]

355 return func(vec_a, vec_b, **kwargs)

356

357

358 def get_available_metrics() -> list[str]:

359 """Return list of available distance metric names."""

360 return [m.value for m in DistanceMetric]

361

362

363# ============================================================================

364# Weighted Distance Functions

365# ============================================================================

366

367

368 def weighted_euclidean_distance(

369 vec_a: np.ndarray,

370 vec_b: np.ndarray,

371 weights: np.ndarray | list[float] | None = None,

372 expected_range: tuple[float, float] | None = None,

373) -> MetricResult:

374 """

375 Calculate weighted Euclidean distance between two vectors.

376

377 Allows certain dimensions to contribute more to the overall distance.

378

379 Args:

380 vec_a: First vector

381 vec_b: Second vector

382 weights: Weight for each dimension. If None, uses uniform weights.

383 expected_range: Optional (min, max) expected range for normalization.

384

385 Returns:

386 MetricResult with weighted Euclidean distance

387 """

388 weights_arr: np.ndarray

389 if weights is None:

390 weights_arr = np.ones(len(vec_a))

391 else:

392 weights_arr = np.asarray(weights, dtype=np.float64)

393 if len(weights_arr) != len(vec_a):

394 raise ValueError(f"Weight length {len(weights_arr)} != vector length {len(vec_a)}")

395

396 # Normalize weights to sum to number of dimensions (preserve scale)

397 weight_sum = float(np.sum(weights_arr))

398 weights_arr = weights_arr * len(weights_arr) / weight_sum

399

400 diff = vec_a - vec_b

401 weighted_diff_sq = weights_arr * (diff**2)

402 dist = float(np.sqrt(np.sum(weighted_diff_sq)))

403

404 # Per-dimension weighted contributions

405 weighted_contributions = weighted_diff_sq / (dist**2) if dist > 0 else np.zeros_like(diff)

406

407 # Normalization

408 if expected_range is not None:

409 min_val, max_val = expected_range

410 max_possible_dist = np.sqrt(np.sum(weights_arr * ((max_val - min_val) ** 2)))

411 else:

412 max_possible_dist = np.sqrt(np.sum(weights_arr * 4)) # Assuming [-1, 1] range

413

414 normalized = min(dist / max_possible_dist, 1.0) if max_possible_dist > 0 else 0.0

415

416 return MetricResult(

417 distance=dist,

418 normalized=float(normalized),

419 metric=DistanceMetric.EUCLIDEAN,

420 details={

421 "weighted": True,

422 "weights": weights_arr.tolist() if len(weights_arr) <= 20 else None,

423 "weighted_contributions": (

424 weighted_contributions.tolist() if len(weighted_contributions) <= 20 else None

425 ),

426 "top_contributing_dimension": int(np.argmax(weighted_contributions)),

427 "top_contribution_value": float(np.max(weighted_contributions)),

428 },

429 )

430

431

432 def calculate_weighted_distance(

433 vec_a: ArrayLike,

434 vec_b: ArrayLike,

435 weights: ArrayLike | None = None,

436 metric: str | DistanceMetric = "euclidean",

437 **kwargs: Any,

438) -> MetricResult:

439 """

440 Calculate weighted distance between two vectors.

441

442 Currently supports weighted versions of:

443 - euclidean: Weighted Euclidean distance

444

445 For other metrics, weights are not applied (falls back to unweighted).

446

447 Args:

448 vec_a: First vector

449 vec_b: Second vector

450 weights: Weight for each dimension

451 metric: Distance metric to use

452 **kwargs: Additional arguments

453

454 Returns:

455 MetricResult with distance score

456 """

457 vec_a = np.asarray(vec_a, dtype=np.float64)

458 vec_b = np.asarray(vec_b, dtype=np.float64)

459

460 if isinstance(metric, str):

461 try:

462 metric = DistanceMetric(metric.lower())

463 except ValueError:

464 valid_metrics = [m.value for m in DistanceMetric]

465 raise ValueError(f"Unknown metric '{metric}'. Valid metrics: {valid_metrics}")

466

467 if weights is not None and metric == DistanceMetric.EUCLIDEAN:

468 return weighted_euclidean_distance(vec_a, vec_b, weights=weights, **kwargs)

469 else:

470 # Fall back to unweighted calculation

471 return calculate_distance(vec_a, vec_b, metric=metric, **kwargs)