SKSurrogate 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.
SKSurrogate/ci.py ADDED
@@ -0,0 +1,93 @@
1
+ """Quality gates for validating model bundles in CI pipelines."""
2
+
3
+ from pathlib import Path
4
+ from numbers import Real
5
+
6
+ from .modelbundle import ModelBundle, load_bundle
7
+
8
+
9
+ class BundleQualityGateError(ValueError):
10
+ """Raised when a model bundle fails one or more CI quality gates."""
11
+
12
+
13
+ def _load_bundle(bundle_or_path, expected_schema):
14
+ if isinstance(bundle_or_path, ModelBundle):
15
+ if expected_schema is not None and bundle_or_path.schema != expected_schema:
16
+ raise ValueError("Model bundle schema is incompatible with the expected schema")
17
+ return bundle_or_path, None
18
+ path = Path(bundle_or_path)
19
+ return load_bundle(path, expected_schema=expected_schema, strict_dependencies=False), path
20
+
21
+
22
+ def check_bundle_quality(
23
+ bundle_or_path,
24
+ *,
25
+ expected_schema=None,
26
+ metric_thresholds=None,
27
+ max_bundle_size_bytes=None,
28
+ ):
29
+ """Return structured CI checks for a model bundle.
30
+
31
+ ``metric_thresholds`` maps metric names to minimum accepted numeric values.
32
+ A bundle path is required when ``max_bundle_size_bytes`` is configured.
33
+ """
34
+ checks = []
35
+ failures = []
36
+ try:
37
+ bundle, path = _load_bundle(bundle_or_path, expected_schema)
38
+ checks.append({"name": "schema_compatibility", "passed": True})
39
+ except (OSError, TypeError, ValueError) as exc:
40
+ checks.append({"name": "schema_compatibility", "passed": False, "error": str(exc)})
41
+ failures.append(checks[-1])
42
+ return {"passed": False, "checks": checks, "failures": failures}
43
+
44
+ for metric_name, minimum in (metric_thresholds or {}).items():
45
+ value = bundle.metrics.get(metric_name)
46
+ passed = isinstance(value, Real) and not isinstance(value, bool) and value >= minimum
47
+ check = {
48
+ "name": "metric_threshold",
49
+ "metric": metric_name,
50
+ "minimum": minimum,
51
+ "value": value,
52
+ "passed": passed,
53
+ }
54
+ checks.append(check)
55
+ if not passed:
56
+ failures.append(check)
57
+
58
+ if max_bundle_size_bytes is not None:
59
+ size = path.stat().st_size if path is not None else None
60
+ passed = size is not None and size <= max_bundle_size_bytes
61
+ check = {
62
+ "name": "bundle_size",
63
+ "maximum": max_bundle_size_bytes,
64
+ "value": size,
65
+ "passed": passed,
66
+ }
67
+ checks.append(check)
68
+ if not passed:
69
+ failures.append(check)
70
+
71
+ return {"passed": not failures, "checks": checks, "failures": failures}
72
+
73
+
74
+ def assert_bundle_quality(
75
+ bundle_or_path,
76
+ *,
77
+ expected_schema=None,
78
+ metric_thresholds=None,
79
+ max_bundle_size_bytes=None,
80
+ ):
81
+ """Raise :class:`BundleQualityGateError` when CI checks fail."""
82
+ report = check_bundle_quality(
83
+ bundle_or_path,
84
+ expected_schema=expected_schema,
85
+ metric_thresholds=metric_thresholds,
86
+ max_bundle_size_bytes=max_bundle_size_bytes,
87
+ )
88
+ if not report["passed"]:
89
+ raise BundleQualityGateError("Model bundle failed quality gates: %s" % report["failures"])
90
+ return report
91
+
92
+
93
+ __all__ = ["BundleQualityGateError", "assert_bundle_quality", "check_bundle_quality"]
@@ -0,0 +1,31 @@
1
+ """Approval-gated model promotion and rollback helpers."""
2
+
3
+
4
+ class DeploymentApprovalGate:
5
+ """Require approvals and passing quality checks before promotion."""
6
+
7
+ def __init__(self, registry, required_approvals=1):
8
+ if required_approvals < 1:
9
+ raise ValueError("required_approvals must be at least 1")
10
+ self.registry = registry
11
+ self.required_approvals = required_approvals
12
+
13
+ def promote(self, task_name, model_version, state, *, approvers, quality_report=None):
14
+ unique_approvers = sorted({str(approver) for approver in approvers if str(approver).strip()})
15
+ if len(unique_approvers) < self.required_approvals:
16
+ raise PermissionError(
17
+ "Deployment requires %d unique approvals" % self.required_approvals
18
+ )
19
+ if quality_report is not None and not quality_report.get("passed", False):
20
+ raise ValueError("Deployment quality gates did not pass")
21
+ for approver in unique_approvers:
22
+ self.registry.record_approval(task_name, model_version, approver, state=state)
23
+ self.registry.promote(task_name, model_version, state)
24
+ return {"status": "promoted", "task_name": task_name, "model_version": model_version, "state": state}
25
+
26
+ def rollback(self, task_name, state, model_version=None):
27
+ self.registry.rollback(task_name, state, model_version=model_version)
28
+ return {"status": "rolled_back", "task_name": task_name, "state": state, "model_version": model_version}
29
+
30
+
31
+ __all__ = ["DeploymentApprovalGate"]
SKSurrogate/eoa.py ADDED
@@ -0,0 +1,381 @@
1
+ """Configurable evolutionary optimization operators.
2
+
3
+ The :class:`EOA` coordinator evolves a supplied population of tuple-like
4
+ individuals. Fitness values are maximized by the built-in crossover and
5
+ elitism operators.
6
+ """
7
+
8
+
9
+ class EOA(object):
10
+ """Coordinate population initialization, evolution, and checkpointing.
11
+
12
+ ``fitness`` receives an ``OrderedDict`` of individuals and must return an
13
+ ``OrderedDict`` with numeric fitness values. Larger values are considered
14
+ better. Operators are classes; they are instantiated with the remaining
15
+ keyword arguments and called with this ``EOA`` instance.
16
+
17
+ :param population: Complete collection of possible individuals.
18
+ :param fitness: Callback that evaluates an ``OrderedDict`` of individuals.
19
+ :param init_pop: Parent initializer class; defaults to ``UniformRand``.
20
+ :param recomb: Recombination class; defaults to ``UniformCrossover``.
21
+ :param mutation: Mutation class; defaults to ``Mutation``.
22
+ :param termination: Termination class; defaults to ``MaxGenTermination``.
23
+ :param elitism: Elitism class; defaults to ``Elites``.
24
+ :param num_parents: Number of parents selected each generation. If omitted,
25
+ it is derived from ``parents_porp``.
26
+ :param parents_porp: Parent proportion used when ``num_parents`` is omitted;
27
+ defaults to ``0.1``.
28
+ :param elits_porp: Proportion used to calculate the elite count; defaults to
29
+ ``0.2``.
30
+ :param mutation_prob: Probability of changing each individual element;
31
+ defaults to ``0.05``.
32
+ :param max_generation: Maximum generation count for the default termination
33
+ operator; defaults to ``50``.
34
+ :param genes: Complete gene list used by ``Mutation``. Inferred from the
35
+ population when omitted.
36
+ :param init_genes: Genes allowed in the first position during mutation.
37
+ :param term_genes: Genes allowed in the last position during mutation.
38
+ :param task_name: Checkpoint filename prefix; defaults to ``"EOA"``.
39
+ :param check_point: Directory prefix for checkpoints; defaults to ``"./"``.
40
+ The directory must already exist.
41
+ :param random_state: Optional seed for reproducible built-in operators.
42
+ """
43
+ CHECKPOINT_VERSION = 1
44
+
45
+
46
+ def __init__(self, population, fitness, **kwargs):
47
+
48
+ from collections import OrderedDict
49
+ import random
50
+
51
+ self.population = population
52
+ self.random_state = kwargs.pop("random_state", None)
53
+ self.rng = random.Random(self.random_state)
54
+ self.init_pop = kwargs.pop("init_pop", UniformRand)(**kwargs)
55
+ self.fitness = fitness
56
+ self.recomb = kwargs.pop("recomb", UniformCrossover)(**kwargs)
57
+ self.mutation = kwargs.pop("mutation", Mutation)(**kwargs)
58
+ self.termination = kwargs.pop("termination", MaxGenTermination)(**kwargs)
59
+ self.elitism = kwargs.pop("elitism", Elites)(**kwargs)
60
+ self.population_size = len(self.population)
61
+ self.parents_porp = kwargs.pop("parents_porp", 0.1)
62
+ self.num_parents = kwargs.pop(
63
+ "num_parents", 2 * int(self.population_size * self.parents_porp / 2.0)
64
+ )
65
+ if self.num_parents > self.population_size:
66
+ raise ValueError(
67
+ "num_parents ({num_parents}) exceeds the population size "
68
+ "({population_size}). Narrow num_parents (or widen the search "
69
+ "space) so that at least num_parents candidates exist.".format(
70
+ num_parents=self.num_parents,
71
+ population_size=self.population_size,
72
+ )
73
+ )
74
+ self.elits_porp = kwargs.pop("elits_porp", 0.2)
75
+ self.num_elites = int(self.elits_porp * self.num_parents)
76
+ self.mutation_prob = kwargs.pop("mutation_prob", 0.05)
77
+ self.max_generations = kwargs.pop("max_generation", 50)
78
+ self.generation_num = 0
79
+ self.genes = kwargs.pop("genes", [])
80
+ self.init_genes = kwargs.pop("init_genes", [])
81
+ self.term_genes = kwargs.pop("term_genes", [])
82
+ self.task_name = kwargs.pop("task_name", "EOA")
83
+ self.check_point = kwargs.pop("check_point", "./")
84
+ if not self.genes:
85
+ self.find_genes()
86
+ self.evals = OrderedDict([(_, None) for _ in self.population])
87
+ self.parents = OrderedDict()
88
+ self.children = OrderedDict()
89
+
90
+ def find_genes(self):
91
+ """Infer mutation genes and positional gene lists from the population."""
92
+ for ind in self.population:
93
+ for e in ind:
94
+ if e not in self.genes:
95
+ self.genes.append(e)
96
+ if not self.init_genes:
97
+ self.init_genes = self.genes
98
+ if not self.term_genes:
99
+ self.term_genes = self.genes
100
+
101
+ def __save(self):
102
+ """Serialize the current optimizer state to the ``.eoa`` checkpoint."""
103
+ from pickle import dumps
104
+
105
+ fl = open(self.check_point + self.task_name + ".eoa", "wb")
106
+ info = dict(
107
+ checkpoint_version=self.CHECKPOINT_VERSION,
108
+ population_size=self.population_size,
109
+ parents_porp=self.parents_porp,
110
+ num_parents=self.num_parents,
111
+ elits_porp=self.elits_porp,
112
+ num_elites=self.num_elites,
113
+ mutation_prob=self.mutation_prob,
114
+ max_generations=self.max_generations,
115
+ generation_num=self.generation_num,
116
+ genes=self.genes,
117
+ init_genes=self.init_genes,
118
+ term_genes=self.term_genes,
119
+ task_name=self.task_name,
120
+ check_point=self.check_point,
121
+ random_state=self.random_state,
122
+ rng_state=self.rng.getstate(),
123
+ evals=self.evals,
124
+ parents=self.parents,
125
+ children=self.children,
126
+ )
127
+ fl.write(dumps(info))
128
+ fl.close()
129
+
130
+ def __load(self):
131
+ """Restore a matching checkpoint when one exists."""
132
+ from pickle import loads
133
+
134
+ try:
135
+ fl = open(self.check_point + self.task_name + ".eoa", "rb")
136
+ info = loads(fl.read())
137
+ fl.close()
138
+ if info.get("checkpoint_version", self.CHECKPOINT_VERSION) != self.CHECKPOINT_VERSION:
139
+ raise ValueError("Unsupported EOA checkpoint version")
140
+ self.population_size = info["population_size"]
141
+ self.parents_porp = info["parents_porp"]
142
+ self.num_parents = info["num_parents"]
143
+ self.elits_porp = info["elits_porp"]
144
+ self.num_elites = info["num_elites"]
145
+ self.mutation_prob = info["mutation_prob"]
146
+ self.max_generations = info["max_generations"]
147
+ self.generation_num = info["generation_num"]
148
+ self.genes = info["genes"]
149
+ self.init_genes = info["init_genes"]
150
+ self.term_genes = info["term_genes"]
151
+ self.task_name = info["task_name"]
152
+ self.check_point = info["check_point"]
153
+ self.random_state = info.get("random_state", self.random_state)
154
+ if "rng_state" in info:
155
+ self.rng.setstate(info["rng_state"])
156
+ self.evals = info["evals"]
157
+ self.parents = info["parents"]
158
+ self.children = info["children"]
159
+ except FileNotFoundError:
160
+ pass
161
+
162
+ def __call__(self, *args, **kwargs):
163
+ """Run generations until the configured termination operator returns true.
164
+
165
+ The method mutates ``parents``, ``children``, ``evals`` and
166
+ ``generation_num`` in place. It returns ``None``; inspect ``evals`` or
167
+ ``children`` after the run to retrieve results.
168
+ """
169
+ self.parents = self.init_pop(self)
170
+ self.__load()
171
+ #tqdm = None
172
+ #pbar = None
173
+ #try:
174
+ # from tqdm import tqdm
175
+ #except ImportError:
176
+ # tqdm = None
177
+ #if tqdm is not None:
178
+ # pbar = tqdm(total=self.max_generations)
179
+ #pbar.update(self.generation_num)
180
+ while not self.termination(self):
181
+ print("Generation {g} of {t}".format(g=self.generation_num, t=self.max_generations))
182
+ self.__save()
183
+ self.generation_num += 1
184
+ self.parents = self.fitness(self.parents)
185
+ for _ in self.parents:
186
+ self.evals[_] = self.parents[_]
187
+ self.recomb(self)
188
+ self.mutation(self)
189
+ self.children = self.fitness(self.children)
190
+ for _ in self.children:
191
+ self.evals[_] = self.children[_]
192
+ self.elitism(self)
193
+ self.parents = self.children
194
+ #if tqdm is not None:
195
+ # pbar.update(1)
196
+
197
+
198
+ class UniformRand(object):
199
+ """Select distinct initial parents uniformly from the population."""
200
+
201
+ def __init__(self, **kwargs):
202
+ pass
203
+
204
+ def __call__(self, ref, *args, **kwargs):
205
+ """Return an ``OrderedDict`` containing ``ref.num_parents`` individuals."""
206
+ from collections import OrderedDict
207
+ import random
208
+
209
+ rng = getattr(ref, "rng", random)
210
+ indices = rng.sample(range(ref.population_size), ref.num_parents)
211
+ return OrderedDict(
212
+ [(ref.population[i], ref.evals[ref.population[i]]) for i in indices]
213
+ )
214
+
215
+
216
+ class MaxGenTermination(object):
217
+ """Stop when ``ref.generation_num`` reaches ``ref.max_generations``."""
218
+
219
+ def __init__(self, **kwargs):
220
+ pass
221
+
222
+ def __call__(self, ref, *args, **kwargs):
223
+ """Return ``True`` when the configured generation limit is reached."""
224
+ if ref.generation_num < ref.max_generations:
225
+ return False
226
+ return True
227
+
228
+
229
+ class UniformCrossover(object):
230
+ """Select fitness-weighted parent pairs and create tuple children.
231
+
232
+ The operator expects numeric parent fitness values and writes the
233
+ resulting children to ``ref.children``. Higher fitness receives greater
234
+ selection weight.
235
+ """
236
+
237
+ def __init__(self, **kwargs):
238
+ self.fitnesses = []
239
+ self.fmin = 0.0
240
+ self.fmax = 0.0
241
+ self.kwargs = kwargs
242
+ self.mated = False
243
+
244
+ def scale(self, scrs):
245
+ """Normalize fitness values into non-negative mating weights."""
246
+ self.fmin = min(scrs)
247
+ self.fmax = max(scrs)
248
+ lng = self.fmax - self.fmin
249
+ if lng == 0.0:
250
+ lng = 1.0
251
+ self.fitnesses = [(_ - self.fmin) / lng for _ in scrs]
252
+
253
+ def select_idx(self):
254
+ """Select a parent index using the current mating weights."""
255
+ fsum = sum(self.fitnesses)
256
+ r = self.rng.uniform(0.0, fsum)
257
+ idx = 0
258
+ F = self.fitnesses[idx]
259
+ while F < r:
260
+ idx += 1
261
+ F += self.fitnesses[idx]
262
+ return idx
263
+
264
+ def pair(self):
265
+ """Remove and return one fitness-weighted pair from the parent pool."""
266
+ p1 = self.parents.pop(-1)
267
+ self.fitnesses.pop(-1)
268
+ idx = self.select_idx()
269
+ p2 = self.parents.pop(idx)
270
+ self.fitnesses.pop(idx)
271
+ return p1, p2
272
+
273
+ def mate(self, p1, p2):
274
+ """Exchange tuple suffixes from two parents and return two children."""
275
+ l1 = len(p1)
276
+ l2 = len(p2)
277
+ if l1 == 1 and l2 == 1:
278
+ c1 = (p1[0], p2[0])
279
+ c2 = (p2[0], p1[0])
280
+ return c1, c2
281
+ r = self.rng.randint(1, max(l1, l2))
282
+ cl1 = max(0, l1 - r)
283
+ cl2 = max(0, l2 - r)
284
+ c1l = list(p1)[:cl1]
285
+ c1r = list(p1)[cl1:]
286
+ c2l = list(p2)[:cl2]
287
+ c2r = list(p2)[cl2:]
288
+ c1 = tuple(c1l + c2r)
289
+ c2 = tuple(c2l + c1r)
290
+ self.mated = True
291
+ return c1, c2
292
+
293
+ def __call__(self, ref, *args, **kwargs):
294
+ """Populate ``ref.children`` with recombined individuals."""
295
+ from collections import OrderedDict
296
+
297
+ self.rng = ref.rng
298
+ ref.parents = OrderedDict(sorted(ref.parents.items(), key=lambda x: x[1]))
299
+ self.parents = list(ref.parents.keys())
300
+ self.scale(list(ref.parents.values()))
301
+ children = []
302
+ while len(self.parents) > 1:
303
+ p1, p2 = self.pair()
304
+ c1, c2 = self.mate(p1, p2)
305
+ if c1 in ref.evals:
306
+ children += [c1]
307
+ if c2 in ref.evals:
308
+ children += [c2]
309
+ ref.children = OrderedDict([(c, ref.evals[c]) for c in children])
310
+ ref.children = ref.fitness(ref.children)
311
+ ref.children = OrderedDict(sorted(ref.children.items(), key=lambda x: x[1]))
312
+
313
+
314
+ class Elites(object):
315
+ """Preserve the highest-fitness parents among the next generation."""
316
+
317
+ def __init__(self, **kwargs):
318
+ pass
319
+
320
+ def __call__(self, ref, *args, **kwargs):
321
+ """Merge elite parents into ``ref.children`` in fitness order."""
322
+ from collections import OrderedDict
323
+
324
+ children = ref.children
325
+ parents = ref.parents
326
+ dif_ = ref.num_elites + len(parents) - len(children)
327
+ top_elites = list(parents.items())[-dif_:]
328
+ top_children = list(children.items())[dif_:]
329
+ ref.children = OrderedDict(
330
+ sorted(top_elites + top_children, key=lambda x: x[1])
331
+ )
332
+
333
+
334
+ class Mutation(object):
335
+ """Mutate child genes using the configured per-element probability."""
336
+
337
+ def __init__(self, **kwargs):
338
+ pass
339
+
340
+ def __call__(self, ref, *args, **kwargs):
341
+ """Replace genes in ``ref.children`` and remove empty mutations."""
342
+ from collections import OrderedDict
343
+
344
+ rng = ref.rng
345
+ mchildren = []
346
+ for chld in ref.children:
347
+ mchld = []
348
+ idx = 0
349
+ length = len(chld) - 1
350
+ for e in chld:
351
+ me = e
352
+ prb = rng.uniform(0, 1.0)
353
+ if prb <= ref.mutation_prob:
354
+ if idx == 0:
355
+ lng = len(ref.init_genes)
356
+ rdx = rng.randint(0, lng)
357
+ if rdx < lng:
358
+ me = ref.init_genes[rdx]
359
+ else:
360
+ me = ""
361
+ elif idx == length:
362
+ lng = len(ref.term_genes)
363
+ rdx = rng.randint(0, lng)
364
+ if rdx < lng:
365
+ me = ref.term_genes[rdx]
366
+ else:
367
+ me = ""
368
+ else:
369
+ lng = len(ref.genes)
370
+ rdx = rng.randint(0, lng)
371
+ if rdx < lng:
372
+ me = ref.genes[rdx]
373
+ else:
374
+ me = ""
375
+ if me != "":
376
+ mchld.append(me)
377
+ idx += 1
378
+ new_chld = tuple(mchld)
379
+ if new_chld in ref.evals:
380
+ mchildren.append((new_chld, ref.evals[new_chld]))
381
+ ref.children = OrderedDict(mchildren)