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 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
+ [![CI](https://github.com/bbonik/datacarve/actions/workflows/ci.yml/badge.svg)](https://github.com/bbonik/datacarve/actions/workflows/ci.yml)
38
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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