orivex 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.
orivex/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Better FLACCO: selective and verified exploratory landscape analysis."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from orivex.api import compute, list_features
6
+ from orivex.capabilities import AutogradSupport, FeatureCapability
7
+ from orivex.normalization import YNormalization
8
+ from orivex.result import BackendName, DeviceType, FloatingDType
9
+ from orivex.sample import LandscapeSample, ObjectiveSense, ObjectiveSenseName
10
+ from orivex.specs import (
11
+ CostModel,
12
+ CostTier,
13
+ FeatureSpec,
14
+ InputRequirement,
15
+ InvarianceBehavior,
16
+ InvarianceClaim,
17
+ MetricKind,
18
+ Reference,
19
+ Transformation,
20
+ )
21
+
22
+ try:
23
+ __version__ = version("orivex")
24
+ except PackageNotFoundError: # Running directly from a source checkout.
25
+ __version__ = "0.0.0"
26
+
27
+ __all__ = [
28
+ "AutogradSupport",
29
+ "BackendName",
30
+ "CostModel",
31
+ "CostTier",
32
+ "DeviceType",
33
+ "FeatureCapability",
34
+ "FeatureSpec",
35
+ "FloatingDType",
36
+ "InputRequirement",
37
+ "InvarianceBehavior",
38
+ "InvarianceClaim",
39
+ "LandscapeSample",
40
+ "MetricKind",
41
+ "ObjectiveSense",
42
+ "ObjectiveSenseName",
43
+ "Reference",
44
+ "Transformation",
45
+ "YNormalization",
46
+ "__version__",
47
+ "compute",
48
+ "list_features",
49
+ ]
orivex/api.py ADDED
@@ -0,0 +1,75 @@
1
+ """Public convenience API backed by the built-in feature engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from orivex.engine import Engine
8
+ from orivex.features.distribution import FEATURES as DISTRIBUTION_FEATURES
9
+ from orivex.features.distribution import INTERMEDIATES as DISTRIBUTION_INTERMEDIATES
10
+ from orivex.features.fitness_distance import FEATURES as FITNESS_DISTANCE_FEATURES
11
+ from orivex.features.fitness_distance import INTERMEDIATES as FITNESS_DISTANCE_INTERMEDIATES
12
+ from orivex.features.information_content import FEATURES as INFORMATION_CONTENT_FEATURES
13
+ from orivex.features.information_content import INTERMEDIATES as INFORMATION_CONTENT_INTERMEDIATES
14
+ from orivex.features.meta_model import FEATURES as META_MODEL_FEATURES
15
+ from orivex.features.meta_model import INTERMEDIATES as META_MODEL_INTERMEDIATES
16
+ from orivex.features.nearest_better import FEATURES as NEAREST_BETTER_FEATURES
17
+ from orivex.features.nearest_better import INTERMEDIATES as NEAREST_BETTER_INTERMEDIATES
18
+ from orivex.normalization import YNormalization
19
+ from orivex.options import FeatureOptions
20
+ from orivex.result import ComputationResult
21
+ from orivex.sample import LandscapeSample
22
+ from orivex.specs import FeatureSpec
23
+
24
+ DEFAULT_ENGINE = Engine(
25
+ DISTRIBUTION_FEATURES
26
+ + META_MODEL_FEATURES
27
+ + INFORMATION_CONTENT_FEATURES
28
+ + NEAREST_BETTER_FEATURES
29
+ + FITNESS_DISTANCE_FEATURES,
30
+ DISTRIBUTION_INTERMEDIATES
31
+ + META_MODEL_INTERMEDIATES
32
+ + INFORMATION_CONTENT_INTERMEDIATES
33
+ + NEAREST_BETTER_INTERMEDIATES
34
+ + FITNESS_DISTANCE_INTERMEDIATES,
35
+ )
36
+
37
+
38
+ def compute(
39
+ sample: LandscapeSample,
40
+ features: str | tuple[str, ...] | list[str],
41
+ *,
42
+ rng: np.random.Generator | None = None,
43
+ workers: int = 1,
44
+ y_normalization: YNormalization = "minmax",
45
+ options: FeatureOptions | None = None,
46
+ ) -> ComputationResult:
47
+ """Compute features from canonical objectives, min-max normalized by default.
48
+
49
+ ``y_normalization`` is ``"minmax"`` (observed range) or ``None`` (raw canonical
50
+ objectives). Normalized constant objectives map to zero. The original sample is
51
+ preserved; preprocessing is recorded
52
+ separately from mathematical feature definitions in result metadata.
53
+
54
+ ``options`` is a nested mapping keyed by feature group, for example
55
+ ``{"fitness_distance": {"proportion_of_best": 0.25}}``. Omit it to use defaults.
56
+ Unknown groups and option names are rejected. Fitness-distance features always use
57
+ Euclidean distances to the best selected observation. Effective options are recorded
58
+ in ``result.metadata.options`` as an immutable snapshot.
59
+
60
+ ``workers=1`` is the predictable default. Pass ``-1`` to let supporting kernels use all
61
+ available CPUs, or a positive integer to set an upper worker count. Kernels that do not
62
+ support parallel execution ignore this setting.
63
+ """
64
+ return DEFAULT_ENGINE.compute(
65
+ sample,
66
+ features,
67
+ rng=rng,
68
+ workers=workers,
69
+ y_normalization=y_normalization,
70
+ options=options,
71
+ )
72
+
73
+
74
+ def list_features() -> tuple[FeatureSpec, ...]:
75
+ return tuple(DEFAULT_ENGINE.registry.get(name) for name in DEFAULT_ENGINE.registry.names())
orivex/capabilities.py ADDED
@@ -0,0 +1,42 @@
1
+ """Backend-specific feature implementation capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal, TypeAlias
7
+
8
+ from orivex.result import BackendName, DeviceType, FloatingDType
9
+
10
+ AutogradSupport: TypeAlias = Literal["smooth", "piecewise", "none"]
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class FeatureCapability:
15
+ """Execution properties that belong to an implementation, not its mathematics."""
16
+
17
+ feature_name: str
18
+ backend: BackendName
19
+ autograd: AutogradSupport
20
+ devices: tuple[DeviceType, ...]
21
+ dtypes: tuple[FloatingDType, ...]
22
+ notes: tuple[str, ...] = ()
23
+
24
+ def __post_init__(self) -> None:
25
+ if not self.feature_name:
26
+ raise ValueError("feature name must not be empty")
27
+ if self.backend not in ("numpy", "torch"):
28
+ raise ValueError(f"unsupported backend: {self.backend!r}")
29
+ if self.autograd not in ("smooth", "piecewise", "none"):
30
+ raise ValueError(f"unsupported autograd support: {self.autograd!r}")
31
+ if not self.devices:
32
+ raise ValueError("at least one device type is required")
33
+ if not self.dtypes:
34
+ raise ValueError("at least one floating dtype is required")
35
+ if any(device not in ("cpu", "cuda", "mps") for device in self.devices):
36
+ raise ValueError(f"unsupported device types: {self.devices!r}")
37
+ if any(dtype not in ("float32", "float64") for dtype in self.dtypes):
38
+ raise ValueError(f"unsupported floating dtypes: {self.dtypes!r}")
39
+ if len(set(self.devices)) != len(self.devices):
40
+ raise ValueError("device types must be unique")
41
+ if len(set(self.dtypes)) != len(self.dtypes):
42
+ raise ValueError("floating dtypes must be unique")
orivex/engine.py ADDED
@@ -0,0 +1,192 @@
1
+ """Execution of planned feature definitions with shared intermediate caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from types import MappingProxyType
9
+
10
+ import numpy as np
11
+
12
+ from orivex.normalization import YNormalization, normalize_objectives
13
+ from orivex.options import FeatureOptions, resolve_options
14
+ from orivex.planner import IntermediateSpec, Planner
15
+ from orivex.registry import FeatureRegistry
16
+ from orivex.result import ComputationResult, ExecutionMetadata, FeatureStatus, FeatureValue
17
+ from orivex.sample import LandscapeSample
18
+ from orivex.specs import FeatureSpec, InputRequirement
19
+
20
+ IntermediateValue = object
21
+
22
+
23
+ class FeatureUnavailable(ValueError):
24
+ """A feature is mathematically undefined for an otherwise valid sample."""
25
+
26
+
27
+ # Numerical failures that are expected for otherwise valid samples (overflow, underflow, division
28
+ # by an underflowed quantity, singular designs). These are isolated per feature as INVALID results.
29
+ # Programming errors -- TypeError, KeyError, IndexError, AttributeError, and the like -- are not
30
+ # listed and therefore still propagate so that bugs remain distinguishable from numerical failures.
31
+ EXPECTED_NUMERICAL_ERRORS: tuple[type[Exception], ...] = (
32
+ FeatureUnavailable,
33
+ ArithmeticError,
34
+ np.linalg.LinAlgError,
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class _IntermediateFailure:
40
+ """Marks an intermediate that raised an expected numerical failure.
41
+
42
+ Stored in place of a value so that features depending on it (directly or transitively) surface
43
+ the same failure as an INVALID status instead of terminating the whole request.
44
+ """
45
+
46
+ message: str
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ComputationContext:
51
+ sample: LandscapeSample
52
+ intermediates: Mapping[str, IntermediateValue]
53
+ rng: np.random.Generator | None
54
+ workers: int = 1
55
+ objective_y: np.ndarray | None = None
56
+ options: FeatureOptions = field(default_factory=resolve_options)
57
+
58
+ @property
59
+ def y(self) -> np.ndarray:
60
+ """Canonical objectives after the computation's declared preprocessing."""
61
+ return self.sample.minimization_y if self.objective_y is None else self.objective_y
62
+
63
+ def intermediate(self, name: str) -> IntermediateValue:
64
+ try:
65
+ value = self.intermediates[name]
66
+ except KeyError as error:
67
+ raise RuntimeError(f"intermediate was not planned: {name}") from error
68
+ if isinstance(value, _IntermediateFailure):
69
+ raise FeatureUnavailable(value.message)
70
+ return value
71
+
72
+
73
+ FeatureCalculator = Callable[[ComputationContext], float | int]
74
+ IntermediateCalculator = Callable[[ComputationContext], IntermediateValue]
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class FeatureDefinition:
79
+ spec: FeatureSpec
80
+ calculate: FeatureCalculator
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class IntermediateDefinition:
85
+ spec: IntermediateSpec
86
+ calculate: IntermediateCalculator
87
+
88
+
89
+ class Engine:
90
+ def __init__(
91
+ self,
92
+ features: tuple[FeatureDefinition, ...],
93
+ intermediates: tuple[IntermediateDefinition, ...],
94
+ ) -> None:
95
+ self._feature_definitions: dict[str, FeatureDefinition] = {}
96
+ for definition in features:
97
+ if definition.spec.name in self._feature_definitions:
98
+ raise ValueError(f"feature definition already registered: {definition.spec.name}")
99
+ self._feature_definitions[definition.spec.name] = definition
100
+
101
+ self._intermediate_definitions: dict[str, IntermediateDefinition] = {}
102
+ for definition in intermediates:
103
+ if definition.spec.name in self._intermediate_definitions:
104
+ raise ValueError(
105
+ f"intermediate definition already registered: {definition.spec.name}"
106
+ )
107
+ self._intermediate_definitions[definition.spec.name] = definition
108
+
109
+ self.registry = FeatureRegistry(tuple(item.spec for item in features))
110
+ self.planner = Planner(
111
+ self.registry,
112
+ tuple(item.spec for item in intermediates),
113
+ )
114
+
115
+ def compute(
116
+ self,
117
+ sample: LandscapeSample,
118
+ features: str | tuple[str, ...] | list[str],
119
+ *,
120
+ rng: np.random.Generator | None = None,
121
+ workers: int = 1,
122
+ y_normalization: YNormalization = "minmax",
123
+ options: FeatureOptions | None = None,
124
+ ) -> ComputationResult:
125
+ options = resolve_options(options)
126
+ if workers == 0 or workers < -1:
127
+ raise ValueError("workers must be -1 or a positive integer")
128
+ sample.validate_unchanged()
129
+ started = time.perf_counter()
130
+ plan = self.planner.plan(features)
131
+ if InputRequirement.RNG in plan.requirements and rng is None:
132
+ raise ValueError("the requested feature plan requires an explicit numpy Generator")
133
+
134
+ objective_y, constant = normalize_objectives(sample.minimization_y, y_normalization)
135
+
136
+ cache: dict[str, IntermediateValue] = {}
137
+ for intermediate in plan.intermediates:
138
+ context = ComputationContext(
139
+ sample,
140
+ MappingProxyType(cache),
141
+ rng,
142
+ workers,
143
+ objective_y,
144
+ options,
145
+ )
146
+ try:
147
+ cache[intermediate.name] = self._intermediate_definitions[
148
+ intermediate.name
149
+ ].calculate(context)
150
+ except EXPECTED_NUMERICAL_ERRORS as error:
151
+ cache[intermediate.name] = _IntermediateFailure(str(error))
152
+
153
+ context = ComputationContext(
154
+ sample,
155
+ MappingProxyType(cache),
156
+ rng,
157
+ workers,
158
+ objective_y,
159
+ options,
160
+ )
161
+ values: dict[str, FeatureValue] = {}
162
+ for spec in plan.features:
163
+ definition = self._feature_definitions[spec.name]
164
+ try:
165
+ value = definition.calculate(context)
166
+ if not np.isfinite(value):
167
+ raise FeatureUnavailable("definition produced a non-finite value")
168
+ values[spec.name] = FeatureValue(
169
+ value=value,
170
+ status=FeatureStatus.OK,
171
+ definition=spec.definition,
172
+ )
173
+ except EXPECTED_NUMERICAL_ERRORS as error:
174
+ values[spec.name] = FeatureValue(
175
+ value=None,
176
+ status=FeatureStatus.INVALID,
177
+ definition=spec.definition,
178
+ message=str(error),
179
+ )
180
+
181
+ metadata = ExecutionMetadata(
182
+ sample_fingerprint=sample.fingerprint,
183
+ requested_features=plan.feature_names,
184
+ computed_intermediates=plan.intermediate_names,
185
+ runtime_seconds=time.perf_counter() - started,
186
+ additional_objective_evaluations=0,
187
+ workers=workers,
188
+ y_normalization=y_normalization,
189
+ constant_objective=constant,
190
+ options=options,
191
+ )
192
+ return ComputationResult(values, metadata)
@@ -0,0 +1 @@
1
+ """Built-in feature definitions."""
@@ -0,0 +1,212 @@
1
+ """Objective-value distribution features compatible with flacco type-3 estimators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from orivex.engine import (
10
+ ComputationContext,
11
+ FeatureDefinition,
12
+ FeatureUnavailable,
13
+ IntermediateDefinition,
14
+ )
15
+ from orivex.planner import IntermediateSpec
16
+ from orivex.specs import (
17
+ CostModel,
18
+ CostTier,
19
+ FeatureSpec,
20
+ InputRequirement,
21
+ InvarianceBehavior,
22
+ InvarianceClaim,
23
+ MetricKind,
24
+ Reference,
25
+ Transformation,
26
+ )
27
+
28
+ REFERENCE = Reference(
29
+ citation="Mersmann et al. (2011), Exploratory Landscape Analysis",
30
+ doi="10.1145/2001576.2001690",
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class CenteredObjectives:
36
+ observations: int
37
+ values: np.ndarray
38
+ scale: float
39
+
40
+
41
+ def centered_objectives(context: ComputationContext) -> CenteredObjectives:
42
+ """Mean-centered objectives rescaled to unit maximum magnitude.
43
+
44
+ The type-3 skewness and kurtosis are invariant to a positive scaling of the deviations, so the
45
+ shared moments are accumulated from ``(y - mean) / max|y - mean|`` instead of the raw
46
+ deviations. The scale cancels analytically in every dependent feature, which keeps the moment
47
+ sums bounded by the observation count and therefore representable at any finite objective scale.
48
+ ``scale`` is ``0.0`` exactly for a constant objective.
49
+ """
50
+ y = context.y
51
+ deviations = y - np.mean(y)
52
+ # Constancy is decided on the objectives directly: a genuinely constant objective can still
53
+ # acquire tiny nonzero deviations through mean rounding, and rescaling would otherwise amplify
54
+ # that noise into apparently valid moments.
55
+ constant = y.size == 0 or bool(np.min(y) == np.max(y))
56
+ scale = 0.0 if constant else float(np.max(np.abs(deviations)))
57
+ values = deviations / scale if scale > 0.0 else np.zeros_like(deviations)
58
+ values.flags.writeable = False
59
+ return CenteredObjectives(observations=y.size, values=values, scale=scale)
60
+
61
+
62
+ CENTERED = IntermediateDefinition(
63
+ spec=IntermediateSpec(
64
+ name="y.centered",
65
+ dependencies=(),
66
+ requirements=frozenset({InputRequirement.Y}),
67
+ ),
68
+ calculate=centered_objectives,
69
+ )
70
+
71
+
72
+ def _centered(context: ComputationContext) -> CenteredObjectives:
73
+ value = context.intermediate("y.centered")
74
+ if not isinstance(value, CenteredObjectives):
75
+ raise TypeError("y.centered has an invalid runtime type")
76
+ return value
77
+
78
+
79
+ def sum2(context: ComputationContext) -> float:
80
+ centered = _centered(context).values
81
+ return float(centered @ centered)
82
+
83
+
84
+ def sum3(context: ComputationContext) -> float:
85
+ return float(np.sum(_centered(context).values ** 3))
86
+
87
+
88
+ def sum4(context: ComputationContext) -> float:
89
+ return float(np.sum(_centered(context).values ** 4))
90
+
91
+
92
+ SUM2 = IntermediateDefinition(
93
+ IntermediateSpec("y.sum2", ("y.centered",), frozenset()),
94
+ sum2,
95
+ )
96
+ SUM3 = IntermediateDefinition(
97
+ IntermediateSpec("y.sum3", ("y.centered",), frozenset()),
98
+ sum3,
99
+ )
100
+ SUM4 = IntermediateDefinition(
101
+ IntermediateSpec("y.sum4", ("y.centered",), frozenset()),
102
+ sum4,
103
+ )
104
+
105
+
106
+ def _sum(context: ComputationContext, name: str) -> float:
107
+ value = context.intermediate(name)
108
+ if not isinstance(value, float):
109
+ raise TypeError(f"{name} has an invalid runtime type")
110
+ return value
111
+
112
+
113
+ def skewness_type3(context: ComputationContext) -> float:
114
+ centered = _centered(context)
115
+ second = _sum(context, "y.sum2")
116
+ third = _sum(context, "y.sum3")
117
+ if centered.observations < 3:
118
+ raise FeatureUnavailable("type-3 skewness requires at least 3 observations")
119
+ if centered.scale == 0.0:
120
+ raise FeatureUnavailable("skewness is undefined for constant objective values")
121
+ n = centered.observations
122
+ type1 = np.sqrt(n) * third / second**1.5
123
+ return float(type1 * ((n - 1) / n) ** 1.5)
124
+
125
+
126
+ def kurtosis_type3(context: ComputationContext) -> float:
127
+ centered = _centered(context)
128
+ second = _sum(context, "y.sum2")
129
+ fourth = _sum(context, "y.sum4")
130
+ if centered.observations < 4:
131
+ raise FeatureUnavailable("type-3 kurtosis requires at least 4 observations")
132
+ if centered.scale == 0.0:
133
+ raise FeatureUnavailable("kurtosis is undefined for constant objective values")
134
+ n = centered.observations
135
+ ratio = n * fourth / second**2
136
+ return float(ratio * (1.0 - 1.0 / n) ** 2 - 3.0)
137
+
138
+
139
+ COMMON_INVARIANCES = (
140
+ InvarianceClaim(
141
+ Transformation.ROW_PERMUTATION,
142
+ InvarianceBehavior.INVARIANT,
143
+ conditions="paired finite observations",
144
+ ),
145
+ InvarianceClaim(
146
+ Transformation.Y_TRANSLATION,
147
+ InvarianceBehavior.INVARIANT,
148
+ conditions="finite y with non-zero variance",
149
+ ),
150
+ InvarianceClaim(
151
+ Transformation.Y_POSITIVE_SCALING,
152
+ InvarianceBehavior.INVARIANT,
153
+ conditions="finite positive scale and non-zero y variance",
154
+ ),
155
+ )
156
+
157
+
158
+ SKEWNESS = FeatureDefinition(
159
+ spec=FeatureSpec(
160
+ name="ela_distr.skewness",
161
+ group="ela_distr",
162
+ kind=MetricKind.LANDSCAPE,
163
+ definition="flacco-type3-v1",
164
+ summary="Type-3 sample skewness of objective observations under minimization convention.",
165
+ requirements=frozenset({InputRequirement.Y}),
166
+ intermediates=("y.sum2", "y.sum3"),
167
+ cost=CostModel(CostTier.SAMPLE_ONLY, cpu="O(n)", memory="O(n) shared"),
168
+ deterministic=True,
169
+ invariances=(
170
+ *COMMON_INVARIANCES,
171
+ InvarianceClaim(
172
+ Transformation.OBJECTIVE_SENSE_REVERSAL,
173
+ InvarianceBehavior.INVARIANT,
174
+ conditions="negate y and reverse the declared objective sense together",
175
+ ),
176
+ ),
177
+ references=(REFERENCE,),
178
+ legacy_names=("ela_distr.skewness",),
179
+ minimum_observations=3,
180
+ ),
181
+ calculate=skewness_type3,
182
+ )
183
+
184
+
185
+ KURTOSIS = FeatureDefinition(
186
+ spec=FeatureSpec(
187
+ name="ela_distr.kurtosis",
188
+ group="ela_distr",
189
+ kind=MetricKind.LANDSCAPE,
190
+ definition="flacco-type3-v1",
191
+ summary="Type-3 excess sample kurtosis of objective observations.",
192
+ requirements=frozenset({InputRequirement.Y}),
193
+ intermediates=("y.sum2", "y.sum4"),
194
+ cost=CostModel(CostTier.SAMPLE_ONLY, cpu="O(n)", memory="O(n) shared"),
195
+ deterministic=True,
196
+ invariances=(
197
+ *COMMON_INVARIANCES,
198
+ InvarianceClaim(
199
+ Transformation.OBJECTIVE_SENSE_REVERSAL,
200
+ InvarianceBehavior.INVARIANT,
201
+ conditions="negate y and reverse the declared objective sense together",
202
+ ),
203
+ ),
204
+ references=(REFERENCE,),
205
+ legacy_names=("ela_distr.kurtosis",),
206
+ minimum_observations=4,
207
+ ),
208
+ calculate=kurtosis_type3,
209
+ )
210
+
211
+ FEATURES = (SKEWNESS, KURTOSIS)
212
+ INTERMEDIATES = (CENTERED, SUM2, SUM3, SUM4)