mlsampler 0.3.0__tar.gz → 0.3.3__tar.gz

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.
Files changed (27) hide show
  1. mlsampler-0.3.3/PKG-INFO +36 -0
  2. mlsampler-0.3.3/README.md +23 -0
  3. {mlsampler-0.3.0 → mlsampler-0.3.3}/pyproject.toml +1 -1
  4. mlsampler-0.3.3/src/mlsampler/__init__.py +12 -0
  5. mlsampler-0.3.3/src/mlsampler/base.py +160 -0
  6. {mlsampler-0.3.0/src → mlsampler-0.3.3/src/mlsampler}/constraints/__init__.py +2 -2
  7. {mlsampler-0.3.0/src → mlsampler-0.3.3/src/mlsampler}/constraints/constraints.py +36 -29
  8. {mlsampler-0.3.0/src → mlsampler-0.3.3/src/mlsampler}/engine/__init__.py +3 -0
  9. mlsampler-0.3.3/src/mlsampler/engine/hypergrid.py +136 -0
  10. {mlsampler-0.3.0/src → mlsampler-0.3.3/src/mlsampler}/engine/random.py +123 -73
  11. mlsampler-0.3.3/src/mlsampler/errors.py +23 -0
  12. mlsampler-0.3.3/src/mlsampler/terminals.py +27 -0
  13. mlsampler-0.3.3/src/mlsampler/types.py +7 -0
  14. mlsampler-0.3.3/src/mlsampler/validate.py +48 -0
  15. mlsampler-0.3.3/src/mlsampler.egg-info/PKG-INFO +36 -0
  16. mlsampler-0.3.3/src/mlsampler.egg-info/SOURCES.txt +20 -0
  17. mlsampler-0.3.3/src/mlsampler.egg-info/top_level.txt +1 -0
  18. mlsampler-0.3.0/PKG-INFO +0 -21
  19. mlsampler-0.3.0/README.md +0 -8
  20. mlsampler-0.3.0/src/mlsampler.egg-info/PKG-INFO +0 -21
  21. mlsampler-0.3.0/src/mlsampler.egg-info/SOURCES.txt +0 -13
  22. mlsampler-0.3.0/src/mlsampler.egg-info/top_level.txt +0 -2
  23. {mlsampler-0.3.0 → mlsampler-0.3.3}/LICENSE +0 -0
  24. {mlsampler-0.3.0 → mlsampler-0.3.3}/setup.cfg +0 -0
  25. {mlsampler-0.3.0/src → mlsampler-0.3.3/src/mlsampler}/constraints/base.py +0 -0
  26. {mlsampler-0.3.0 → mlsampler-0.3.3}/src/mlsampler.egg-info/dependency_links.txt +0 -0
  27. {mlsampler-0.3.0 → mlsampler-0.3.3}/src/mlsampler.egg-info/requires.txt +0 -0
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: mlsampler
3
+ Version: 0.3.3
4
+ Summary: Flexible constrained random sampler for reverse analysis in machine learning
5
+ Author: Jugai O
6
+ License: LICENSE
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy
11
+ Requires-Dist: joblib
12
+ Dynamic: license-file
13
+
14
+ # RandomSampler
15
+
16
+ mlsampler is a flexible, constraint-aware sampling library designed for reverse analysis and data exploration in machine learning workflows.
17
+
18
+ It allows you to generate synthetic samples that satisfy complex constraints across continuous, integer, binary, and categorical features.
19
+
20
+ ## Features
21
+ - Constraint-based sampling
22
+ - Support for continuous / categorical features
23
+ - Parallel generation
24
+
25
+ ## Documentation
26
+ https://jugais.github.io/randsampler/
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install mlsampler
32
+ ```
33
+
34
+ ## Use Cases
35
+ - Reverse analysis (finding inputs that satisfy target conditions)
36
+ - Candidate generation for optimization
@@ -0,0 +1,23 @@
1
+ # RandomSampler
2
+
3
+ mlsampler is a flexible, constraint-aware sampling library designed for reverse analysis and data exploration in machine learning workflows.
4
+
5
+ It allows you to generate synthetic samples that satisfy complex constraints across continuous, integer, binary, and categorical features.
6
+
7
+ ## Features
8
+ - Constraint-based sampling
9
+ - Support for continuous / categorical features
10
+ - Parallel generation
11
+
12
+ ## Documentation
13
+ https://jugais.github.io/randsampler/
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install mlsampler
19
+ ```
20
+
21
+ ## Use Cases
22
+ - Reverse analysis (finding inputs that satisfy target conditions)
23
+ - Candidate generation for optimization
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mlsampler"
7
- version = "0.3.0"
7
+ version = "0.3.3"
8
8
  description = "Flexible constrained random sampler for reverse analysis in machine learning"
