-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwaddiv.py
More file actions
266 lines (210 loc) · 7.99 KB
/
Copy pathwaddiv.py
File metadata and controls
266 lines (210 loc) · 7.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
import numpy as np
from scipy.spatial.distance import cdist
from scipy.stats import wasserstein_distance
class WADDiv:
"""
Wasserstein Distance-based Generative Diversity (WAD-Div)
Computes the 1D Wasserstein distance between:
- Observed kNN distance distribution
- A chosen reference distribution
Supports:
- Raw WAD-Div
- Normalized WAD-Div anchored to a maximally diverse dataset
Notes
-----
Normalization requires calling `fit_max_reference()` first.
The same metric configuration (k, reference type, etc.)
must be used for fitting and evaluation.
"""
def __init__(self, distance_metric: str = "euclidean", saturation: float = 0.99):
"""
Parameters
----------
distance_metric : str
Distance metric used for pairwise feature distances.
Passed to scipy.spatial.distance.cdist.
saturation : float
Target saturation level t in normalization.
A dataset with W = W_max will obtain W_norm ≈ t.
Typical value: 0.99
"""
self.distance_metric = distance_metric
self.saturation = saturation
self.W_max = None
self._max_config = None # stores configuration used for W_max
# ============================================================
# Public API
# ============================================================
def fit_max_reference(
self,
features: np.ndarray,
k: int = 3,
reference: str = "zero",
ref_features: np.ndarray = None,
ref_sample_size: int = 10000,
ref_percentile: float = 95.0,
ref_prob: float = 0.95,
):
"""
Fit maximal diversity anchor W_max.
Typically computed on a real dataset.
IMPORTANT:
The same parameters must be used later when calling
`compute(normalize=True)`.
"""
result = self.compute(
features=features,
k=k,
reference=reference,
ref_features=ref_features,
ref_sample_size=ref_sample_size,
ref_percentile=ref_percentile,
ref_prob=ref_prob,
normalize=False,
)
self.W_max = result["wad_div"]
# Store configuration for safety
self._max_config = dict(
k=k,
reference=reference,
ref_percentile=ref_percentile,
ref_prob=ref_prob,
ref_sample_size=ref_sample_size,
)
return self.W_max
def compute(
self,
features: np.ndarray,
k: int = 3,
reference: str = "zero",
ref_features: np.ndarray = None,
ref_sample_size: int = 10000,
ref_percentile: float = 95.0,
ref_prob: float = 0.95,
random_state: int = None,
normalize: bool = False,
return_distributions: bool = False,
normalization_method: str = "linear",
):
"""
Compute WAD-Div score.
Parameters
----------
features : np.ndarray (N x D)
Feature embeddings of dataset to evaluate.
k : int
Number of nearest neighbors used to construct
the local distance distribution.
reference : {"zero", "exponential", "empirical"}
Type of reference distribution.
ref_features : np.ndarray
Required if reference="empirical".
Feature embeddings of reference dataset.
ref_sample_size : int
Sample size for synthetic reference distributions
(zero or exponential).
ref_percentile : float
Percentile of observed distances used to fit
exponential reference.
ref_prob : float
Target probability P(X <= x_ref) for exponential reference.
random_state : int
Optional random seed for reproducibility.
normalize : bool
If True, returns normalized WAD-Div in [0,1).
return_distributions : bool
If True, returns raw observed and reference
distance distributions.
normalization_method : str
Choose between "linear" or "paper"
"""
if random_state is not None:
np.random.seed(random_state)
obs_dist = self._compute_knn_distribution(features, k)
if reference == "zero":
ref_dist = self._zero_reference(ref_sample_size)
elif reference == "exponential":
ref_dist = self._exponential_reference(
obs_dist,
ref_sample_size,
ref_percentile,
ref_prob,
)
elif reference == "empirical":
if ref_features is None:
raise ValueError("ref_features required for empirical reference.")
ref_dist = self._compute_knn_distribution(ref_features, k)
else:
raise ValueError(f"Unknown reference type: {reference}")
W = float(wasserstein_distance(obs_dist, ref_dist))
# ---------------- Normalization ----------------
if normalize:
if self.W_max is None:
raise RuntimeError(
"W_max not set. Call fit_max_reference() first."
)
# Safety check
if self._max_config is not None:
if k != self._max_config["k"] or reference != self._max_config["reference"]:
raise ValueError(
"Normalization configuration mismatch with fitted W_max."
)
# -------------------------------
# Choose normalization method
# -------------------------------
# "linear": robust, W_norm = W / W_max
# "paper": original formula from the BVM26 paper
method = normalization_method
if method == "linear":
# Collapse → 0, real/reference → ~1
W_norm = np.clip(W / self.W_max, 0.0, 1.0)
elif method == "paper":
# Original paper formula:
# W_norm = W / (W + s), s = W_max * (1-t)/t
# NOTE: Assumes W_max was computed over multiple datasets.
# For single synthetic dataset, this can inflate W_norm.
t = self.saturation
s = self.W_max * (1 - t) / t
W_norm = W / (W + s) if W > 0 else 0.0
else:
raise ValueError(f"Unknown normalization method: {method}")
else:
W_norm = None
output = {
"wad_div": W,
"wad_div_norm": W_norm,
"k": k,
"reference": reference,
"n_obs": len(obs_dist),
}
if return_distributions:
output["obs_dist"] = obs_dist
output["ref_dist"] = ref_dist
return output
# ============================================================
# Internal Methods
# ============================================================
def _compute_knn_distribution(self, features: np.ndarray, k: int):
if features.ndim != 2:
raise ValueError("Features must be of shape (N, D)")
if k >= features.shape[0]:
raise ValueError("k must be smaller than number of samples.")
dists = cdist(features, features, metric=self.distance_metric)
np.fill_diagonal(dists, np.inf)
topk = np.sort(dists, axis=1)[:, :k]
return topk.flatten()
def _zero_reference(self, size: int):
return np.zeros(size)
def _exponential_reference(
self,
obs_dist: np.ndarray,
ref_sample_size: int,
ref_percentile: float,
ref_prob: float,
):
x_ref = float(np.percentile(obs_dist, ref_percentile))
if x_ref <= 0 or ref_prob <= 0:
return np.zeros(ref_sample_size)
lam = -np.log(1.0 - ref_prob) / max(x_ref, 1e-12)
lam = np.clip(lam, 1e-12, 1e6)
return np.random.exponential(scale=1.0 / lam, size=ref_sample_size)