datacarve 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datacarve/__init__.py +24 -0
- datacarve/core.py +878 -0
- datacarve-0.1.0.dist-info/METADATA +298 -0
- datacarve-0.1.0.dist-info/RECORD +7 -0
- datacarve-0.1.0.dist-info/WHEEL +5 -0
- datacarve-0.1.0.dist-info/licenses/LICENSE +21 -0
- datacarve-0.1.0.dist-info/top_level.txt +1 -0
datacarve/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
datacarve: distributional dataset undersampling via MILP optimization.
|
|
3
|
+
|
|
4
|
+
Carve a compact, distribution-shaped subset out of a large dataset.
|
|
5
|
+
Given a dataset and a target distribution, datacarve selects the optimal
|
|
6
|
+
combination of datapoints that (1) follows the target distribution across
|
|
7
|
+
every dimension simultaneously and (2) minimizes linear correlations
|
|
8
|
+
between dimensions.
|
|
9
|
+
|
|
10
|
+
Basic usage:
|
|
11
|
+
|
|
12
|
+
>>> from datacarve import undersample_dataset
|
|
13
|
+
>>> mask = undersample_dataset(data, data_to_keep=1000)
|
|
14
|
+
>>> subset = data[mask]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from datacarve.core import (
|
|
18
|
+
plot_scatter_matrix,
|
|
19
|
+
prereduce_dataset,
|
|
20
|
+
undersample_dataset,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["undersample_dataset", "prereduce_dataset", "plot_scatter_matrix"]
|
|
24
|
+
__version__ = "0.1.0"
|
datacarve/core.py
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Distributional dataset undersampling via Mixed Integer Linear Programming.
|
|
4
|
+
|
|
5
|
+
Given a large dataset, select an optimal subset of datapoints that
|
|
6
|
+
(1) follows a target distribution across every dimension simultaneously and
|
|
7
|
+
(2) minimizes linear correlations between dimensions.
|
|
8
|
+
|
|
9
|
+
The optimization is formulated as a MILP and solved with Google OR-Tools
|
|
10
|
+
(CBC backend).
|
|
11
|
+
|
|
12
|
+
If you use this code for research purposes, please cite:
|
|
13
|
+
|
|
14
|
+
1. Vonikakis, V., Subramanian, R., Arnfred, J., & Winkler, S.
|
|
15
|
+
A Probabilistic Approach to People-Centric Photo Selection and Sequencing.
|
|
16
|
+
IEEE Transactions in Multimedia, 11(19), pp.2609-2624, 2017.
|
|
17
|
+
2. Vonikakis, V., Subramanian, R., & Winkler, S.
|
|
18
|
+
Shaping Datasets: Optimal Data Selection for Specific Target Distributions.
|
|
19
|
+
Proc. ICIP2016, Phoenix, USA, Sept. 25-28, 2016.
|
|
20
|
+
|
|
21
|
+
Author: Vasileios Vonikakis
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Literal, Sequence
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from ortools.linear_solver import pywraplp
|
|
30
|
+
from scipy import stats
|
|
31
|
+
|
|
32
|
+
__all__ = ["undersample_dataset", "prereduce_dataset", "plot_scatter_matrix"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
TargetDistribution = Literal["uniform", "gaussian", "weibull", "triangular"]
|
|
36
|
+
|
|
37
|
+
# Human-readable names for the ortools result status codes.
|
|
38
|
+
_RESULT_STATUS = {
|
|
39
|
+
pywraplp.Solver.OPTIMAL: "optimal",
|
|
40
|
+
pywraplp.Solver.FEASIBLE: "feasible",
|
|
41
|
+
pywraplp.Solver.INFEASIBLE: "infeasible",
|
|
42
|
+
pywraplp.Solver.ABNORMAL: "abnormal",
|
|
43
|
+
pywraplp.Solver.NOT_SOLVED: "not solved",
|
|
44
|
+
pywraplp.Solver.UNBOUNDED: "unbounded",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_target_pdf(
|
|
49
|
+
target_distribution: TargetDistribution | Sequence[float],
|
|
50
|
+
bins: int,
|
|
51
|
+
) -> np.ndarray:
|
|
52
|
+
"""
|
|
53
|
+
Build the (normalized) target histogram over the quantization bins.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
target_distribution : str or sequence of float
|
|
58
|
+
Either one of the built-in distribution names
|
|
59
|
+
('uniform', 'gaussian', 'weibull', 'triangular'), or a custom
|
|
60
|
+
sequence of ``bins`` non-negative weights (one per bin).
|
|
61
|
+
bins : int
|
|
62
|
+
Number of quantization bins.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
numpy.ndarray of shape (bins,)
|
|
67
|
+
Target probability mass per bin, normalized to sum to 1.
|
|
68
|
+
"""
|
|
69
|
+
x = np.arange(1, bins + 1) - 0.5 # bin centers
|
|
70
|
+
|
|
71
|
+
if isinstance(target_distribution, str):
|
|
72
|
+
if target_distribution == "uniform":
|
|
73
|
+
pdf = stats.uniform.pdf(x, loc=0, scale=bins)
|
|
74
|
+
elif target_distribution == "gaussian":
|
|
75
|
+
pdf = stats.norm.pdf(x, loc=bins / 2, scale=1)
|
|
76
|
+
elif target_distribution == "weibull":
|
|
77
|
+
pdf = stats.weibull_min.pdf(x, c=5, loc=2, scale=1)
|
|
78
|
+
elif target_distribution == "triangular":
|
|
79
|
+
pdf = stats.triang.pdf(x, c=0.75, loc=0, scale=bins)
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Unknown target_distribution '{target_distribution}'. "
|
|
83
|
+
"Expected one of: 'uniform', 'gaussian', 'weibull', "
|
|
84
|
+
"'triangular', or a custom array of bin weights."
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
pdf = np.asarray(target_distribution, dtype=float)
|
|
88
|
+
if pdf.shape != (bins,):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Custom target_distribution must have shape ({bins},) to "
|
|
91
|
+
f"match the number of bins, got {pdf.shape}."
|
|
92
|
+
)
|
|
93
|
+
if np.any(pdf < 0):
|
|
94
|
+
raise ValueError("Custom target_distribution weights must be >= 0.")
|
|
95
|
+
|
|
96
|
+
total = pdf.sum()
|
|
97
|
+
if total <= 0:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"Target distribution has zero total mass over the requested bins."
|
|
100
|
+
)
|
|
101
|
+
return pdf / total # normalize so bin counts sum to ~data_to_keep
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _normalize_target_specs(
|
|
105
|
+
target_distribution: (
|
|
106
|
+
TargetDistribution
|
|
107
|
+
| Sequence[float]
|
|
108
|
+
| Sequence[TargetDistribution | Sequence[float]]
|
|
109
|
+
),
|
|
110
|
+
n_dimensions: int,
|
|
111
|
+
) -> list:
|
|
112
|
+
"""
|
|
113
|
+
Expand the ``target_distribution`` argument into one spec per dimension.
|
|
114
|
+
|
|
115
|
+
Accepted forms:
|
|
116
|
+
|
|
117
|
+
* a single name ('uniform', ...): applied to every dimension;
|
|
118
|
+
* a flat sequence of numbers: a single custom histogram applied to
|
|
119
|
+
every dimension;
|
|
120
|
+
* a sequence of specs (names and/or nested weight sequences), one per
|
|
121
|
+
dimension.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
list of length n_dimensions
|
|
126
|
+
One target spec (str or array-like of weights) per dimension.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(target_distribution, str):
|
|
129
|
+
return [target_distribution] * n_dimensions
|
|
130
|
+
|
|
131
|
+
specs = list(target_distribution)
|
|
132
|
+
|
|
133
|
+
# a flat sequence of numbers = one custom histogram for all dimensions
|
|
134
|
+
if all(isinstance(el, (int, float, np.integer, np.floating))
|
|
135
|
+
for el in specs):
|
|
136
|
+
return [specs] * n_dimensions
|
|
137
|
+
|
|
138
|
+
# otherwise: one spec per dimension
|
|
139
|
+
if len(specs) != n_dimensions:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Per-dimension target_distribution must have one spec per "
|
|
142
|
+
f"dimension ({n_dimensions}), got {len(specs)}."
|
|
143
|
+
)
|
|
144
|
+
return specs
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _pairwise_correlation_cost(
|
|
148
|
+
data: np.ndarray, avg: np.ndarray | float
|
|
149
|
+
) -> np.ndarray:
|
|
150
|
+
"""
|
|
151
|
+
Per-datapoint cost used to discourage linear correlations (2nd objective).
|
|
152
|
+
|
|
153
|
+
For each datapoint k, computes
|
|
154
|
+
``v[k] = sum over all dimension pairs (i < j) of |x_ki - avg| * |x_kj - avg|``
|
|
155
|
+
which penalizes points that deviate from the expected mean in several
|
|
156
|
+
dimensions at once (such points contribute most to cross-correlation).
|
|
157
|
+
|
|
158
|
+
Vectorized using the identity:
|
|
159
|
+
``sum_{i<j} d_i d_j = ((sum_i d_i)^2 - sum_i d_i^2) / 2``
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
data : numpy.ndarray of shape (N, M)
|
|
164
|
+
Scaled dataset.
|
|
165
|
+
avg : float or numpy.ndarray of shape (M,)
|
|
166
|
+
Expected mean value of the target distribution, either shared by
|
|
167
|
+
all dimensions or one value per dimension.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
numpy.ndarray of shape (N,)
|
|
172
|
+
Correlation cost per datapoint.
|
|
173
|
+
"""
|
|
174
|
+
d = np.abs(data - avg) # (N, M), avg broadcasts per column
|
|
175
|
+
return (d.sum(axis=1) ** 2 - (d**2).sum(axis=1)) / 2.0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _minmax_scale(data: np.ndarray) -> np.ndarray:
|
|
179
|
+
"""Min-max scale each column to [0, 1] (constant columns map to 0)."""
|
|
180
|
+
data_min = data.min(axis=0)
|
|
181
|
+
data_max = data.max(axis=0)
|
|
182
|
+
span = data_max - data_min
|
|
183
|
+
span[span == 0] = 1.0 # constant features: avoid division by zero
|
|
184
|
+
return (data - data_min) / span
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _quantize(
|
|
188
|
+
data: np.ndarray, bins: int, categorical_set: set[int]
|
|
189
|
+
) -> tuple[np.ndarray, list[int]]:
|
|
190
|
+
"""
|
|
191
|
+
Quantize each column into bin indices.
|
|
192
|
+
|
|
193
|
+
Numeric columns (expected in [0, 1]) get ``bins`` equal-width bins;
|
|
194
|
+
categorical columns get one bin per unique value.
|
|
195
|
+
|
|
196
|
+
Returns
|
|
197
|
+
-------
|
|
198
|
+
(data_quantized, n_bins_per_dim)
|
|
199
|
+
Integer bin index per (row, column), and the number of bins used
|
|
200
|
+
by each column.
|
|
201
|
+
"""
|
|
202
|
+
n_observations, n_dimensions = data.shape
|
|
203
|
+
data_quantized = np.zeros((n_observations, n_dimensions), dtype=int)
|
|
204
|
+
n_bins_per_dim: list[int] = []
|
|
205
|
+
edges = np.linspace(0, 1, bins + 1)
|
|
206
|
+
|
|
207
|
+
for m in range(n_dimensions):
|
|
208
|
+
if m in categorical_set:
|
|
209
|
+
_, codes = np.unique(data[:, m], return_inverse=True)
|
|
210
|
+
data_quantized[:, m] = codes
|
|
211
|
+
n_bins_per_dim.append(int(codes.max()) + 1)
|
|
212
|
+
else:
|
|
213
|
+
# digitize returns indices in [1, bins+1]; shift to [0, bins-1]
|
|
214
|
+
q = np.digitize(data[:, m], bins=edges) - 1
|
|
215
|
+
q[q == bins] = bins - 1 # datapoints exactly at 1.0
|
|
216
|
+
data_quantized[:, m] = q
|
|
217
|
+
n_bins_per_dim.append(bins)
|
|
218
|
+
|
|
219
|
+
return data_quantized, n_bins_per_dim
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cell_ids(
|
|
223
|
+
data_quantized: np.ndarray, n_bins_per_dim: Sequence[int]
|
|
224
|
+
) -> np.ndarray:
|
|
225
|
+
"""Joint quantization-cell id per row (mixed-radix over the columns)."""
|
|
226
|
+
total_cells = 1
|
|
227
|
+
for b in n_bins_per_dim:
|
|
228
|
+
total_cells *= int(b)
|
|
229
|
+
if total_cells < 2**62:
|
|
230
|
+
radix = np.ones(len(n_bins_per_dim), dtype=np.int64)
|
|
231
|
+
acc = 1
|
|
232
|
+
for m in range(len(n_bins_per_dim) - 1, -1, -1):
|
|
233
|
+
radix[m] = acc
|
|
234
|
+
acc *= int(n_bins_per_dim[m])
|
|
235
|
+
return (data_quantized.astype(np.int64) * radix).sum(axis=1)
|
|
236
|
+
# gigantic bin products: fall back to row-wise unique
|
|
237
|
+
return np.unique(data_quantized, axis=0, return_inverse=True)[1]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _adaptive_cap(cell_counts: np.ndarray, pool_target: int) -> int:
|
|
241
|
+
"""
|
|
242
|
+
Largest per-cell cap whose resulting pool stays within pool_target.
|
|
243
|
+
|
|
244
|
+
Uses the monotone function f(C) = sum(min(count, C)); returns 1 if
|
|
245
|
+
even a cap of 1 exceeds the target (one row per occupied cell).
|
|
246
|
+
"""
|
|
247
|
+
counts_sorted = np.sort(cell_counts)
|
|
248
|
+
prefix = np.concatenate([[0], np.cumsum(counts_sorted)])
|
|
249
|
+
|
|
250
|
+
def pool_size(cap: int) -> int:
|
|
251
|
+
idx = np.searchsorted(counts_sorted, cap, side="right")
|
|
252
|
+
return int(prefix[idx] + cap * (len(counts_sorted) - idx))
|
|
253
|
+
|
|
254
|
+
lo, hi = 1, int(counts_sorted[-1])
|
|
255
|
+
while lo < hi:
|
|
256
|
+
mid = (lo + hi + 1) // 2
|
|
257
|
+
if pool_size(mid) <= pool_target:
|
|
258
|
+
lo = mid
|
|
259
|
+
else:
|
|
260
|
+
hi = mid - 1
|
|
261
|
+
return lo
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _cap_cells(
|
|
265
|
+
cell_ids: np.ndarray,
|
|
266
|
+
cap_per_cell: int,
|
|
267
|
+
rng: np.random.Generator,
|
|
268
|
+
) -> np.ndarray:
|
|
269
|
+
"""
|
|
270
|
+
Cap the number of rows per joint quantization cell.
|
|
271
|
+
|
|
272
|
+
Rows sharing the same joint bin signature across all columns are
|
|
273
|
+
interchangeable with respect to every histogram constraint, so cells
|
|
274
|
+
with more than ``cap_per_cell`` rows are randomly downsampled to the
|
|
275
|
+
cap, while all rows of smaller (rare) cells are kept.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
numpy.ndarray of bool, shape (N,)
|
|
280
|
+
True for rows to keep.
|
|
281
|
+
"""
|
|
282
|
+
n = cell_ids.shape[0]
|
|
283
|
+
|
|
284
|
+
# group rows by cell, in random order within each cell
|
|
285
|
+
perm = rng.permutation(n)
|
|
286
|
+
order = perm[np.argsort(cell_ids[perm], kind="stable")]
|
|
287
|
+
sorted_cells = cell_ids[order]
|
|
288
|
+
|
|
289
|
+
# rank of each row within its cell (0-based, random by construction)
|
|
290
|
+
is_start = np.ones(n, dtype=bool)
|
|
291
|
+
is_start[1:] = sorted_cells[1:] != sorted_cells[:-1]
|
|
292
|
+
group_starts = np.flatnonzero(is_start)
|
|
293
|
+
group_sizes = np.diff(np.append(group_starts, n))
|
|
294
|
+
ranks = np.arange(n) - np.repeat(group_starts, group_sizes)
|
|
295
|
+
|
|
296
|
+
mask = np.zeros(n, dtype=bool)
|
|
297
|
+
mask[order[ranks < cap_per_cell]] = True
|
|
298
|
+
return mask
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def prereduce_dataset(
|
|
302
|
+
data: np.ndarray,
|
|
303
|
+
cap_per_cell: int,
|
|
304
|
+
bins: int = 10,
|
|
305
|
+
categorical_dims: Sequence[int] | None = None,
|
|
306
|
+
data_scaling: Literal["minmax"] | None = "minmax",
|
|
307
|
+
seed: int | None = None,
|
|
308
|
+
verbose: bool = True,
|
|
309
|
+
) -> np.ndarray:
|
|
310
|
+
"""
|
|
311
|
+
Pre-reduce a very large dataset so that ``undersample_dataset`` can
|
|
312
|
+
handle it, without destroying rare groups.
|
|
313
|
+
|
|
314
|
+
Rows are grouped by their joint quantization cell (the combination of
|
|
315
|
+
bin indices across all columns). Cells with at most ``cap_per_cell``
|
|
316
|
+
rows are kept in full — rare groups are never touched — while
|
|
317
|
+
overcrowded cells are randomly downsampled to the cap. Rows within a
|
|
318
|
+
cell are interchangeable with respect to every histogram constraint,
|
|
319
|
+
so this loses (almost) nothing that the MILP could have used.
|
|
320
|
+
|
|
321
|
+
This is preferable to naive random subsampling, which preserves the
|
|
322
|
+
skew you are trying to fix and can wipe out rare categories entirely.
|
|
323
|
+
|
|
324
|
+
Parameters
|
|
325
|
+
----------
|
|
326
|
+
data : numpy.ndarray of shape (N, M)
|
|
327
|
+
Dataset of N observations and M dimensions.
|
|
328
|
+
cap_per_cell : int
|
|
329
|
+
Maximum number of rows to keep per joint quantization cell. A
|
|
330
|
+
safe choice is your intended ``data_to_keep``: no solution can
|
|
331
|
+
ever use more rows than that from a single cell.
|
|
332
|
+
bins : int
|
|
333
|
+
Number of quantization bins per numeric dimension. Should match
|
|
334
|
+
the ``bins`` you will pass to ``undersample_dataset``. With many
|
|
335
|
+
dimensions or fine bins the joint cells become sparse and the
|
|
336
|
+
reduction shrinks; a coarser value recovers it.
|
|
337
|
+
categorical_dims : sequence of int, optional
|
|
338
|
+
Column indices to treat as categorical (one cell slot per unique
|
|
339
|
+
value). Should match the ``undersample_dataset`` call.
|
|
340
|
+
data_scaling : {'minmax', None}
|
|
341
|
+
Scaling applied before quantization (same as
|
|
342
|
+
``undersample_dataset``).
|
|
343
|
+
seed : int, optional
|
|
344
|
+
Seed for the random downsampling of overcrowded cells.
|
|
345
|
+
verbose : bool
|
|
346
|
+
Whether to print the reduction summary.
|
|
347
|
+
|
|
348
|
+
Returns
|
|
349
|
+
-------
|
|
350
|
+
numpy.ndarray of bool, shape (N,)
|
|
351
|
+
True for rows kept in the reduced pool.
|
|
352
|
+
|
|
353
|
+
Examples
|
|
354
|
+
--------
|
|
355
|
+
>>> pre_mask = prereduce_dataset(huge, cap_per_cell=1000)
|
|
356
|
+
>>> sub_mask = undersample_dataset(huge[pre_mask], data_to_keep=1000)
|
|
357
|
+
>>> final = np.zeros(len(huge), dtype=bool)
|
|
358
|
+
>>> final[np.flatnonzero(pre_mask)[sub_mask]] = True
|
|
359
|
+
|
|
360
|
+
Or simply pass ``prereduce=`` to ``undersample_dataset``, which runs
|
|
361
|
+
both stages and returns a single mask over the original rows.
|
|
362
|
+
"""
|
|
363
|
+
data = np.asarray(data, dtype=float)
|
|
364
|
+
if data.ndim != 2:
|
|
365
|
+
raise ValueError(f"data must be a 2D array [N, M], got shape {data.shape}")
|
|
366
|
+
if cap_per_cell < 1:
|
|
367
|
+
raise ValueError(f"cap_per_cell must be >= 1, got {cap_per_cell}")
|
|
368
|
+
|
|
369
|
+
n_dimensions = data.shape[1]
|
|
370
|
+
categorical_set = set(int(c) for c in categorical_dims or [])
|
|
371
|
+
if categorical_set and not all(
|
|
372
|
+
0 <= c < n_dimensions for c in categorical_set
|
|
373
|
+
):
|
|
374
|
+
raise ValueError(
|
|
375
|
+
f"categorical_dims entries must be column indices in "
|
|
376
|
+
f"[0, {n_dimensions - 1}], got {sorted(categorical_set)}."
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if data_scaling == "minmax":
|
|
380
|
+
data = _minmax_scale(data)
|
|
381
|
+
|
|
382
|
+
data_quantized, n_bins_per_dim = _quantize(data, bins, categorical_set)
|
|
383
|
+
mask = _cap_cells(
|
|
384
|
+
_cell_ids(data_quantized, n_bins_per_dim), cap_per_cell,
|
|
385
|
+
np.random.default_rng(seed),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if verbose:
|
|
389
|
+
print(
|
|
390
|
+
f"Pre-reduction: {len(mask):,} -> {int(mask.sum()):,} rows "
|
|
391
|
+
f"(cap {cap_per_cell}/cell)"
|
|
392
|
+
)
|
|
393
|
+
return mask
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def undersample_dataset(
|
|
397
|
+
data: np.ndarray,
|
|
398
|
+
data_to_keep: int = 1000,
|
|
399
|
+
data_scaling: Literal["minmax"] | None = "minmax",
|
|
400
|
+
target_distribution: (
|
|
401
|
+
TargetDistribution
|
|
402
|
+
| Sequence[float]
|
|
403
|
+
| Sequence[TargetDistribution | Sequence[float]]
|
|
404
|
+
) = "uniform",
|
|
405
|
+
bins: int = 10,
|
|
406
|
+
categorical_dims: Sequence[int] | None = None,
|
|
407
|
+
lamda: float = 0.5,
|
|
408
|
+
prereduce: int | Literal["auto"] | None = None,
|
|
409
|
+
solver: Literal["CBC", "SCIP", "SAT"] = "CBC",
|
|
410
|
+
max_solver_time_sec: float = 10.0,
|
|
411
|
+
verbose: bool = True,
|
|
412
|
+
scatterplot_matrix: bool | Literal["auto"] = "auto",
|
|
413
|
+
) -> np.ndarray:
|
|
414
|
+
"""
|
|
415
|
+
Undersample a dataset by imposing distributional and correlational
|
|
416
|
+
constraints across its dimensions.
|
|
417
|
+
|
|
418
|
+
Runs a mixed integer linear program (MILP) to find the optimal
|
|
419
|
+
combination of ``data_to_keep`` datapoints whose per-dimension histograms
|
|
420
|
+
are as close as possible to the given target distribution, while
|
|
421
|
+
(optionally) minimizing linear correlations between dimensions.
|
|
422
|
+
|
|
423
|
+
Parameters
|
|
424
|
+
----------
|
|
425
|
+
data : numpy.ndarray of shape (N, M)
|
|
426
|
+
Dataset of N observations and M dimensions.
|
|
427
|
+
data_to_keep : int
|
|
428
|
+
Number of datapoints to keep from the original dataset, in [1, N].
|
|
429
|
+
data_scaling : {'minmax', None}
|
|
430
|
+
Scaling applied to each feature before quantization. With None,
|
|
431
|
+
no scaling is applied and the data is expected to lie in [0, 1].
|
|
432
|
+
target_distribution : str, array-like, or sequence of these
|
|
433
|
+
Distribution to enforce on the undersampled dataset. Three forms
|
|
434
|
+
are accepted:
|
|
435
|
+
|
|
436
|
+
* a single name ('uniform', 'gaussian', 'weibull', 'triangular'):
|
|
437
|
+
enforced on every dimension. 'uniform' produces a balanced
|
|
438
|
+
dataset;
|
|
439
|
+
* a flat sequence of non-negative weights (one per bin): a single
|
|
440
|
+
custom histogram enforced on every dimension;
|
|
441
|
+
* a sequence with one spec per dimension, mixing names and custom
|
|
442
|
+
weight sequences, e.g. ``['uniform', 'gaussian', [1, 2, 3]]``
|
|
443
|
+
for 3-dimensional data. For categorical dimensions, custom
|
|
444
|
+
weights must have one entry per category.
|
|
445
|
+
bins : int
|
|
446
|
+
Number of bins into which each numeric dimension is quantized for
|
|
447
|
+
the integer program. Categorical dimensions ignore this and use
|
|
448
|
+
one bin per unique value.
|
|
449
|
+
categorical_dims : sequence of int, optional
|
|
450
|
+
Column indices to treat as categorical. Each unique value in such
|
|
451
|
+
a column becomes its own bin, so a 'uniform' target means "equal
|
|
452
|
+
counts per category". Values must be numeric (e.g. label-encoded);
|
|
453
|
+
the encoding order is only used for reporting, not for binning
|
|
454
|
+
width.
|
|
455
|
+
lamda : float
|
|
456
|
+
Balance between the two objectives: distribution matching vs
|
|
457
|
+
correlation minimization. ``lamda=0`` uses only distributional
|
|
458
|
+
constraints; larger values weight correlation minimization more.
|
|
459
|
+
prereduce : int, 'auto', or None
|
|
460
|
+
Pre-reduce very large datasets before building the MILP, using
|
|
461
|
+
joint-cell capping (see ``prereduce_dataset``): rows are grouped
|
|
462
|
+
by their joint bin signature, rare cells are kept in full, and
|
|
463
|
+
overcrowded cells are randomly downsampled to a cap. The returned
|
|
464
|
+
mask always refers to the *original* rows.
|
|
465
|
+
|
|
466
|
+
* ``None`` (default) — no pre-reduction.
|
|
467
|
+
* an int — always pre-reduce, capping each joint cell at that
|
|
468
|
+
many rows.
|
|
469
|
+
* ``'auto'`` — pre-reduce only when the dataset exceeds 200,000
|
|
470
|
+
rows, choosing the largest per-cell cap whose reduced pool
|
|
471
|
+
stays within max(100,000, 5 * data_to_keep) rows — large
|
|
472
|
+
enough to leave the solver real freedom, small enough to solve
|
|
473
|
+
in seconds.
|
|
474
|
+
|
|
475
|
+
The random downsampling uses a fixed internal seed, so results
|
|
476
|
+
are reproducible; use ``prereduce_dataset`` directly for control
|
|
477
|
+
over the seed or the grouping granularity.
|
|
478
|
+
solver : {'CBC', 'SCIP', 'SAT'}
|
|
479
|
+
MILP solver backend (all free and bundled with OR-Tools, no extra
|
|
480
|
+
installation needed). Guidance on choosing:
|
|
481
|
+
|
|
482
|
+
* ``'CBC'`` (default) — classic branch-and-bound. Fastest on easy
|
|
483
|
+
and moderately sized problems; a good first choice. If it
|
|
484
|
+
reports 'optimal' within the time budget, there is no reason to
|
|
485
|
+
switch.
|
|
486
|
+
* ``'SAT'`` (CP-SAT) — clause-learning search. On hard instances
|
|
487
|
+
that exhaust the time budget (status 'feasible' instead of
|
|
488
|
+
'optimal'), it typically finds *better* solutions than CBC in
|
|
489
|
+
the same time. Use it when your problem is large or highly
|
|
490
|
+
constrained and solution quality matters more than speed.
|
|
491
|
+
Note: slack variables are modeled as integers for this backend
|
|
492
|
+
(mathematically equivalent for this problem).
|
|
493
|
+
* ``'SCIP'`` — a modern branch-and-cut solver. Worth trying when
|
|
494
|
+
CBC struggles to *prove* optimality on medium-hard instances.
|
|
495
|
+
|
|
496
|
+
Rule of thumb: start with 'CBC'; if the result status is
|
|
497
|
+
'feasible' (time limit hit), re-run with 'SAT' and/or a larger
|
|
498
|
+
``max_solver_time_sec``.
|
|
499
|
+
max_solver_time_sec : float
|
|
500
|
+
Time budget for the MILP solver, in seconds. If the solver proves
|
|
501
|
+
optimality earlier, it returns earlier; otherwise the best
|
|
502
|
+
solution found so far is returned (status 'feasible').
|
|
503
|
+
verbose : bool
|
|
504
|
+
Whether to print progress information.
|
|
505
|
+
scatterplot_matrix : bool or 'auto'
|
|
506
|
+
Whether to display scatterplot matrices of the original and
|
|
507
|
+
undersampled datasets. With 'auto', plots are shown only for
|
|
508
|
+
datasets of 10 or fewer dimensions.
|
|
509
|
+
|
|
510
|
+
Returns
|
|
511
|
+
-------
|
|
512
|
+
numpy.ndarray of bool, shape (N,)
|
|
513
|
+
Selection mask over the original observations: True for each
|
|
514
|
+
datapoint kept in the undersampled dataset. If no solution is
|
|
515
|
+
found, a mask of all False is returned.
|
|
516
|
+
|
|
517
|
+
Raises
|
|
518
|
+
------
|
|
519
|
+
ValueError
|
|
520
|
+
If the inputs are inconsistent (wrong shapes, unknown distribution
|
|
521
|
+
name, or infeasible data_to_keep).
|
|
522
|
+
RuntimeError
|
|
523
|
+
If the MILP solver backend cannot be created.
|
|
524
|
+
|
|
525
|
+
Examples
|
|
526
|
+
--------
|
|
527
|
+
>>> rng = np.random.default_rng(0)
|
|
528
|
+
>>> data = rng.random((5000, 4))
|
|
529
|
+
>>> mask = undersample_dataset(data, data_to_keep=500, verbose=False,
|
|
530
|
+
... scatterplot_matrix=False)
|
|
531
|
+
>>> subset = data[mask]
|
|
532
|
+
|
|
533
|
+
Per-dimension targets and a categorical column (index 2):
|
|
534
|
+
|
|
535
|
+
>>> mask = undersample_dataset(
|
|
536
|
+
... data, data_to_keep=500,
|
|
537
|
+
... target_distribution=['uniform', 'gaussian', 'uniform', 'uniform'],
|
|
538
|
+
... categorical_dims=[2],
|
|
539
|
+
... verbose=False, scatterplot_matrix=False)
|
|
540
|
+
"""
|
|
541
|
+
# ------------------------------------------------------ input validation
|
|
542
|
+
|
|
543
|
+
data = np.asarray(data, dtype=float)
|
|
544
|
+
if data.ndim != 2:
|
|
545
|
+
raise ValueError(f"data must be a 2D array [N, M], got shape {data.shape}")
|
|
546
|
+
|
|
547
|
+
n_observations, n_dimensions = data.shape
|
|
548
|
+
|
|
549
|
+
if not 1 <= data_to_keep <= n_observations:
|
|
550
|
+
raise ValueError(
|
|
551
|
+
f"data_to_keep must be in [1, {n_observations}], got {data_to_keep}"
|
|
552
|
+
)
|
|
553
|
+
if bins < 2:
|
|
554
|
+
raise ValueError(f"bins must be >= 2, got {bins}")
|
|
555
|
+
if solver not in ("CBC", "SCIP", "SAT"):
|
|
556
|
+
raise ValueError(
|
|
557
|
+
f"Unknown solver '{solver}'. Expected 'CBC', 'SCIP' or 'SAT'."
|
|
558
|
+
)
|
|
559
|
+
solver_backend = solver
|
|
560
|
+
|
|
561
|
+
categorical_set = set(int(c) for c in categorical_dims or [])
|
|
562
|
+
if categorical_set and not all(
|
|
563
|
+
0 <= c < n_dimensions for c in categorical_set
|
|
564
|
+
):
|
|
565
|
+
raise ValueError(
|
|
566
|
+
f"categorical_dims entries must be column indices in "
|
|
567
|
+
f"[0, {n_dimensions - 1}], got {sorted(categorical_set)}."
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if prereduce is not None and prereduce != "auto":
|
|
571
|
+
if isinstance(prereduce, bool) or not isinstance(prereduce, int):
|
|
572
|
+
raise ValueError(
|
|
573
|
+
f"prereduce must be None, 'auto', or an int >= 1, "
|
|
574
|
+
f"got {prereduce!r}."
|
|
575
|
+
)
|
|
576
|
+
if prereduce < 1:
|
|
577
|
+
raise ValueError(f"prereduce must be >= 1, got {prereduce}")
|
|
578
|
+
|
|
579
|
+
if scatterplot_matrix == "auto":
|
|
580
|
+
scatterplot_matrix = n_dimensions <= 10
|
|
581
|
+
|
|
582
|
+
# --------------------------------------------------------- data scaling
|
|
583
|
+
|
|
584
|
+
if data_scaling == "minmax":
|
|
585
|
+
data = _minmax_scale(data)
|
|
586
|
+
|
|
587
|
+
# ----------------------------------------------- quantize data into bins
|
|
588
|
+
|
|
589
|
+
if verbose:
|
|
590
|
+
print("\nQuantizing dataset...")
|
|
591
|
+
|
|
592
|
+
# numeric dimensions: `bins` equal-width bins over [0, 1];
|
|
593
|
+
# categorical dimensions: one bin per unique value
|
|
594
|
+
data_quantized, n_bins_per_dim = _quantize(data, bins, categorical_set)
|
|
595
|
+
|
|
596
|
+
# ------------------------------------------ optional pre-reduction stage
|
|
597
|
+
|
|
598
|
+
original_indices = None # set when pre-reduction is applied
|
|
599
|
+
|
|
600
|
+
apply_prereduce = prereduce is not None and (
|
|
601
|
+
prereduce != "auto" or n_observations > 200_000
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
if apply_prereduce:
|
|
605
|
+
ids = _cell_ids(data_quantized, n_bins_per_dim)
|
|
606
|
+
if prereduce == "auto":
|
|
607
|
+
# pick the largest cap whose pool the solver handles comfortably
|
|
608
|
+
_, cell_counts = np.unique(ids, return_counts=True)
|
|
609
|
+
pool_target = max(100_000, 5 * data_to_keep)
|
|
610
|
+
cap = _adaptive_cap(cell_counts, pool_target)
|
|
611
|
+
else:
|
|
612
|
+
cap = prereduce
|
|
613
|
+
keep = _cap_cells(ids, cap, np.random.default_rng(0))
|
|
614
|
+
n_kept = int(keep.sum())
|
|
615
|
+
if n_kept < data_to_keep:
|
|
616
|
+
raise ValueError(
|
|
617
|
+
f"Pre-reduction with cap {cap}/cell leaves only {n_kept} "
|
|
618
|
+
f"rows, fewer than data_to_keep={data_to_keep}. Increase "
|
|
619
|
+
f"the prereduce cap."
|
|
620
|
+
)
|
|
621
|
+
if verbose:
|
|
622
|
+
print(
|
|
623
|
+
f"Pre-reduction: {n_observations:,} -> {n_kept:,} rows "
|
|
624
|
+
f"(cap {cap}/cell)"
|
|
625
|
+
)
|
|
626
|
+
n_original = n_observations
|
|
627
|
+
original_indices = np.flatnonzero(keep)
|
|
628
|
+
data = data[keep]
|
|
629
|
+
data_quantized = data_quantized[keep]
|
|
630
|
+
n_observations = n_kept
|
|
631
|
+
|
|
632
|
+
# ------------------------------------------ target distribution per bin
|
|
633
|
+
|
|
634
|
+
specs = _normalize_target_specs(target_distribution, n_dimensions)
|
|
635
|
+
target_pdfs = [
|
|
636
|
+
_build_target_pdf(spec, n_bins_per_dim[m])
|
|
637
|
+
for m, spec in enumerate(specs)
|
|
638
|
+
]
|
|
639
|
+
|
|
640
|
+
str_specs = [s for s in specs if isinstance(s, str)]
|
|
641
|
+
if len(str_specs) == n_dimensions and len(set(str_specs)) == 1:
|
|
642
|
+
target_name = str_specs[0]
|
|
643
|
+
elif isinstance(target_distribution, str):
|
|
644
|
+
target_name = target_distribution
|
|
645
|
+
else:
|
|
646
|
+
target_name = "custom"
|
|
647
|
+
|
|
648
|
+
# ---------------------------------------- display original distributions
|
|
649
|
+
|
|
650
|
+
if scatterplot_matrix:
|
|
651
|
+
plot_scatter_matrix(
|
|
652
|
+
data,
|
|
653
|
+
title=f"Original dataset ({n_observations} datapoints)",
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# ------------------------------------------------------ build the MILP
|
|
657
|
+
#
|
|
658
|
+
# Variables:
|
|
659
|
+
# x[0..N-1] binary -> 1 if datapoint is selected
|
|
660
|
+
# s[0..sum(bins_per_dim)-1] slack per (dimension, bin) cell
|
|
661
|
+
# (continuous; integer for the SAT
|
|
662
|
+
# backend, which only supports integer
|
|
663
|
+
# arithmetic -- equivalent here since
|
|
664
|
+
# bin-count deviations are integral)
|
|
665
|
+
#
|
|
666
|
+
# Objective:
|
|
667
|
+
# minimize lamda * sum_k v[k] * x[k] + sum s
|
|
668
|
+
# where v[k] is the per-datapoint correlation cost.
|
|
669
|
+
#
|
|
670
|
+
# Constraints:
|
|
671
|
+
# sum x == data_to_keep
|
|
672
|
+
# for every (dimension m, bin n) with target count b = pdf_m[n]*keep:
|
|
673
|
+
# count_selected(m, n) - s[m,n] <= b (upper slack bound)
|
|
674
|
+
# -count_selected(m, n) - s[m,n] <= -b (lower slack bound)
|
|
675
|
+
# i.e. |count_selected - b| <= s, and s is minimized.
|
|
676
|
+
|
|
677
|
+
if verbose:
|
|
678
|
+
print("Filling problem matrices...")
|
|
679
|
+
|
|
680
|
+
solver = pywraplp.Solver.CreateSolver(solver_backend)
|
|
681
|
+
if solver is None:
|
|
682
|
+
raise RuntimeError(
|
|
683
|
+
f"Could not create the '{solver_backend}' MILP solver backend."
|
|
684
|
+
)
|
|
685
|
+
solver.SetTimeLimit(int(max_solver_time_sec * 1000)) # milliseconds
|
|
686
|
+
|
|
687
|
+
# expected mean of each dimension's target distribution (used by the
|
|
688
|
+
# correlation cost); for categorical dims, categories are mapped to
|
|
689
|
+
# evenly spaced points in [0, 1] for this purpose
|
|
690
|
+
avg = np.empty(n_dimensions)
|
|
691
|
+
data_for_corr = data.copy()
|
|
692
|
+
for m in range(n_dimensions):
|
|
693
|
+
n_bins_m = n_bins_per_dim[m]
|
|
694
|
+
centers = (np.arange(1, n_bins_m + 1) - 0.5) / n_bins_m
|
|
695
|
+
avg[m] = float(np.dot(centers, target_pdfs[m]))
|
|
696
|
+
if m in categorical_set:
|
|
697
|
+
data_for_corr[:, m] = centers[data_quantized[:, m]]
|
|
698
|
+
|
|
699
|
+
# 2nd objective: per-datapoint correlation cost (vectorized)
|
|
700
|
+
v = _pairwise_correlation_cost(data_for_corr, avg)
|
|
701
|
+
|
|
702
|
+
# slack bookkeeping: dimension m owns slots
|
|
703
|
+
# [slack_offset[m], slack_offset[m+1]) in the slack vector
|
|
704
|
+
slack_offset = np.concatenate([[0], np.cumsum(n_bins_per_dim)])
|
|
705
|
+
n_slacks = int(slack_offset[-1])
|
|
706
|
+
|
|
707
|
+
# decision variables (slacks are >= 0 at any optimum, so bound them at 0;
|
|
708
|
+
# the SAT backend requires integer variables, which is equivalent here
|
|
709
|
+
# because bin-count deviations are integral)
|
|
710
|
+
x = [solver.BoolVar(f"x[{i}]") for i in range(n_observations)]
|
|
711
|
+
if solver_backend == "SAT":
|
|
712
|
+
s = [
|
|
713
|
+
solver.IntVar(0, n_observations, f"s[{i}]")
|
|
714
|
+
for i in range(n_slacks)
|
|
715
|
+
]
|
|
716
|
+
else:
|
|
717
|
+
s = [
|
|
718
|
+
solver.NumVar(0, solver.infinity(), f"s[{i}]")
|
|
719
|
+
for i in range(n_slacks)
|
|
720
|
+
]
|
|
721
|
+
|
|
722
|
+
# objective: correlation cost on selections + sum of slacks
|
|
723
|
+
solver.Minimize(
|
|
724
|
+
solver.Sum(lamda * v[i] * x[i] for i in range(n_observations))
|
|
725
|
+
+ solver.Sum(s)
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
# equality constraint: exactly data_to_keep datapoints selected
|
|
729
|
+
solver.Add(solver.Sum(x) == data_to_keep)
|
|
730
|
+
|
|
731
|
+
# distribution constraints per (dimension, bin)
|
|
732
|
+
total_constraints = n_slacks
|
|
733
|
+
if verbose:
|
|
734
|
+
print(f"Adding constraints [{0:3d}%]", end="")
|
|
735
|
+
|
|
736
|
+
k = 0
|
|
737
|
+
for m in range(n_dimensions):
|
|
738
|
+
for n in range(n_bins_per_dim[m]):
|
|
739
|
+
# target count of selected datapoints in this (dimension, bin)
|
|
740
|
+
b = np.ceil(target_pdfs[m][n] * data_to_keep)
|
|
741
|
+
slack = s[slack_offset[m] + n]
|
|
742
|
+
|
|
743
|
+
# selected datapoints falling in bin n of dimension m
|
|
744
|
+
members = np.flatnonzero(data_quantized[:, m] == n)
|
|
745
|
+
count = solver.Sum(x[i] for i in members)
|
|
746
|
+
|
|
747
|
+
solver.Add(count - slack <= b) # upper slack bound
|
|
748
|
+
solver.Add(-count - slack <= -b) # lower slack bound
|
|
749
|
+
|
|
750
|
+
k += 1
|
|
751
|
+
if verbose:
|
|
752
|
+
progress = round(k * 100 / total_constraints)
|
|
753
|
+
print(f"\b\b\b\b\b\b[{progress:3d}%]", end="")
|
|
754
|
+
|
|
755
|
+
if verbose:
|
|
756
|
+
print(f"\nNumber of variables = {solver.NumVariables()}")
|
|
757
|
+
print(f"Number of constraints = {solver.NumConstraints()}")
|
|
758
|
+
|
|
759
|
+
# ------------------------------------------------- solve the MILP
|
|
760
|
+
|
|
761
|
+
if verbose:
|
|
762
|
+
print(f"Solving with {solver_backend}...")
|
|
763
|
+
|
|
764
|
+
status = solver.Solve()
|
|
765
|
+
|
|
766
|
+
if verbose:
|
|
767
|
+
print(f"Result status = {_RESULT_STATUS.get(status, 'unknown')}")
|
|
768
|
+
print(f"Total cost = {solver.Objective().Value()}")
|
|
769
|
+
print(f"Problem solved in {solver.wall_time():f} milliseconds")
|
|
770
|
+
print(f"Problem solved in {solver.iterations()} iterations")
|
|
771
|
+
print(f"Problem solved in {solver.nodes()} branch-and-bound nodes")
|
|
772
|
+
print()
|
|
773
|
+
|
|
774
|
+
# ------------------------------------------------- extract the solution
|
|
775
|
+
|
|
776
|
+
indx_selected = np.zeros(n_observations, dtype=bool)
|
|
777
|
+
|
|
778
|
+
if status in (pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE):
|
|
779
|
+
for i in range(n_observations):
|
|
780
|
+
if x[i].solution_value() > 0.5:
|
|
781
|
+
indx_selected[i] = True
|
|
782
|
+
|
|
783
|
+
if indx_selected.sum() > 0:
|
|
784
|
+
if scatterplot_matrix:
|
|
785
|
+
plot_scatter_matrix(
|
|
786
|
+
data[indx_selected, :],
|
|
787
|
+
title=(
|
|
788
|
+
f"Undersampled dataset ({indx_selected.sum()} "
|
|
789
|
+
f"datapoints) - {target_name}"
|
|
790
|
+
),
|
|
791
|
+
)
|
|
792
|
+
plot_scatter_matrix(
|
|
793
|
+
data_quantized[indx_selected, :],
|
|
794
|
+
title=(
|
|
795
|
+
f"Undersampled dataset quantized ({indx_selected.sum()} "
|
|
796
|
+
f"datapoints) - {target_name}"
|
|
797
|
+
),
|
|
798
|
+
)
|
|
799
|
+
elif verbose:
|
|
800
|
+
print("No solution was found")
|
|
801
|
+
|
|
802
|
+
# map the selection back to the original (pre-reduction) rows
|
|
803
|
+
if original_indices is not None:
|
|
804
|
+
full_mask = np.zeros(n_original, dtype=bool)
|
|
805
|
+
full_mask[original_indices[indx_selected]] = True
|
|
806
|
+
return full_mask
|
|
807
|
+
|
|
808
|
+
return indx_selected
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def plot_scatter_matrix(
|
|
812
|
+
data: np.ndarray,
|
|
813
|
+
column_names: Sequence[str] | None = None,
|
|
814
|
+
show_correlation: bool = True,
|
|
815
|
+
alpha: float | None = None,
|
|
816
|
+
title: str | None = None,
|
|
817
|
+
save_path: str | None = None,
|
|
818
|
+
) -> None:
|
|
819
|
+
"""
|
|
820
|
+
Plot a customized scatterplot matrix (based on pandas).
|
|
821
|
+
|
|
822
|
+
Parameters
|
|
823
|
+
----------
|
|
824
|
+
data : numpy.ndarray of shape (N, M)
|
|
825
|
+
Array of datapoints of N observations and M dimensions.
|
|
826
|
+
column_names : sequence of str, optional
|
|
827
|
+
Names of each data dimension. If None, labels D0, D1, ... are
|
|
828
|
+
auto-generated.
|
|
829
|
+
show_correlation : bool
|
|
830
|
+
Whether to annotate the Pearson correlation coefficient for each
|
|
831
|
+
pair of dimensions on the upper triangle of the matrix.
|
|
832
|
+
alpha : float in [0, 1], optional
|
|
833
|
+
Transparency of each datapoint (0 = transparent, 1 = opaque).
|
|
834
|
+
If None, it is adjusted automatically: more transparent for large
|
|
835
|
+
datasets, less transparent for smaller ones.
|
|
836
|
+
title : str, optional
|
|
837
|
+
Title displayed above the scatterplot matrix.
|
|
838
|
+
save_path : str, optional
|
|
839
|
+
If given, the figure is also saved to this path (format inferred
|
|
840
|
+
from the extension, e.g. '.png').
|
|
841
|
+
"""
|
|
842
|
+
# imported lazily so the optimizer can run in headless environments
|
|
843
|
+
import matplotlib.pyplot as plt
|
|
844
|
+
import pandas as pd
|
|
845
|
+
|
|
846
|
+
plt.style.use("ggplot")
|
|
847
|
+
|
|
848
|
+
if column_names is None:
|
|
849
|
+
column_names = [f"D{i}" for i in range(data.shape[1])]
|
|
850
|
+
|
|
851
|
+
# auto alpha according to dataset size, clipped to [0.1, 0.7]
|
|
852
|
+
if alpha is None:
|
|
853
|
+
alpha = float(np.clip((5000 - data.shape[0]) / 5000, 0.1, 0.7))
|
|
854
|
+
|
|
855
|
+
df = pd.DataFrame(np.asarray(data, dtype=float), columns=list(column_names))
|
|
856
|
+
axes = pd.plotting.scatter_matrix(
|
|
857
|
+
df, alpha=alpha, figsize=(8, 8), diagonal="hist"
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
# annotate Pearson correlation coefficients on the upper triangle
|
|
861
|
+
if show_correlation:
|
|
862
|
+
corr = df.corr().to_numpy()
|
|
863
|
+
for i, j in zip(*np.triu_indices_from(axes, k=1)):
|
|
864
|
+
axes[i, j].annotate(
|
|
865
|
+
f"r={corr[i, j]:.3f}",
|
|
866
|
+
(0.7, 0.9),
|
|
867
|
+
xycoords="axes fraction",
|
|
868
|
+
ha="center",
|
|
869
|
+
va="center",
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
if title is not None:
|
|
873
|
+
plt.suptitle(title)
|
|
874
|
+
|
|
875
|
+
if save_path is not None:
|
|
876
|
+
plt.gcf().savefig(save_path, dpi=150, bbox_inches="tight")
|
|
877
|
+
|
|
878
|
+
plt.show()
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datacarve
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Distributional dataset undersampling: carve a balanced subset out of a large dataset with MILP optimization, enforcing target distributions across all dimensions simultaneously.
|
|
5
|
+
Author: Vasileios Vonikakis
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bbonik/datacarve
|
|
8
|
+
Project-URL: Repository, https://github.com/bbonik/datacarve
|
|
9
|
+
Keywords: distributional dataset undersampling,undersampling,dataset balancing,subset selection,MILP,integer programming,data shaping,fairness,imbalanced data,sampling
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: ortools>=9.8
|
|
24
|
+
Requires-Dist: numpy>=1.26
|
|
25
|
+
Requires-Dist: scipy>=1.11
|
|
26
|
+
Provides-Extra: plot
|
|
27
|
+
Requires-Dist: pandas>=2.0; extra == "plot"
|
|
28
|
+
Requires-Dist: matplotlib>=3.8; extra == "plot"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pandas>=2.0; extra == "dev"
|
|
32
|
+
Requires-Dist: matplotlib>=3.8; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# datacarve
|
|
36
|
+
|
|
37
|
+
[](https://github.com/bbonik/datacarve/actions/workflows/ci.yml)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
**Carve a balanced subset out of a large dataset — distributional dataset undersampling via MILP optimization.**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install datacarve
|
|
45
|
+
```
|
|
46
|
+
```python
|
|
47
|
+
from datacarve import undersample_dataset
|
|
48
|
+
|
|
49
|
+
mask = undersample_dataset(data, data_to_keep=1000) # balanced across ALL dimensions
|
|
50
|
+
subset = data[mask]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`datacarve` selects the **provably optimal subset** of a dataset whose attributes jointly follow the distributions you specify — balanced across any number of dimensions, numeric or categorical, *all at once* (e.g. gender *and* age *and* race *and* label). Typical uses: **fair evaluation sets** for bias audits and Responsible AI compliance, **LLM data mixtures** (eval suites, SFT subsets, red-teaming pools), quota samples, and matched cohorts. Under the hood it is a Mixed Integer Linear Programming (**MILP**) formulation that exploits the redundancies of a large dataset to carve a compact, distribution-shaped version of it, while also minimizing cross-attribute correlations. Formerly known as `distributional_dataset_undersampling`.
|
|
54
|
+
|
|
55
|
+
<img src="https://github.com/bbonik/datacarve/raw/master/assets/example.png" width="900">
|
|
56
|
+
|
|
57
|
+
## The problem: your dataset is imbalanced in several ways at once
|
|
58
|
+
|
|
59
|
+
Real datasets are rarely skewed along just one attribute. Take the classic Adult census dataset (48,842 rows): **two-thirds male, 85% White, 76% low-income, ages bunched between 25 and 45** — four imbalances at the same time. Train or evaluate on it as-is, and your metrics are quietly dominated by the majority groups.
|
|
60
|
+
|
|
61
|
+
Fixing **one** attribute is easy: group by it, sample equally per group. Fixing **all of them at once** is a different kind of problem, and this is the part few people appreciate until they try:
|
|
62
|
+
|
|
63
|
+
- **Every row you keep counts toward every histogram simultaneously.** A row that improves your gender balance may worsen your age balance. There is no "safe" row to drop.
|
|
64
|
+
- **Stratifying on the combination of attributes explodes.** 2 sexes × 5 races × 2 income classes × 10 age bins = 200 strata — most of which are nearly or completely empty in the original data. You cannot sample equally from empty strata.
|
|
65
|
+
- **Greedy selection has no guarantee.** Picking whichever row locally improves balance routinely paints itself into corners where every remaining candidate makes some attribute worse.
|
|
66
|
+
|
|
67
|
+
Selecting the best possible subset under joint distributional constraints is a **combinatorial optimization problem**. Treating it like one — instead of approximating with heuristics — is the whole point of this package.
|
|
68
|
+
|
|
69
|
+
## How datacarve solves it
|
|
70
|
+
|
|
71
|
+
`datacarve` formulates the selection as a **Mixed Integer Linear Program (MILP)**: one binary keep/drop decision per row, constraints that tie the selected counts in every (attribute, bin) cell to your target distribution, and an objective that minimizes total deviation from the targets while also suppressing cross-attribute correlations.
|
|
72
|
+
|
|
73
|
+
```mermaid
|
|
74
|
+
flowchart LR
|
|
75
|
+
A["Large skewed dataset<br/>(N rows, M attributes)"] --> Q["Quantize each attribute<br/>into bins / categories"]
|
|
76
|
+
T["Target distribution<br/>per attribute<br/>(uniform, gaussian, custom)"] --> S
|
|
77
|
+
K["Subset size K"] --> S
|
|
78
|
+
Q --> S{"MILP solver<br/>one binary variable per row:<br/>keep or drop"}
|
|
79
|
+
S --> O["Optimal subset of<br/>K real rows"]
|
|
80
|
+
O --> R["All M marginals match<br/>their targets jointly<br/>+ minimal cross-correlations"]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The solver either **proves it found the optimal subset**, or — given a time budget — returns the best subset found with a quality bound. Three properties fall out of this that heuristics cannot offer:
|
|
84
|
+
|
|
85
|
+
1. **Exactness.** The selected counts per group are guaranteed, not approximate: you can state "200 rows per race, 500 per sex" in a datasheet and mean it.
|
|
86
|
+
2. **Jointness.** All attributes are satisfied *simultaneously* — numeric ones shaped to any distribution (uniform, gaussian, custom histogram), categorical ones to exact per-category counts.
|
|
87
|
+
3. **Real data only.** The subset is made of your actual rows. Nothing is synthesized, duplicated, or reweighted.
|
|
88
|
+
|
|
89
|
+
The technique is *complementary to dimensionality reduction*: instead of reducing feature dimensions while keeping all observations, it reduces observations while imposing distributional constraints on the dimensions.
|
|
90
|
+
|
|
91
|
+
The figure above shows it in action on a 6-dimensional dataset (11K datapoints), where dimension D5 is a linear combination of D0 and D3. Three 1K subsets are carved with Uniform, Gaussian and Triangular targets: every histogram takes the target shape, and the D0–D5 correlation visible in the original is broken in the subsets.
|
|
92
|
+
|
|
93
|
+
## Fairness and Responsible AI
|
|
94
|
+
|
|
95
|
+
This is the use case the package was built around, and it has only become more urgent since the original papers (ICIP 2016 / IEEE TMM 2017). Today, model cards, datasheets, bias audits, and regulations such as the **EU AI Act** all expect evidence that systems were evaluated on **representative, balanced data across sensitive attributes** — and "we randomly sampled and hoped" does not qualify.
|
|
96
|
+
|
|
97
|
+
`datacarve` turns that requirement into a one-liner with a provable result:
|
|
98
|
+
|
|
99
|
+
- **Balanced evaluation sets.** In a random 1,000-row sample of Adult, the smallest racial group gets ~8 rows — its accuracy estimate is statistical noise that swings on a couple of lucky predictions. A carved set gives *every* group the same 200-row evidence base, making per-group metrics comparable and equally trustworthy. See the [worked notebook](notebooks/balanced_evaluation_sets.ipynb): sex 500/500, race 5×200, income 500/500, age flat — simultaneously, in seconds.
|
|
100
|
+
- **Auditable by construction.** Because the constraints are explicit and the solver's result status is reported, the composition of your eval set is a *documented guarantee*, not a post-hoc observation — exactly what a datasheet or compliance review wants to see.
|
|
101
|
+
- **Realistic, not just uniform, targets.** Fairness rarely means "make everything equal". Per-attribute targets let you balance sensitive attributes exactly while keeping, say, a realistic 3:1 label ratio: `target_distribution=["uniform", "uniform", [3, 1]]`.
|
|
102
|
+
|
|
103
|
+
## Applications
|
|
104
|
+
|
|
105
|
+
Any situation where you need a **subset of fixed size whose attributes follow prescribed distributions, jointly across several attributes**, is a candidate.
|
|
106
|
+
|
|
107
|
+
### In the LLM era
|
|
108
|
+
|
|
109
|
+
Modern LLM work is largely *data curation under a budget* — which is exactly this problem. Attributes don't need to be raw columns: task labels, topic clusters from embeddings, difficulty scores, or length buckets all work.
|
|
110
|
+
|
|
111
|
+
- **Balanced benchmark & eval suites.** Carve an evaluation set that is balanced across task type × domain × difficulty × language × prompt length, so a model's headline score isn't dominated by whichever category the benchmark over-collected. Same for regression-testing suites that must stay small enough to run on every checkpoint.
|
|
112
|
+
- **Fine-tuning mixtures (SFT).** Instruction datasets skew heavily by source, topic and length. Carve a compact training subset that hits an exact target mixture (e.g. 30% coding, 30% reasoning, 20% writing, 20% multilingual — with a target length distribution) instead of eyeballing sampling ratios.
|
|
113
|
+
- **Safety & red-teaming sets.** Balance adversarial prompts across harm categories × attack styles × targeted demographics, so safety metrics cover the space instead of over-testing the most common attack type.
|
|
114
|
+
- **Human evaluation & preference data.** Annotator time is the scarcest resource in RLHF pipelines; carve the candidate pool so every scenario type gets equal annotation coverage.
|
|
115
|
+
|
|
116
|
+
### Classical ML and beyond
|
|
117
|
+
|
|
118
|
+
- **Dataset debiasing / data-centric AI.** Reshape a skewed training set toward a target distribution instead of collecting new data, leveraging redundancy already present in the dataset.
|
|
119
|
+
- **Causal inference & epidemiology.** Select a control cohort whose covariate distributions match a treatment group (or any reference population). This generalizes matching approaches such as cardinality matching: the target can be *any* distribution, not just another group's.
|
|
120
|
+
- **Survey statistics & market research.** Quota sampling and panel calibration: pick respondents so that the sample matches census demographics across several attributes simultaneously ([worked notebook](notebooks/survey_quota_sampling.ipynb)).
|
|
121
|
+
- **A/B testing.** Assign experiment groups that are balanced across multiple covariates, rather than relying on randomization alone for small samples.
|
|
122
|
+
- **Drug discovery / cheminformatics.** Select compound libraries with desired property distributions (molecular weight, logP, solubility, ...) while minimizing redundancy between correlated properties.
|
|
123
|
+
- **Simulation & testing.** Choose a representative, affordable subset of test scenarios (e.g., driving scenarios spanning weather × traffic × speed distributions) when running all of them is too expensive.
|
|
124
|
+
|
|
125
|
+
## How it compares to other approaches
|
|
126
|
+
|
|
127
|
+
| Approach | What it does | Limitation this method addresses |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| **Random / stratified sampling** | Samples uniformly, or balances strata of *one* attribute. | Cannot jointly balance several attributes; multi-attribute stratification explodes combinatorially and leaves many empty strata. |
|
|
130
|
+
| **Class balancing** (e.g., random undersampling, SMOTE in `imbalanced-learn`) | Balances a single categorical label, possibly by synthesizing points. | Single-label only; synthetic points may be unrealistic. This method handles multiple *continuous or categorical* dimensions and only ever selects real datapoints. |
|
|
131
|
+
| **Reweighting / calibration** (importance weights, raking) | Keeps all data but assigns weights so that weighted statistics match targets. | The dataset stays large and individual high-weight points dominate; many ML pipelines and human-evaluation settings need an *actual subset*, not weights. |
|
|
132
|
+
| **Matching methods** (propensity score, cardinality matching) | Selects a control group whose covariates match a treatment group. | Matches to *another sample's* distribution; here the target is arbitrary (uniform, gaussian, custom), and correlation between attributes is minimized explicitly. |
|
|
133
|
+
| **Coreset selection / data pruning** | Selects a subset that preserves model loss or gradient information. | Optimizes for a *model's* training objective, not for interpretable distributional guarantees; typically gives no control over per-attribute histograms. |
|
|
134
|
+
| **Greedy / heuristic subset selection** | Iteratively picks points that locally improve balance. | No global guarantee: a point that helps attribute A may hurt attribute B. The MILP reasons about all attributes and all points jointly, and returns a certified optimal (or bounded) solution. |
|
|
135
|
+
|
|
136
|
+
In short: this method occupies a niche none of the standard tools cover — **exact, jointly multi-attribute, distribution-targeted subset selection of real datapoints**. One binary decision variable per datapoint solves comfortably up to hundreds of thousands of rows on a laptop; for larger datasets the built-in [pre-reduction stage](#very-large-datasets) extends it to tens of millions.
|
|
137
|
+
|
|
138
|
+
## Installation
|
|
139
|
+
|
|
140
|
+
Requires Python 3.10+ (tested with Python 3.12).
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pip install datacarve # core (solver only)
|
|
144
|
+
pip install "datacarve[plot]" # core + scatterplot matrices
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Or from source:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
git clone https://github.com/bbonik/datacarve.git
|
|
151
|
+
cd datacarve
|
|
152
|
+
|
|
153
|
+
# create and activate a virtual environment
|
|
154
|
+
python3 -m venv .venv
|
|
155
|
+
source .venv/bin/activate # on Windows: .venv\Scripts\activate
|
|
156
|
+
|
|
157
|
+
pip install -e ".[plot]"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The MILP solver is [Google OR-Tools](https://developers.google.com/optimization) (CBC backend), which is installed automatically — no separate solver installation is needed.
|
|
161
|
+
|
|
162
|
+
## Quick start
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import numpy as np
|
|
166
|
+
from datacarve import undersample_dataset
|
|
167
|
+
|
|
168
|
+
rng = np.random.default_rng(0)
|
|
169
|
+
data = rng.random((5000, 4)) # [N observations, M dimensions]
|
|
170
|
+
|
|
171
|
+
mask = undersample_dataset(
|
|
172
|
+
data=data,
|
|
173
|
+
data_to_keep=500, # size of the undersampled subset
|
|
174
|
+
target_distribution="uniform", # 'uniform', 'gaussian', 'weibull', 'triangular'
|
|
175
|
+
bins=10, # quantization bins per dimension
|
|
176
|
+
lamda=0.5, # weight of the correlation-minimization objective
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
subset = data[mask] # boolean mask over the original observations
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
You can also pass a **custom target distribution** as an array of bin weights (one weight per bin, automatically normalized):
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
# triangular-ish custom target over 10 bins
|
|
186
|
+
mask = undersample_dataset(
|
|
187
|
+
data=data,
|
|
188
|
+
data_to_keep=500,
|
|
189
|
+
target_distribution=[1, 2, 3, 4, 5, 5, 4, 3, 2, 1],
|
|
190
|
+
bins=10,
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Useful options:
|
|
195
|
+
|
|
196
|
+
| Parameter | Default | Description |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `data_to_keep` | `1000` | Number of datapoints to keep. |
|
|
199
|
+
| `data_scaling` | `'minmax'` | Per-feature scaling to [0, 1]. Use `None` if the data is already scaled. |
|
|
200
|
+
| `target_distribution` | `'uniform'` | Built-in name, a custom array of bin weights, or a list with one spec per dimension. See [Per-dimension targets](#per-dimension-targets-and-categorical-attributes). |
|
|
201
|
+
| `bins` | `10` | Quantization bins per numeric dimension. Categorical dimensions use one bin per unique value. |
|
|
202
|
+
| `categorical_dims` | `None` | Column indices to treat as categorical (one bin per unique value). |
|
|
203
|
+
| `lamda` | `0.5` | Balance between distribution matching (`0`) and correlation minimization (`>0`). |
|
|
204
|
+
| `prereduce` | `None` | Pre-reduce huge datasets before solving: `'auto'`, or an int cap per joint cell. See [Very large datasets](#very-large-datasets). |
|
|
205
|
+
| `solver` | `'CBC'` | MILP solver backend: `'CBC'`, `'SCIP'`, or `'SAT'`. See [Choosing a solver](#choosing-a-solver). |
|
|
206
|
+
| `max_solver_time_sec` | `10.0` | Time budget for the MILP solver. Increase for large datasets. |
|
|
207
|
+
| `verbose` | `True` | Print progress and solver statistics. |
|
|
208
|
+
| `scatterplot_matrix` | `'auto'` | Show scatterplot matrices (auto-disabled for >10 dimensions). |
|
|
209
|
+
|
|
210
|
+
## Per-dimension targets and categorical attributes
|
|
211
|
+
|
|
212
|
+
Each dimension can get its **own target distribution** — pass a list with one spec per dimension, mixing built-in names and custom weight arrays:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
mask = undersample_dataset(
|
|
216
|
+
data=data, # shape (N, 3)
|
|
217
|
+
data_to_keep=500,
|
|
218
|
+
target_distribution=["uniform", "gaussian", [1, 2, 3, 4, 5, 5, 4, 3, 2, 1]],
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Categorical attributes** (e.g. gender, race, class labels) should not be quantized into equal-width bins. Mark them with `categorical_dims` and each unique value becomes its own bin, so `'uniform'` means "equal counts per category":
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
# column 2 holds a label-encoded category (e.g. 0=A, 1=B, 2=C)
|
|
226
|
+
mask = undersample_dataset(
|
|
227
|
+
data=data,
|
|
228
|
+
data_to_keep=300,
|
|
229
|
+
target_distribution=["uniform", "uniform", [3, 2, 1]], # 3:2:1 over categories
|
|
230
|
+
categorical_dims=[2],
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
This is the typical recipe for fairness-style curation: balance the categorical attributes exactly (equal counts per gender/race/label) while shaping the continuous attributes (age, pose, brightness) to a target distribution — all jointly, in one optimization.
|
|
235
|
+
|
|
236
|
+
## Very large datasets
|
|
237
|
+
|
|
238
|
+
The MILP uses one binary variable per row, which is comfortable up to several hundred thousand rows. Beyond that, use the built-in **pre-reduction** stage:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
mask = undersample_dataset(
|
|
242
|
+
data=huge_data, # e.g. 10 million rows
|
|
243
|
+
data_to_keep=1000,
|
|
244
|
+
prereduce="auto", # or an explicit per-cell cap, e.g. prereduce=50
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Pre-reduction groups rows by their joint quantization cell (the combination of bin indices across all attributes). Rows in the same cell are interchangeable with respect to every histogram constraint, so overcrowded cells are randomly downsampled to a cap while **rare cells are always kept in full** — unlike naive random subsampling, which preserves the skew you are trying to fix and can wipe out rare categories entirely. With `'auto'`, the cap is chosen adaptively so the reduced pool stays at a size the solver handles in seconds. The returned mask always refers to the original rows.
|
|
249
|
+
|
|
250
|
+
Measured on a laptop: a 10-million-row dataset is carved into a perfectly balanced 1,000-row subset (proven optimal) in about 10 seconds end-to-end.
|
|
251
|
+
|
|
252
|
+
The standalone `prereduce_dataset()` function exposes the same stage with control over the cap, grouping granularity, and random seed.
|
|
253
|
+
|
|
254
|
+
## Choosing a solver
|
|
255
|
+
|
|
256
|
+
All three backends are free, open source, and bundled with OR-Tools — no extra installation needed. They solve the exact same model; they differ in *how* they search, which matters once problems get hard.
|
|
257
|
+
|
|
258
|
+
| Solver | Best for | Character |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| `'CBC'` (default) | Easy to moderate problems | Classic branch-and-bound. Fastest when the problem is not too constrained; if it reports `optimal` within the time budget, stay with it. |
|
|
261
|
+
| `'SAT'` (CP-SAT) | Hard instances that hit the time limit | Clause-learning search, multi-core. When the status is `feasible` (time ran out before optimality was proven), it typically finds noticeably *better* subsets than CBC in the same time budget. |
|
|
262
|
+
| `'SCIP'` | Medium-hard instances | Modern branch-and-cut. Worth trying when CBC finds a solution quickly but struggles to prove it optimal. |
|
|
263
|
+
|
|
264
|
+
**Rule of thumb:**
|
|
265
|
+
|
|
266
|
+
1. Start with the default (`'CBC'`).
|
|
267
|
+
2. Check the reported result status (printed when `verbose=True`).
|
|
268
|
+
3. If the status is `optimal` — done, no reason to switch.
|
|
269
|
+
4. If the status is `feasible` (the time budget ran out), re-run with `solver='SAT'` and/or a larger `max_solver_time_sec`. This is where CP-SAT shines: on a hard 11K-point benchmark instance, all solvers hit a 60s budget, but CP-SAT returned the best subset found.
|
|
270
|
+
5. If no solution is found at all, the constraints may be too tight for your data: increase `max_solver_time_sec`, reduce `bins`, or reduce `data_to_keep`.
|
|
271
|
+
|
|
272
|
+
What makes an instance "hard"? More datapoints, more dimensions, strongly imbalanced data relative to the target (little redundancy to exploit), and correlated dimensions all increase difficulty.
|
|
273
|
+
|
|
274
|
+
## Examples
|
|
275
|
+
|
|
276
|
+
Two executed walkthrough notebooks in [`notebooks/`](notebooks/):
|
|
277
|
+
|
|
278
|
+
- **[Building fair, balanced evaluation sets](notebooks/balanced_evaluation_sets.ipynb)** — carves a 1,000-row eval set from the Adult census data, balanced across sex, race, income and age *simultaneously*, and shows why per-group accuracy numbers become trustworthy.
|
|
279
|
+
- **[Survey quota sampling](notebooks/survey_quota_sampling.ipynb)** — selects a quota sample from a skewed respondent panel, hitting census-style age/gender/region targets exactly (fully offline, synthetic data).
|
|
280
|
+
|
|
281
|
+
Runnable scripts in the [`examples/`](examples/) folder:
|
|
282
|
+
|
|
283
|
+
- **`example_6d_dataset.py`** — undersamples the bundled 6-dimensional dataset (11K datapoints) down to a uniform 1K subset.
|
|
284
|
+
- **`example_random_data.py`** — generates a random N-dimensional dataset (a different random distribution per dimension) and undersamples it.
|
|
285
|
+
- **`example_sklearn_datasets.py`** — applies the technique to classic scikit-learn datasets (diabetes, iris, breast cancer). Requires `scikit-learn`.
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
python examples/example_6d_dataset.py
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Solver benchmarks live in [`benchmarks/`](benchmarks/).
|
|
292
|
+
|
|
293
|
+
## Citations
|
|
294
|
+
|
|
295
|
+
If you use this code in your research please cite the following papers:
|
|
296
|
+
|
|
297
|
+
1. [Vonikakis, V., Subramanian, R., Arnfred, J., & Winkler, S. A Probabilistic Approach to People-Centric Photo Selection and Sequencing. IEEE Transactions in Multimedia, 11(19), pp.2609-2624, 2017.](https://www.researchgate.net/publication/316569587_A_Probabilistic_Approach_to_People-Centric_Photo_Selection_and_Sequencing)
|
|
298
|
+
2. [V. Vonikakis, R. Subramanian, S. Winkler. Shaping Datasets: Optimal Data Selection for Specific Target Distributions. Proc. ICIP2016, Phoenix, USA, Sept. 25-28, 2016.](http://vintage.winklerbros.net/Publications/icip2016a.pdf)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
datacarve/__init__.py,sha256=58NTChzw5WZsQ7j9nQZzMup3boNYJTowdQwFezF2xBA,743
|
|
2
|
+
datacarve/core.py,sha256=0aGS9MAiHAX2zBoI8EqO1wGjVLi6FaaHtNmjjVbLTS4,33880
|
|
3
|
+
datacarve-0.1.0.dist-info/licenses/LICENSE,sha256=q_mVTIGhf07r9YVBnkOCYXrEQsSjtnc1kXenuYze-bY,1076
|
|
4
|
+
datacarve-0.1.0.dist-info/METADATA,sha256=7zkTLwFwg57jKhOzt9iNblesaFgYvcp6_STkEFczlbM,21749
|
|
5
|
+
datacarve-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
datacarve-0.1.0.dist-info/top_level.txt,sha256=WxG7W-9R0UFKqcH8QSb85aUiRt9L_PXBFedLgnSqUAc,10
|
|
7
|
+
datacarve-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Vassilios Vonikakis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
datacarve
|