9
9
  authors = [
10
10
  { name="Jugai O" }
@@ -0,0 +1,12 @@
1
+ from .base import BaseSampler, SamplerConfig, FeatureMeta
2
+ from .engine.random import RandomSampler
3
+ from .engine.hypergrid import HyperGridSampler
4
+
5
+ __all__ = [
6
+ "BaseSampler",
7
+ "SamplerConfig",
8
+ "FeatureMeta",
9
+ "RandomSampler",
10
+ "HyperGridSampler",
11
+ ]
12
+
@@ -0,0 +1,160 @@
1
+ import numpy as np
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+ from abc import ABC, abstractmethod
5
+ from .constraints import Constraints
6
+
7
+ @dataclass(frozen=True)
8
+ class FeatureMeta:
9
+ index: Optional[int] = None
10
+ low: Optional[float] = None
11
+ high: Optional[float] = None
12
+ dtype: str = "float" # "float", "int", "binary", "categorical"
13
+ categories:Optional[list] = None
14
+
15
+ @dataclass(frozen=True)
16
+ class DtypeMeta:
17
+ float: str = "float"
18
+ integer: str = "int"
19
+ bin:str = "binary"
20
+ cat:str = "categorical"
21
+ const:str = "constant"
22
+
23
+ @dataclass
24
+ class SamplerConfig:
25
+ features: list[FeatureMeta]
26
+ batch_size: int = 1000
27
+ random_state: Optional[int] = None
28
+ n_jobs: int = -1
29
+ max_retries: int = 1000
30
+
31
+
32
+ class BaseSampler(ABC):
33
+ """
34
+ Base class for constraint-based samplers.
35
+
36
+ This class provides common functionality for sampling based on feature metadata
37
+ and registered constraints. It is designed to be extended by specific sampler implementations.
38
+
39
+ Parameters
40
+ ----------
41
+ config : SamplerConfig
42
+ Configuration object containing feature metadata and sampling settings.
43
+ """
44
+ constraints: list[Constraints]
45
+
46
+ def __init__(self, config: SamplerConfig) -> None:
47
+ self.config = config
48
+ self._constraints = []
49
+
50
+ @classmethod
51
+ def setup(
52
+ cls,
53
+ X: np.ndarray,
54
+ random_state: Optional[int] = None,
55
+ n_jobs: int = -1,
56
+ max_retries: int = 1000,
57
+ ):
58
+ """
59
+ Create a RandomSampler from provided training data.
60
+
61
+ Parameters
62
+ ----------
63
+ X : np.ndarray
64
+ Input dataset used to infer feature ranges and types.
65
+ batch_size : int, default=1000
66
+ Number of samples generated per batch.
67
+ random_state : int or None, default=None
68
+ Seed for reproducible sampling.
69
+ n_jobs : int, default=-1
70
+ Number of parallel jobs.
71
+ max_retries : int, default=1000
72
+ Maximum retries for satisfying constraints.
73
+
74
+ Returns
75
+ -------
76
+ RandomSampler
77
+ """
78
+
79
+ features = []
80
+
81
+ for col in range(X.shape[1]):
82
+ col_data = X[:, col]
83
+
84
+ if any(val is None or (
85
+ isinstance(val, float) and np.isnan(val)
86
+ ) for val in col_data):
87
+ raise ValueError(f"Column {col} contains missing values (NaN/None).")
88
+
89
+ low, high = None, None
90
+ categories = None
91
+ dtype = None
92
+ unique_vals = np.unique(col_data)
93
+ try:
94
+ numeric_data = col_data.astype(float)
95
+ low = numeric_data.min()
96
+ high = numeric_data.max()
97
+
98
+ if np.isclose(low, high, equal_nan=True):
99
+ dtype = DtypeMeta.const
100
+ elif set(unique_vals).issubset({0, 1}):
101
+ dtype = DtypeMeta.bin
102
+ elif np.all(numeric_data.astype(int) == numeric_data):
103
+ dtype = DtypeMeta.integer
104
+ else:
105
+ dtype = DtypeMeta.float
106
+ except (ValueError, TypeError):
107
+ if len(unique_vals) == 1:
108
+ dtype = DtypeMeta.const
109
+ else:
110
+ dtype = DtypeMeta.cat
111
+ categories=np.unique(col_data).tolist()
112
+
113
+ features.append(
114
+ FeatureMeta(
115
+ index=col,
116
+ low=low,
117
+ high=high,
118
+ dtype=dtype,
119
+ categories=categories
120
+ )
121
+ )
122
+
123
+ config = SamplerConfig(
124
+ features = features,
125
+ random_state = random_state,
126
+ n_jobs = n_jobs,
127
+ max_retries = max_retries,
128
+ )
129
+
130
+ return cls(config)
131
+
132
+ def set_constraints(self, constraint_fn: str, **kwargs) -> None:
133
+ pass
134
+
135
+ def apply_constraints(self, row: np.ndarray) -> Optional[np.ndarray]:
136
+ pass
137
+
138
+ @abstractmethod
139
+ def sample(self, n_samples: int) -> np.ndarray:
140
+ pass
141
+
142
+ @property
143
+ def n_features(self) -> int:
144
+ return len(self.config.features)
145
+
146
+ def __len__(self):
147
+ return len(self.constraints)
148
+
149
+ def __getitem__(self, key):
150
+ return self.constraints[key]
151
+
152
+ def __repr__(self):
153
+ return f"<{self.__class__.__name__} with {len(self)} constraints>"
154
+
155
+ def __iter__(self):
156
+ return iter(self._constraints)
157
+
158
+ def __bool__(self):
159
+ return bool(self.constraints)
160
+
@@ -6,7 +6,7 @@ from .constraints import (
6
6
  RangeConstraint,
7
7
  CategoriesConstraint,
8
8
  StepConstraint,
9
- StepSumConstraint,
9
+ SumStepConstraint,
10
10
  FunctionConstraint
11
11
  )
12
12
  from .base import Constraints
@@ -20,6 +20,6 @@ __all__ = [
20
20
  "RangeConstraint",
21
21
  "CategoriesConstraint",
22
22
  "StepConstraint",
23
- "StepSumConstraint",
23
+ "SumStepConstraint",
24
24
  "FunctionConstraint"
25
25
  ]
@@ -4,7 +4,7 @@ from .base import Constraints, SelectConstraint
4
4
  from .. import validate as v
5
5
  from ..types import Numeric, ArrayLike, Bool, ConstraintFn
6
6
  from typing import Optional, Callable
7
- from ..errors import ConstraintViolationError, ConstraintError
7
+ from ..errors import ConstraintViolationError, ConstraintError, ConstraintTypeError
8
8
 
9
9
  class MultihotConstraint(SelectConstraint):
10
10
  def __init__(
@@ -29,7 +29,7 @@ class MultihotConstraint(SelectConstraint):
29
29
  ) -> np.ndarray:
30
30
  row[selected] = 1
31
31
  return row
32
-
32
+
33
33
  class RandomSelectConstraint(SelectConstraint):
34
34
  def __init__(
35
35
  self,
@@ -53,7 +53,8 @@ class RandomSelectConstraint(SelectConstraint):
53
53
  class SumConstraint(SelectConstraint):
54
54
  def __init__(self,
55
55
  cols: list[int],
56
- sum_value: Numeric = 1,
56
+ sum_value: Numeric = 1,
57
+ method: str = 'dirichlet',
57
58
  alpha: Optional[np.ndarray] = None,
58
59
  rng: Optional[np.random.Generator] = None,
59
60
  **kwargs
@@ -62,6 +63,7 @@ class SumConstraint(SelectConstraint):
62
63
 
63
64
  super().__init__(cols, **kwargs)
64
65
  self.sum_value = sum_value
66
+ self.method = method
65
67
  self.alpha = alpha
66
68
  self.rng = self._rng(rng)
67
69
 
@@ -80,14 +82,20 @@ class SumConstraint(SelectConstraint):
80
82
  Returns:
81
83
  np.ndarray: The modified row
82
84
  """
83
- if self.alpha is None:
84
- alpha = np.ones(len(selected))
85
- else:
86
- alpha = self.alpha
85
+
86
+ if self.method == 'dirichlet':
87
+ if self.alpha is None:
88
+ alpha = np.ones(len(selected))
89
+ else:
90
+ alpha = self.alpha
87
91
 
88
- weights = self.rng.dirichlet(alpha)
92
+ weights = self.rng.dirichlet(alpha)
93
+ row[selected] = weights * self.sum_value
94
+ elif self.method == 'simple':
95
+ row[selected] = row / np.sum(row) * self.sum_value
96
+ else:
97
+ raise ConstraintTypeError(f'Method: {self.method} not implemented')
89
98
 
90
- row[selected] = weights * self.sum_value
91
99
  return row
92
100
 
93
101
  class SumIntConstraint(SumConstraint):
@@ -103,7 +111,13 @@ class SumIntConstraint(SumConstraint):
103
111
  return row
104
112
 
105
113
  cuts = np.sort(self.rng.choice((self.sum_value + k - 1), k - 1, replace=False))
106
- parts = np.diff(np.concatenate(([-1], cuts, [self.sum_value + k - 1]))) - 1
114
+ parts = np.diff(
115
+ np.concatenate((
116
+ np.array([-1], dtype=cuts.dtype),
117
+ cuts,
118
+ np.array([self.sum_value + k - 1], dtype=cuts.dtype)
119
+ ))
120
+ ) - 1
107
121
 
108
122
  row[selected] = parts
109
123
  return row
@@ -134,11 +148,11 @@ class CategoriesConstraint(Constraints):
134
148
  elif self.strength == "soft":
135
149
  mask = mask
136
150
  else:
137
- raise ConstraintError(f"{self.strength} was not supported")
151
+ raise ConstraintError(f"{self.strength} was not supported.")
138
152
 
139
153
  valid_patterns = self.values[mask]
140
154
  if len(valid_patterns) == 0:
141
- raise ConstraintViolationError("No patterns found in categories")
155
+ raise ConstraintViolationError("No patterns found in categories.")
142
156
 
143
157
  idx = rng.integers(len(valid_patterns))
144
158
  selected_pattern = valid_patterns[idx]
@@ -159,7 +173,7 @@ class RangeConstraint(Constraints):
159
173
  return row
160
174
 
161
175
  class StepConstraint(Constraints):
162
- def __init__(self, col:int, low: float, high: float, step: float, **kwargs):
176
+ def __init__(self, col:int, step: float, low: float, high: float, **kwargs):
163
177
  super().__init__(cols=[col], **kwargs)
164
178
  self.values = np.arange(low, high + step, step)
165
179
  self.col = col
@@ -179,7 +193,7 @@ class StepConstraint(Constraints):
179
193
  row[self.col] = rng.choice(self.values)
180
194
  return row
181
195
 
182
- class StepSumConstraint(StepConstraint):
196
+ class SumStepConstraint(StepConstraint):
183
197
  def __init__(
184
198
  self,
185
199
  cols: list[int],
@@ -210,10 +224,7 @@ class StepSumConstraint(StepConstraint):
210
224
  def _constrain(self, row: np.ndarray, rng: Optional[np.random.Generator] = None) -> np.ndarray:
211
225
  rng = self._rng(rng)
212
226
 
213
- # 1. Initialize all target columns with their respective minimum (low) values
214
227
  current_values = self.lows.copy().astype(float)
215
-
216
- # 2. Calculate the difference to reach the target sum_value
217
228
  current_sum = np.sum(current_values)
218
229
  residual = self.sum_value - current_sum
219
230
 
@@ -224,7 +235,7 @@ class StepSumConstraint(StepConstraint):
224
235
  if not np.isclose(residual % self.step, 0) and not np.isclose(residual % self.step, self.step):
225
236
  raise ConstraintViolationError(f"Residual ({residual}) is not a multiple of step ({self.step}).")
226
237
 
227
- # 3. Randomly distribute the residual in 'step' increments
238
+ # Randomly distribute the residual in 'step' increments
228
239
  num_steps = int(round(residual / self.step))
229
240
 
230
241
  for _ in range(num_steps):
@@ -237,18 +248,16 @@ class StepSumConstraint(StepConstraint):
237
248
  if not eligible_indices:
238
249
  raise ConstraintViolationError("Target sum_value is unreachable within defined highs.")
239
250
 
240
- # Pick a random column and increment it
241
251
  target_idx = rng.choice(eligible_indices)
242
252
  current_values[target_idx] += self.step
243
253
 
244
- # 4. Final assignment to the row
245
254
  row[self.cols] = current_values
246
255
  return row
247
256
 
248
257
 
249
258
  class FunctionConstraint(Constraints):
250
- def __init__(self, fn: ConstraintFn):
251
- super().__init__(cols=[])
259
+ def __init__(self, fn: ConstraintFn, cols:list[int]):
260
+ super().__init__(cols=cols)
252
261
  self.fn = fn
253
262
 
254
263
  def _constrain(
@@ -256,15 +265,13 @@ class FunctionConstraint(Constraints):
256
265
  row: np.ndarray,
257
266
  rng: Optional[np.random.Generator] = None
258
267
  ) -> Optional[np.ndarray]:
268
+
259
269
  result = self.fn(row)
260
270
 
261
271
  if isinstance(result, Bool):
262
- if not result:
263
- return None
264
- else:
265
- return row
272
+ return row if result else None
266
273
  elif isinstance(result, np.ndarray):
267
- return result
274
+ row[self.cols] = result
275
+ return row
268
276
  else:
269
- from ..errors import ConstraintTypeError
270
- raise ConstraintTypeError("Constraint function must return either a boolean or a numpy array")
277
+ raise ConstraintTypeError("Constraint function must return either a boolean or a numpy array")
@@ -1,10 +1,13 @@
1
1
  from .random import RandomSampler
2
+ from .hypergrid import HyperGridSampler
2
3
  from ..base import BaseSampler
3
4
 
4
5
  __annotations__ = {
5
6
  "RandomSampler": BaseSampler,
7
+ "HyperGridSampler": BaseSampler
6
8
  }
7
9
 
8
10
  __all__ = [
9
11
  "RandomSampler",
12
+ "HyperGridSampler"
10
13
  ]
@@ -0,0 +1,136 @@
1
+ import numpy as np
2
+ from typing import Optional
3
+ from scipy.stats import qmc
4
+ from ..base import BaseSampler, DtypeMeta as dm
5
+ from ..errors import ConstraintViolationError
6
+ from ..terminals import spinning
7
+
8
+ class HyperGridSampler(BaseSampler):
9
+ """
10
+ Hybrid grid and LHS-based constraint sampler.
11
+
12
+ This sampler generates samples based on feature metadata inferred
13
+ from training data. Continuous features are sampled using Latin
14
+ Hypercube Sampling (LHS), while discrete features (e.g., integer,
15
+ binary, categorical) are sampled using grid random selection
16
+ over their respective value spaces.
17
+
18
+ Parameters
19
+ ----------
20
+ config : SamplerConfig
21
+ Configuration object containing feature metadata and sampling settings.
22
+
23
+ Notes
24
+ -----
25
+ Sampling strategy depends on feature data types:
26
+
27
+ - Float features:
28
+ Sampled using Latin Hypercube Sampling (LHS), then scaled to
29
+ their respective [low, high] ranges.
30
+
31
+ - Integer features:
32
+ Sampled uniformly from the inclusive range [low, high].
33
+
34
+ - Binary features:
35
+ Sampled uniformly from {0, 1}.
36
+
37
+ - Categorical features:
38
+ Sampled uniformly from the provided category list.
39
+
40
+ - Constant features:
41
+ Always return the same fixed value.
42
+ """
43
+
44
+ def __init__(self, config):
45
+ super().__init__(config)
46
+ self.rng = np.random.default_rng(config.random_state)
47
+
48
+ def _lhs(self, n_samples: int, float_features: list):
49
+ """LHS sampling for float columns"""
50
+ if not float_features:
51
+ return np.empty((n_samples, 0))
52
+
53
+ dim = len(float_features)
54
+ sampler = qmc.LatinHypercube(d=dim, seed=self.config.random_state)
55
+ sample = sampler.random(n=n_samples)
56
+
57
+ lows = np.array([f.low for f in float_features])
58
+ highs = np.array([f.high for f in float_features])
59
+
60
+ scaled = qmc.scale(sample, lows, highs)
61
+ return scaled
62
+
63
+ def _discrete(self, n_samples: int, features: list):
64
+ """Grid random sampling for discrete columns"""
65
+ cols = []
66
+
67
+ for f in features:
68
+ if f.dtype == dm.bin:
69
+ vals = np.array([0, 1])
70
+ elif f.dtype == dm.integer:
71
+ vals = np.arange(int(f.low), int(f.high) + 1)
72
+ elif f.dtype == dm.cat:
73
+ vals = np.array(f.categories)
74
+ elif f.dtype == dm.const:
75
+ vals = np.array([f.low])
76
+ else:
77
+ raise ConstraintViolationError(f"Unsupported dtype: {f.dtype}")
78
+
79
+ sampled = self.rng.choice(vals, size=n_samples)
80
+ cols.append(sampled)
81
+
82
+ return np.column_stack(cols) if cols else np.empty((n_samples, 0))
83
+
84
+ def _sample(self, n_samples: int) -> np.ndarray:
85
+ """
86
+ Generate samples based on feature data types.
87
+
88
+ Continuous features are sampled using Latin Hypercube Sampling (LHS),
89
+ while discrete features (e.g., "int", "binary", "categorical", "constant") are
90
+ sampled using uniform random selection over their respective value spaces.
91
+
92
+ Parameters
93
+ ----------
94
+ n_samples : int
95
+ Total number of samples to generate.
96
+
97
+ Returns
98
+ -------
99
+ np.ndarray
100
+ Array of shape (n_samples, n_features) containing the generated samples.
101
+ The column order matches the input feature configuration.
102
+
103
+ Notes
104
+ -----
105
+ - Float features are scaled to their respective [low, high] ranges.
106
+ - Integer features are sampled from the inclusive range [low, high].
107
+ - Categorical features are sampled from the provided category list.
108
+ - Constant features return the same value for all samples.
109
+ """
110
+
111
+ float_feats = [f for f in self.config.features if f.dtype == dm.float]
112
+ discrete_feats = [f for f in self.config.features if f.dtype != dm.float]
113
+
114
+ float_part = self._lhs(n_samples, float_feats)
115
+ discrete_part = self._discrete(n_samples, discrete_feats)
116
+
117
+ result = np.zeros((n_samples, self.n_features), dtype=object)
118
+
119
+ float_idx = 0
120
+ disc_idx = 0
121
+
122
+ for i, f in enumerate(self.config.features):
123
+ if f.dtype == dm.float:
124
+ result[:, i] = float_part[:, float_idx]
125
+ float_idx += 1
126
+ else:
127
+ result[:, i] = discrete_part[:, disc_idx]
128
+ disc_idx += 1
129
+
130
+ return result
131
+
132
+ def sample(self, n_samples: int) -> np.ndarray:
133
+ with spinning():
134
+ samples = self._sample(n_samples)
135
+ return samples
136
+
@@ -1,5 +1,5 @@
1
1
  import numpy as np
2
-
2
+ from ..terminals import spinning
3
3
  from ..base import BaseSampler, SamplerConfig, DtypeMeta as dm
4
4
  from ..constraints import *
5
5
  from ..errors import (
@@ -8,7 +8,8 @@ from ..errors import (
8
8
  DuplicateColumnWarning
9
9
  )
10
10
 
11
- from typing import Optional, Callable
11
+ from typing import Optional, Callable, Any
12
+ from types import MappingProxyType
12
13
  from collections import defaultdict
13
14
  from joblib import Parallel, delayed
14
15
 
@@ -19,7 +20,7 @@ class RandomSampler(BaseSampler):
19
20
  Random constraint-based sampler.
20
21
 
21
22
  This sampler generates samples based on feature metadata
22
- inferred from training data. Users can register constraints
23
+ inferred from training data. Users can register constraints
23
24
  that are applied during sample generation.
24
25
 
25
26
  Parameters
@@ -31,8 +32,15 @@ class RandomSampler(BaseSampler):
31
32
  -----
32
33
  Two types of constraints are supported:
33
34
 
34
- - Validation constraints: return a boolean.
35
- - Constructive constraints: return a modified numpy array.
35
+ - Validation constraints:
36
+ Return a boolean. If False, the sample is rejected.
37
+
38
+ - Constructive constraints:
39
+ Return a modified numpy array.
40
+
41
+ Internally, constraints are unified to return either:
42
+ - np.ndarray (valid sample)
43
+ - None (invalid sample)
36
44
 
37
45
  Sampling is performed with retry logic up to `max_retries`.
38
46
  Parallel generation is supported via joblib.
@@ -44,14 +52,14 @@ class RandomSampler(BaseSampler):
44
52
  super().__init__(config)
45
53
  self.rng = np.random.default_rng(config.random_state)
46
54
  self.features = config.features
47
- self.batch_size = config.batch_size
48
55
  self.n_jobs = config.n_jobs
49
56
  self.seed = config.random_state
50
57
  self.max_retries = config.max_retries
51
58
 
52
59
  self.dim = len(self.features)
53
- self.constraints:list[Constraints] = []
54
- self._registry = {
60
+ self._constraints:list[Constraints] = []
61
+ self._funcs: list[FunctionConstraint] = []
62
+ self._registry = MappingProxyType({
55
63
  "sum": SumConstraint,
56
64
  "sumint": SumIntConstraint,
57
65
  "multihot": MultihotConstraint,
@@ -59,17 +67,23 @@ class RandomSampler(BaseSampler):
59
67
  "range": RangeConstraint,
60
68
  "categories": CategoriesConstraint,
61
69
  "step": StepConstraint,
62
- "stepsum": StepSumConstraint
63
- }
70
+ "stepsum": SumStepConstraint,
71
+
72
+ })
73
+
74
+ @property
75
+ def constraints(self):
76
+ """Get a registered constraint class"""
77
+ return tuple(self._constraints.copy())
64
78
 
65
79
  def reset_constraints(self):
66
80
  """Clear all registered constraints."""
67
- self.constraints = []
68
-
81
+ self._constraints = []
82
+
69
83
  def set_constraints(
70
84
  self,
71
- constraint_fn: str | Callable = "sum",
72
- replace=False,
85
+ constraint_fn: str | Callable[[np.ndarray], bool],
86
+ reset=False,
73
87
  **kwargs
74
88
  ):
75
89
  """
@@ -78,14 +92,16 @@ class RandomSampler(BaseSampler):
78
92
  ----------
79
93
  constraint_fn : str or callable, default="sum"
80
94
  Type of constraint. Supported types:
81
- - callable: user-defined function that takes a row and returns a boolean. Required `fn` parameter.
95
+ - callable: user-defined function that takes a row and returns a boolean or a new row. `cols` must be provided in kwargs.
82
96
  - "sum": constraint based on the sum of selected columns. `sum_value` and `cols` must be provided in kwargs.
83
97
  - "sumint": similar to "sum" but ensures the sum is an integer. `sum_value` and `cols` must be provided in kwargs.
84
98
  - "multihot": constraint ensuring a specified number of columns in a set are active.`n_hot` and `cols` must be provided in kwargs.
85
99
  - "random": constraint selecting a random subset of columns. `cols`, `min_used`, and `max_used` can be provided in kwargs.
86
- - "range": constraint setting values within a specified range. `cols`, `min_val`, and `max_val` must be provided in kwargs.
87
- - "categories": constraint selecting from a list of categorical values. `col` and `values` must be provided in kwargs.
88
- replace : bool, default=True
100
+ - "range": constraint setting values within a specified range. `cols`, `low`, and `high` must be provided in kwargs.
101
+ - "categories": constraint selecting from a list of categorical values. `cols` and `values` must be provided in kwargs.
102
+ - "step": constraint selecting values that are multiples of a step. `cols` and `step`, `low`, and `high` must be provided in kwargs.
103
+ - "stepsum": constraint ensuring the sum of values is a multiple of a step.
104
+ reset : bool, default=True
89
105
  If True, clears existing constraints before adding the new one.
90
106
  **kwargs
91
107
  Additional parameters specific to the constraint type.
@@ -97,38 +113,40 @@ class RandomSampler(BaseSampler):
97
113
  >>> # Custom function constraint
98
114
  >>> sampler.set_constraints(lambda x: (0 < x[0] < 1) and (0 < x[1] < 1))
99
115
  >>> # Sum constraint
100
- >>> sampler.set_constraints("sum", sum_value=1, cols=[2, 3, 4], max_used=3)
116
+ >>> sampler.set_constraints("sum", sum_value=1, cols=[2, 3, 4], max_used=2)
101
117
  """
102
118
 
103
- if replace:
104
- self.constraints = []
105
-
119
+ if reset:
120
+ self._constraints = []
121
+ self._funcs = []
122
+
106
123
  if callable(constraint_fn):
107
- self.constraints.append(FunctionConstraint(fn=constraint_fn))
124
+ self._constraints.append(FunctionConstraint(fn=constraint_fn, **kwargs))
125
+ self._funcs.append(FunctionConstraint(fn=constraint_fn, **kwargs))
108
126
  elif isinstance(constraint_fn, str) and constraint_fn in self._registry:
109
- self.constraints.append(self._registry[constraint_fn](**kwargs))
127
+ self._constraints.append(self._registry[constraint_fn](**kwargs))
110
128
  else:
111
129
  raise ConstraintTypeError(
112
- f"Unsupported constraint type: {constraint_fn}"
113
- )
130
+ f"Unsupported constraint type: {constraint_fn}"
131
+ )
114
132
 
115
133
  def _base_sample(self, rng: np.random.Generator):
116
- # x = np.empty(self.dim, dtype=float)
117
- x = np.empty(len(self.config.features), dtype=object)
118
-
134
+ x = np.empty(self.dim, dtype=object)
119
135
  for i, f in enumerate(self.features):
120
- if f.dtype == dm.bin:
136
+ if f.dtype == dm.const:
137
+ x[i] = f.low
138
+ elif f.dtype == dm.bin:
121
139
  x[i] = rng.integers(0, 2)
122
- elif f.dtype == dm.integer:
140
+ elif f.dtype == dm.integer and f.low is not None and f.high is not None:
123
141
  x[i] = rng.integers(int(f.low), int(f.high) + 1)
124
- elif f.dtype == dm.flt:
142
+ elif f.dtype == dm.float and f.low is not None and f.high is not None:
125
143
  x[i] = rng.uniform(f.low, f.high)
126
144
  else:
127
145
  x[i] = 0
128
146
 
129
147
  return x
130
148
 
131
- def _fill_categorical_features(self, x, rng: np.random.Generator):
149
+ def _fill_categoricals(self, x, rng: np.random.Generator):
132
150
  for i, f in enumerate(self.features):
133
151
  if f.dtype == dm.cat:
134
152
  if f.categories:
@@ -139,34 +157,71 @@ class RandomSampler(BaseSampler):
139
157
 
140
158
  def _apply_constraints(self, row: np.ndarray) -> Optional[np.ndarray]:
141
159
  """
142
- Apply registered constraints to a generated row.
160
+ Apply all registered constraints sequentially.
143
161
 
144
162
  Parameters
145
163
  ----------
146
- row : np.ndarray
147
- The generated sample to validate/modify.
164
+ row : np.ndarray Input sample.
148
165
 
149
166
  Returns
150
167
  -------
151
168
  np.ndarray or None
152
- The modified row if constructive constraints are applied,
153
- or the original row if only validation constraints are present.
154
- Returns None if any validation constraint fails.
169
+ - np.ndarray: if all constraints succeed
170
+ - None: if any constraint fails
171
+
172
+ Notes
173
+ -----
174
+ Each constraint must return either:
175
+ - np.ndarray (possibly modified)
176
+ - None (indicating failure)
155
177
  """
156
178
  for constraint in self:
157
- row = constraint(row)
158
- if row is None:
179
+ if isinstance(constraint, FunctionConstraint):
180
+ continue
181
+
182
+ result = constraint(row)
183
+ if result is None:
159
184
  return None # Constraint violation
185
+ row = result
160
186
  return row
161
187
 
188
+ def _apply_funcs(self, row: np.ndarray) -> Optional[np.ndarray]:
189
+ for constraint in self._funcs:
190
+ result = constraint(row)
191
+ if result is None:
192
+ return None
193
+ return row
194
+
162
195
  def _detect_conflicts(self):
163
196
  col_usage = defaultdict(list)
197
+ constraints_by_col = defaultdict(list)
164
198
 
165
- for i, c in enumerate(self.constraints):
199
+ for i, c in enumerate(self._constraints):
166
200
  for col in getattr(c, "cols", []):
167
201
  col_usage[col].append(i)
202
+ constraints_by_col[col].append(c)
168
203
 
169
204
  for col, ids in col_usage.items():
205
+ constraints = constraints_by_col[col]
206
+ types = {getattr(c, "constraint_fn", None) for c in constraints}
207
+
208
+ meta = self.config.features[col]
209
+
210
+ if meta.dtype == dm.cat:
211
+ invalid = types & {"sum", "sumint", "range"}
212
+
213
+ if invalid:
214
+ raise ConstraintViolationError(
215
+ f"{invalid} cannot be applied to categorical column: {col}"
216
+ )
217
+
218
+ if meta.dtype == dm.const:
219
+ if len(ids) > 1:
220
+ warnings.warn(
221
+ f"Const column {col} has multiple constraints {ids} (types={types})",
222
+ DuplicateColumnWarning
223
+ )
224
+
170
225
  if len(ids) > 1:
171
226
  warnings.warn(
172
227
  f"Column {col} used in multiple constraints {ids}",
@@ -176,22 +231,35 @@ class RandomSampler(BaseSampler):
176
231
  def _generate_one(self, seed_offset: int = 0):
177
232
  base_seed = self.seed if self.seed is not None else None
178
233
  rng = np.random.default_rng(
179
- None if base_seed is None else base_seed + seed_offset
234
+ (base_seed + seed_offset) if base_seed else None
180
235
  )
181
236
 
182
237
  for _ in range(self.max_retries):
183
238
  x = self._base_sample(rng)
184
- x = self._fill_categorical_features(x, rng)
185
- x = self._apply_constraints(x)
239
+ x = self._fill_categoricals(x, rng)
240
+ x = self._apply_constraints(x)
241
+ if x is None:
242
+ continue
243
+
244
+ x = self._apply_funcs(x)
186
245
  if x is not None:
187
246
  return x
188
-
247
+
189
248
  raise ConstraintViolationError("Max retries exceeded.")
190
-
191
- def _generate_batch(self, batch_size):
192
- return np.array([self._generate_one() for _ in range(batch_size)])
193
249
 
194
- def sample(self, n_samples: int):
250
+ def _sample(self, n_samples: int) -> np.ndarray:
251
+ if self.n_jobs == 1:
252
+ samples = [
253
+ self._generate_one(i) for i in range(n_samples)
254
+ ]
255
+ else:
256
+ samples = Parallel(n_jobs=self.n_jobs)(
257
+ delayed(self._generate_one)(i)
258
+ for i in range(n_samples)
259
+ )
260
+ return np.array(samples)
261
+
262
+ def sample(self, n_samples: int) -> np.ndarray:
195
263
  """
196
264
  Generate samples satisfying all registered constraints.
197
265
 
@@ -206,31 +274,13 @@ class RandomSampler(BaseSampler):
206
274
  Array of shape (n_samples, n_features).
207
275
 
208
276
  """
209
-
277
+
210
278
  self._detect_conflicts()
211
279
 
212
- batches = []
213
- remaining = n_samples
214
-
215
- print("sampling...", flush=True)
216
-
217
- while remaining > 0:
218
- current_batch = min(self.batch_size, remaining)
219
-
220
- if self.n_jobs == 1:
221
- batch = self._generate_batch(current_batch)
222
- else:
223
- results = Parallel(n_jobs=self.n_jobs)(
224
- delayed(self._generate_one)()
225
- for _ in range(current_batch)
226
- )
227
- batch = np.array(results)
228
-
229
- batches.append(batch)
230
-
231
- remaining -= current_batch
280
+ with spinning():
281
+ samples = self._sample(n_samples)
282
+
283
+ return samples
232
284
 
233
- print(f"{n_samples - remaining}/{n_samples}", flush=True)
234
285
 
235
- return np.vstack(batches)
236
286
 
@@ -0,0 +1,23 @@
1
+ class ConstraintError(ValueError):
2
+ """Custom exception for constraint violations"""
3
+ pass
4
+
5
+ class ConstraintTypeError(ConstraintError):
6
+ """Raised when an unsupported constraint type is specified."""
7
+ pass
8
+
9
+ class ConstraintViolationError(ConstraintError):
10
+ """Raised when a constraint is violated during data generation"""
11
+ pass
12
+
13
+ class ConstraintValidationError(ConstraintError):
14
+ """Raised when a constraint definition is invalid."""
15
+ pass
16
+
17
+ class ConstraintWarning(UserWarning):
18
+ """Base warning for constraint-related issues."""
19
+ pass
20
+
21
+ class DuplicateColumnWarning(ConstraintWarning):
22
+ """Column is used in multiple constraints."""
23
+ pass
@@ -0,0 +1,27 @@
1
+ import sys
2
+ import time
3
+ import threading
4
+ from contextlib import contextmanager
5
+
6
+ def spinner(stop_event):
7
+ symbols = ["|", "/", "-", "\\"]
8
+ i = 0
9
+ while not stop_event.is_set():
10
+ sys.stdout.write(f"\rsampling... {symbols[i % len(symbols)]}")
11
+ sys.stdout.flush()
12
+ time.sleep(0.1)
13
+ i += 1
14
+
15
+ sys.stdout.write("\rsampling completed \n")
16
+ sys.stdout.flush()
17
+
18
+ @contextmanager
19
+ def spinning(msg="sampling..."):
20
+ stop_event = threading.Event()
21
+ t = threading.Thread(target=spinner, args=(stop_event,))
22
+ t.start()
23
+ try:
24
+ yield
25
+ finally:
26
+ stop_event.set()
27
+ t.join()
@@ -0,0 +1,7 @@
1
+ import numpy as np
2
+ from typing import TypeAlias, Union, Callable
3
+
4
+ Numeric: TypeAlias = Union[int, float, np.integer, np.floating]
5
+ ArrayLike: TypeAlias = Union[np.ndarray, list, tuple]
6
+ Bool: TypeAlias = Union[bool, np.bool_]
7
+ ConstraintFn: TypeAlias = Callable[[np.ndarray], Union[Bool, np.ndarray]]
@@ -0,0 +1,48 @@
1
+ import numpy as np
2
+ from typing import Optional
3
+ from .errors import *
4
+ from .types import Numeric, ArrayLike
5
+ import warnings
6
+
7
+ def validate_cols(cols):
8
+ if not isinstance(cols, ArrayLike):
9
+ raise ConstraintValidationError("cols must be an array-like of column indices")
10
+
11
+ if len(set(cols)) != len(cols):
12
+ raise ConstraintValidationError("Column indices must be unique")
13
+
14
+ for c in cols:
15
+ if not isinstance(c, (int, np.integer)) or c < 0:
16
+ raise ConstraintValidationError("Column indices must be non-negative integers")
17
+
18
+ def validate_usage(min_used: int, max_used: Optional[int]):
19
+ if min_used < 0:
20
+ raise ConstraintValidationError("min_used must be non-negative")
21
+
22
+ if max_used is None:
23
+ raise ConstraintValidationError("max_used must be specified")
24
+
25
+ if max_used is not None and max_used < 0:
26
+ raise ConstraintValidationError("max_used must be non-negative")
27
+
28
+ if min_used > max_used:
29
+ raise ConstraintValidationError("min_used must be <= max_used")
30
+
31
+ def validate_values(value: Numeric):
32
+ if not isinstance(value, Numeric):
33
+ raise ConstraintValidationError("Value must be a numeric")
34
+
35
+ if value < 0:
36
+ warnings.warn("Value should be non-negative", ConstraintWarning)
37
+
38
+ def validate_range(low: Numeric, high: Numeric):
39
+ if not isinstance(low, Numeric):
40
+ raise ConstraintTypeError("low must be a numeric")
41
+
42
+ if not isinstance(high, Numeric):
43
+ raise ConstraintTypeError("high must be a numeric")
44
+
45
+ if low > high:
46
+ raise ConstraintValidationError("low must be <= high")
47
+
48
+
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: mlsampler
3
+ Version: 0.3.3
4
+ Summary: Flexible constrained random sampler for reverse analysis in machine learning
5
+ Author: Jugai O
6
+ License: LICENSE
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy
11
+ Requires-Dist: joblib
12
+ Dynamic: license-file
13
+
14
+ # RandomSampler
15
+
16
+ mlsampler is a flexible, constraint-aware sampling library designed for reverse analysis and data exploration in machine learning workflows.
17
+
18
+ It allows you to generate synthetic samples that satisfy complex constraints across continuous, integer, binary, and categorical features.
19
+
20
+ ## Features
21
+ - Constraint-based sampling
22
+ - Support for continuous / categorical features
23
+ - Parallel generation
24
+
25
+ ## Documentation
26
+ https://jugais.github.io/randsampler/
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install mlsampler
32
+ ```
33
+
34
+ ## Use Cases
35
+ - Reverse analysis (finding inputs that satisfy target conditions)
36
+ - Candidate generation for optimization
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/mlsampler/__init__.py
5
+ src/mlsampler/base.py
6
+ src/mlsampler/errors.py
7
+ src/mlsampler/terminals.py
8
+ src/mlsampler/types.py
9
+ src/mlsampler/validate.py
10
+ src/mlsampler.egg-info/PKG-INFO
11
+ src/mlsampler.egg-info/SOURCES.txt
12
+ src/mlsampler.egg-info/dependency_links.txt
13
+ src/mlsampler.egg-info/requires.txt
14
+ src/mlsampler.egg-info/top_level.txt
15
+ src/mlsampler/constraints/__init__.py
16
+ src/mlsampler/constraints/base.py
17
+ src/mlsampler/constraints/constraints.py
18
+ src/mlsampler/engine/__init__.py
19
+ src/mlsampler/engine/hypergrid.py
20
+ src/mlsampler/engine/random.py
@@ -0,0 +1 @@
1
+ mlsampler
mlsampler-0.3.0/PKG-INFO DELETED
@@ -1,21 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: mlsampler
3
- Version: 0.3.0
4
- Summary: Flexible constrained random sampler for reverse analysis in machine learning
5
- Author: Jugai O
6
- License: LICENSE
7
- Requires-Python: >=3.11
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: numpy
11
- Requires-Dist: joblib
12
- Dynamic: license-file
13
-
14
- # RandomSampler
15
-
16
- Flexible constrained random sampler for reverse analysis in machine learning.
17
-
18
- ## Installation
19
-
20
- ```bash
21
- pip install mlsampler
mlsampler-0.3.0/README.md DELETED
@@ -1,8 +0,0 @@
1
- # RandomSampler
2
-
3
- Flexible constrained random sampler for reverse analysis in machine learning.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- pip install mlsampler
@@ -1,21 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: mlsampler
3
- Version: 0.3.0
4
- Summary: Flexible constrained random sampler for reverse analysis in machine learning
5
- Author: Jugai O
6
- License: LICENSE
7
- Requires-Python: >=3.11
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: numpy
11
- Requires-Dist: joblib
12
- Dynamic: license-file
13
-
14
- # RandomSampler
15
-
16
- Flexible constrained random sampler for reverse analysis in machine learning.
17
-
18
- ## Installation
19
-
20
- ```bash
21
- pip install mlsampler
@@ -1,13 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- src/constraints/__init__.py
5
- src/constraints/base.py
6
- src/constraints/constraints.py
7
- src/engine/__init__.py
8
- src/engine/random.py
9
- src/mlsampler.egg-info/PKG-INFO
10
- src/mlsampler.egg-info/SOURCES.txt
11
- src/mlsampler.egg-info/dependency_links.txt
12
- src/mlsampler.egg-info/requires.txt
13
- src/mlsampler.egg-info/top_level.txt
@@ -1,2 +0,0 @@
1
- constraints
2
- engine
File without changes
File without changes