alf_core 0.1.0a0__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.
- alf_core/__init__.py +58 -0
- alf_core/dataclasses/__init__.py +26 -0
- alf_core/dataclasses/candidate.py +225 -0
- alf_core/dataclasses/labelled_candidates.py +234 -0
- alf_core/dataclasses/predictions.py +119 -0
- alf_core/dataclasses/results.py +85 -0
- alf_core/dataclasses/round_metrics.py +42 -0
- alf_core/dataclasses/state.py +72 -0
- alf_core/dataclasses/surrogate_epoch_metrics.py +55 -0
- alf_core/dataset/base_dataset.py +381 -0
- alf_core/dataset/splitting_utils.py +268 -0
- alf_core/model/base_model.py +154 -0
- alf_core/model/normaliser.py +342 -0
- alf_core/optimizer/acquisition_function.py +39 -0
- alf_core/optimizer/optimizer.py +126 -0
- alf_core/optimizer/search.py +199 -0
- alf_core/oracle/oracle.py +74 -0
- alf_core/surrogate/surrogate.py +95 -0
- alf_core/tasks/base_task.py +119 -0
- alf_core/tasks/design_task.py +122 -0
- alf_core/tasks/supervised_task.py +74 -0
- alf_core/tasks/zeroshot_task.py +67 -0
- alf_core/utils/enums.py +29 -0
- alf_core/utils/metrics/__init__.py +73 -0
- alf_core/utils/metrics/acquisition_batch.py +215 -0
- alf_core/utils/metrics/aggregate.py +182 -0
- alf_core/utils/metrics/base.py +176 -0
- alf_core/utils/metrics/calibration.py +93 -0
- alf_core/utils/metrics/classification.py +185 -0
- alf_core/utils/metrics/regression.py +704 -0
- alf_core/utils/state_logger.py +311 -0
- alf_core-0.1.0a0.dist-info/METADATA +119 -0
- alf_core-0.1.0a0.dist-info/RECORD +35 -0
- alf_core-0.1.0a0.dist-info/WHEEL +5 -0
- alf_core-0.1.0a0.dist-info/top_level.txt +1 -0
alf_core/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright 2026 InstaDeep Ltd. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# This file makes alf_core a Python package
|
|
16
|
+
import importlib.metadata
|
|
17
|
+
|
|
18
|
+
from alf_core.dataclasses import (
|
|
19
|
+
Candidate,
|
|
20
|
+
LabelledCandidates,
|
|
21
|
+
Modality,
|
|
22
|
+
Predictions,
|
|
23
|
+
Results,
|
|
24
|
+
State,
|
|
25
|
+
SurrogateEpochMetrics,
|
|
26
|
+
)
|
|
27
|
+
from alf_core.dataset.base_dataset import BaseDataset, BaseDatasetConfig
|
|
28
|
+
from alf_core.model.base_model import BaseModel, BaseTrainConfig
|
|
29
|
+
from alf_core.model.normaliser import (
|
|
30
|
+
InputNormaliser,
|
|
31
|
+
InputStandardiser,
|
|
32
|
+
OutputStandardiser,
|
|
33
|
+
make_input_transform,
|
|
34
|
+
)
|
|
35
|
+
from alf_core.optimizer.acquisition_function import AcquisitionFunction
|
|
36
|
+
from alf_core.optimizer.optimizer import Optimizer
|
|
37
|
+
from alf_core.optimizer.search import (
|
|
38
|
+
BaseSearch,
|
|
39
|
+
DatasetSearch,
|
|
40
|
+
GeneratorSearch,
|
|
41
|
+
ModelProtocolSearch,
|
|
42
|
+
ProtocolSearch,
|
|
43
|
+
SearchProtocol,
|
|
44
|
+
)
|
|
45
|
+
from alf_core.oracle.oracle import Oracle
|
|
46
|
+
from alf_core.surrogate.surrogate import Surrogate
|
|
47
|
+
from alf_core.tasks.base_task import BaseTask
|
|
48
|
+
from alf_core.tasks.design_task import DesignTask
|
|
49
|
+
from alf_core.tasks.supervised_task import SupervisedTask
|
|
50
|
+
from alf_core.tasks.zeroshot_task import ZeroShotTask
|
|
51
|
+
from alf_core.utils.enums import ProblemType
|
|
52
|
+
from alf_core.utils.state_logger import (
|
|
53
|
+
FileStateLogger,
|
|
54
|
+
StateLogger,
|
|
55
|
+
TerminalStateLogger,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__version__ = importlib.metadata.version("alf-core")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright 2026 InstaDeep Ltd. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# ruff: noqa: E402
|
|
16
|
+
import beartype.claw
|
|
17
|
+
|
|
18
|
+
beartype.claw.beartype_this_package()
|
|
19
|
+
|
|
20
|
+
from alf_core.dataclasses.candidate import Candidate, Modality
|
|
21
|
+
from alf_core.dataclasses.labelled_candidates import LabelledCandidates
|
|
22
|
+
from alf_core.dataclasses.predictions import Predictions
|
|
23
|
+
from alf_core.dataclasses.results import Results
|
|
24
|
+
from alf_core.dataclasses.round_metrics import RoundMetrics
|
|
25
|
+
from alf_core.dataclasses.state import State
|
|
26
|
+
from alf_core.dataclasses.surrogate_epoch_metrics import SurrogateEpochMetrics
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Copyright 2026 InstaDeep Ltd. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import TYPE_CHECKING, Any, TypeAlias, Union
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import torch
|
|
24
|
+
|
|
25
|
+
DataFrameCompatible: TypeAlias = Union[
|
|
26
|
+
str, int, float, bool, dict, list, tuple, np.ndarray, torch.Tensor
|
|
27
|
+
]
|
|
28
|
+
else:
|
|
29
|
+
DataFrameCompatible: TypeAlias = Union[
|
|
30
|
+
str, int, float, bool, dict, list, tuple, np.ndarray, Any
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import torch
|
|
35
|
+
|
|
36
|
+
HAS_TORCH = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
HAS_TORCH = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Modality(Enum):
|
|
42
|
+
"""The *kind* of candidate — used to match datasets with compatible models and metrics.
|
|
43
|
+
|
|
44
|
+
It is the data's domain where one exists, not how the data is stored. This is why a
|
|
45
|
+
protein sequence and a SMILES string are distinct modalities (``SEQUENCE`` vs
|
|
46
|
+
``MOLECULE``) even though both are stored as ``str``: they pair with different models
|
|
47
|
+
and metrics. Storage type is never encoded here — it is inferred from ``type(data)``
|
|
48
|
+
(see :meth:`Candidate.to_serializable`).
|
|
49
|
+
|
|
50
|
+
Members:
|
|
51
|
+
SEQUENCE: Biological sequences (protein / nucleotide), as strings.
|
|
52
|
+
MOLECULE: Small molecules, as SMILES strings.
|
|
53
|
+
TABULAR: Domain-agnostic numeric feature vectors (arrays, tensors, scalars, dicts).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
SEQUENCE = "sequence"
|
|
57
|
+
MOLECULE = "molecule"
|
|
58
|
+
TABULAR = "tabular"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(eq=False, unsafe_hash=False)
|
|
62
|
+
class Candidate:
|
|
63
|
+
"""A candidate is a data point with a modality and features.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
data: The raw data of the candidate (e.g., a sequence string, a SMILES string,
|
|
67
|
+
a feature vector).
|
|
68
|
+
modality: The data's domain (see :class:`Modality`) — what it represents, e.g.
|
|
69
|
+
``"sequence"`` or ``"molecule"``. Not its storage type.
|
|
70
|
+
features: Optional dictionary of precomputed features for the candidate.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
data: Any
|
|
74
|
+
modality: Modality | str
|
|
75
|
+
features: dict | None = None
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
"""Check and convert modality to Modality enum if necessary and
|
|
79
|
+
initialize features to empty dict if None.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: If the modality is not a valid Modality enum.
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(self.modality, Modality):
|
|
85
|
+
try:
|
|
86
|
+
self.modality = Modality(self.modality)
|
|
87
|
+
except ValueError:
|
|
88
|
+
raise ValueError(f"Invalid modality: {self.modality}")
|
|
89
|
+
|
|
90
|
+
if self.features is None:
|
|
91
|
+
self.features = {}
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
"""Return a string representation of the candidate.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A string representation showing the candidate's data, modality, and features.
|
|
98
|
+
"""
|
|
99
|
+
return f"Candidate(data={self.data}, modality={self.modality}, features={self.features})"
|
|
100
|
+
|
|
101
|
+
def _safe_equal(self, a: Any, b: Any) -> bool:
|
|
102
|
+
"""Compare two values, handling numpy arrays and nested structures.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
a: First value to compare.
|
|
106
|
+
b: Second value to compare.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if the values are equal, False otherwise.
|
|
110
|
+
"""
|
|
111
|
+
# Handle None cases
|
|
112
|
+
if a is None and b is None:
|
|
113
|
+
return True
|
|
114
|
+
if a is None or b is None:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Handle numpy arrays
|
|
118
|
+
if isinstance(a, np.ndarray) and isinstance(b, np.ndarray):
|
|
119
|
+
return np.array_equal(a, b, equal_nan=True)
|
|
120
|
+
|
|
121
|
+
# Handle torch tensors
|
|
122
|
+
if HAS_TORCH and isinstance(a, torch.Tensor) and isinstance(b, torch.Tensor):
|
|
123
|
+
return torch.equal(a, b)
|
|
124
|
+
|
|
125
|
+
# Handle dict (for features)
|
|
126
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
127
|
+
if a.keys() != b.keys():
|
|
128
|
+
return False
|
|
129
|
+
return all(self._safe_equal(a[k], b[k]) for k in a.keys())
|
|
130
|
+
|
|
131
|
+
# Handle lists/tuples (for nested data)
|
|
132
|
+
if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
|
|
133
|
+
if len(a) != len(b):
|
|
134
|
+
return False
|
|
135
|
+
return all(self._safe_equal(x, y) for x, y in zip(a, b))
|
|
136
|
+
|
|
137
|
+
# Default comparison
|
|
138
|
+
# Note, if comparison fails, an error will be thrown
|
|
139
|
+
result = a == b
|
|
140
|
+
# Handle case where comparison returns array-like object
|
|
141
|
+
# Convert to boolean if possible
|
|
142
|
+
if hasattr(result, "__len__") and len(result) == 1:
|
|
143
|
+
return bool(result[0])
|
|
144
|
+
elif hasattr(result, "item"): # For single-element tensors/arrays
|
|
145
|
+
return bool(result.item())
|
|
146
|
+
return bool(result)
|
|
147
|
+
|
|
148
|
+
def __eq__(self, other: object) -> bool:
|
|
149
|
+
"""Compare two Candidate objects for equality.
|
|
150
|
+
|
|
151
|
+
Handles numpy arrays in data and features fields correctly.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
other: The object to compare with.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
True if the candidates are equal, False otherwise.
|
|
158
|
+
"""
|
|
159
|
+
if not isinstance(other, Candidate):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
self._safe_equal(self.data, other.data)
|
|
164
|
+
and self.modality == other.modality
|
|
165
|
+
and self._safe_equal(self.features, other.features)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
__hash__ = None # type: ignore[assignment]
|
|
169
|
+
|
|
170
|
+
def to_serializable(self) -> DataFrameCompatible | None:
|
|
171
|
+
"""Convert candidate data to a format suitable for pandas DataFrame storage.
|
|
172
|
+
|
|
173
|
+
Dispatch is based on the **type** of ``data``, not on :attr:`modality`. Modality
|
|
174
|
+
describes the data's domain (see :class:`Modality`); how it is stored — and
|
|
175
|
+
therefore how it is serialised — is determined by its Python type:
|
|
176
|
+
|
|
177
|
+
- ``str`` (sequences, SMILES, JSON-encoded payloads): returned unchanged.
|
|
178
|
+
- ``torch.Tensor``: converted to a numpy array for compact storage.
|
|
179
|
+
- numpy array, scalar (Python ``int``/``float``/``bool`` or a numpy scalar such
|
|
180
|
+
as ``np.int64``), ``dict``, ``list``, ``tuple``, pandas ``Series``: returned
|
|
181
|
+
unchanged.
|
|
182
|
+
- ``None``: returned as ``None``.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
DataFrameCompatible: The candidate data in a DataFrame-compatible format.
|
|
186
|
+
Common types include str, dict, np.ndarray, pd.Series, or torch.Tensor.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
TypeError: If the data type is not DataFrame-compatible.
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
>>> # A sequence or SMILES string is stored as-is
|
|
193
|
+
>>> Candidate(data="ACDEFG", modality=Modality.SEQUENCE).to_serializable()
|
|
194
|
+
'ACDEFG'
|
|
195
|
+
|
|
196
|
+
>>> Candidate(data="CC(=O)O", modality=Modality.MOLECULE).to_serializable()
|
|
197
|
+
'CC(=O)O'
|
|
198
|
+
|
|
199
|
+
>>> # A feature dict is stored as-is
|
|
200
|
+
>>> c = Candidate(data={"age": 32, "height": 178}, modality=Modality.TABULAR)
|
|
201
|
+
>>> c.to_serializable()
|
|
202
|
+
{'age': 32, 'height': 178}
|
|
203
|
+
"""
|
|
204
|
+
data = self.data
|
|
205
|
+
if data is None:
|
|
206
|
+
return None
|
|
207
|
+
# Strings (sequences, SMILES, JSON-encoded payloads) are stored as-is.
|
|
208
|
+
if isinstance(data, str):
|
|
209
|
+
return data
|
|
210
|
+
# Torch tensors are converted to numpy arrays for compact storage.
|
|
211
|
+
if HAS_TORCH and isinstance(data, torch.Tensor):
|
|
212
|
+
return data.cpu().numpy()
|
|
213
|
+
# numpy arrays, scalars (incl. numpy scalars like np.int64), and standard
|
|
214
|
+
# containers are stored as-is.
|
|
215
|
+
if isinstance(data, (int, float, bool, np.generic, dict, np.ndarray, list, tuple)):
|
|
216
|
+
return data
|
|
217
|
+
# pandas Series (checked without importing pandas).
|
|
218
|
+
if data.__class__.__name__ == "Series":
|
|
219
|
+
return data
|
|
220
|
+
|
|
221
|
+
raise TypeError(
|
|
222
|
+
f"Cannot serialise candidate data of type {type(data).__name__}. Supported "
|
|
223
|
+
f"types: str, int, float, bool, dict, list, tuple, numpy.ndarray, "
|
|
224
|
+
f"pandas.Series, or torch.Tensor."
|
|
225
|
+
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Copyright 2026 InstaDeep Ltd. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Union
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from alf_core.dataclasses.candidate import Candidate
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(eq=False, unsafe_hash=False)
|
|
26
|
+
class LabelledCandidates:
|
|
27
|
+
"""A collection of candidates paired with their labels.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
candidates: A list of Candidate objects.
|
|
31
|
+
labels: A numpy array of labels corresponding to each candidate.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
candidates: list[Candidate]
|
|
35
|
+
labels: np.ndarray
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
"""Validate that candidates and labels have the same length.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
AssertionError: If the length of candidates and labels don't match.
|
|
42
|
+
"""
|
|
43
|
+
assert len(self.candidates) == len(self.labels), (
|
|
44
|
+
f"Candidates and labels must have the same length, "
|
|
45
|
+
f"got {len(self.candidates)} candidates and {len(self.labels)} labels"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __len__(self) -> int:
|
|
49
|
+
"""Return the number of candidates in the collection.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The number of candidates (and labels) in the collection.
|
|
53
|
+
"""
|
|
54
|
+
return len(self.candidates)
|
|
55
|
+
|
|
56
|
+
def __getitem__(
|
|
57
|
+
self, index: Union[int, slice, np.ndarray]
|
|
58
|
+
) -> tuple[list[Candidate], np.ndarray]:
|
|
59
|
+
"""Make LabelledCandidates subscriptable.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
index: Integer index or slice to select candidates and labels.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The candidates and labels at the specified index or slice.
|
|
66
|
+
"""
|
|
67
|
+
if isinstance(index, int):
|
|
68
|
+
return ([self.candidates[index]], np.array([self.labels[index]]))
|
|
69
|
+
elif isinstance(index, np.ndarray):
|
|
70
|
+
return ([self.candidates[i] for i in index], self.labels[index])
|
|
71
|
+
else:
|
|
72
|
+
return (self.candidates[index], self.labels[index])
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def data(self) -> list[Any]:
|
|
76
|
+
"""Return the raw data of each candidate.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A list containing the raw data (e.g. a sequence string, a SMILES string,
|
|
80
|
+
a feature vector) of each candidate in the collection.
|
|
81
|
+
"""
|
|
82
|
+
return [cand.data for cand in self.candidates]
|
|
83
|
+
|
|
84
|
+
def append(
|
|
85
|
+
self,
|
|
86
|
+
candidates: Union[list[Candidate], "LabelledCandidates"],
|
|
87
|
+
labels: np.ndarray | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Append candidates and labels to this collection.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
candidates: Either a list of Candidate objects or another LabelledCandidates
|
|
93
|
+
object. If a list is provided, labels must also be provided.
|
|
94
|
+
labels: Optional numpy array of labels. Required if candidates is a list,
|
|
95
|
+
ignored if candidates is a LabelledCandidates object.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
AssertionError: If candidates is a list and labels is None, or if the
|
|
99
|
+
length of candidates and labels don't match.
|
|
100
|
+
"""
|
|
101
|
+
if isinstance(candidates, LabelledCandidates):
|
|
102
|
+
self.candidates.extend(candidates.candidates)
|
|
103
|
+
self.labels = np.concatenate((self.labels, candidates.labels), axis=0)
|
|
104
|
+
else:
|
|
105
|
+
assert labels is not None, (
|
|
106
|
+
"Labels must be provided when appending a list of Candidates "
|
|
107
|
+
"(pass a LabelledCandidates instead to append "
|
|
108
|
+
"without providing labels separately)"
|
|
109
|
+
)
|
|
110
|
+
assert len(candidates) == len(labels), (
|
|
111
|
+
f"Candidates and labels must have the same length, "
|
|
112
|
+
f"got {len(candidates)} candidates and {len(labels)} labels"
|
|
113
|
+
)
|
|
114
|
+
self.candidates.extend(candidates)
|
|
115
|
+
self.labels = np.concatenate((self.labels, labels), axis=0)
|
|
116
|
+
|
|
117
|
+
def shuffle(self, seed: int) -> "LabelledCandidates":
|
|
118
|
+
"""Create a new LabelledCandidates object with shuffled candidates and labels.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
seed: Random seed for reproducibility of the shuffle.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A new LabelledCandidates object with the same candidates and labels,
|
|
125
|
+
but in a randomly shuffled order.
|
|
126
|
+
"""
|
|
127
|
+
shuffled_indices = np.random.RandomState(seed).permutation(len(self.candidates))
|
|
128
|
+
return LabelledCandidates(
|
|
129
|
+
candidates=[self.candidates[i] for i in shuffled_indices],
|
|
130
|
+
labels=self.labels[shuffled_indices],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def sort(self, ascending: bool = True) -> "LabelledCandidates":
|
|
134
|
+
"""Sort the candidates and labels by label values.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
ascending: If True, sort in ascending order (lowest to highest).
|
|
138
|
+
If False, sort in descending order (highest to lowest).
|
|
139
|
+
Defaults to True.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A new LabelledCandidates object with candidates and labels sorted
|
|
143
|
+
by label values.
|
|
144
|
+
"""
|
|
145
|
+
sorted_indices = np.argsort(self.labels)
|
|
146
|
+
if not ascending:
|
|
147
|
+
sorted_indices = sorted_indices[::-1]
|
|
148
|
+
return LabelledCandidates(
|
|
149
|
+
candidates=[self.candidates[i] for i in sorted_indices],
|
|
150
|
+
labels=self.labels[sorted_indices],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def remove(self, candidates: Union[list[Candidate], "LabelledCandidates"]) -> None:
|
|
154
|
+
"""Remove specified candidates and their corresponding labels from this collection.
|
|
155
|
+
|
|
156
|
+
Uses identity-based comparison (same object instance) for removal.
|
|
157
|
+
Candidates not present in the collection are silently ignored.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
candidates: Either a list of Candidate objects or a LabelledCandidates
|
|
161
|
+
object containing the candidates to remove.
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(candidates, LabelledCandidates):
|
|
164
|
+
candidates = candidates.candidates
|
|
165
|
+
|
|
166
|
+
# Build set of object IDs to remove for O(1) lookup
|
|
167
|
+
ids_to_remove = {id(c) for c in candidates}
|
|
168
|
+
|
|
169
|
+
# Find indices to keep (single pass)
|
|
170
|
+
indices_to_keep = [i for i, c in enumerate(self.candidates) if id(c) not in ids_to_remove]
|
|
171
|
+
|
|
172
|
+
# Update candidates and labels
|
|
173
|
+
self.candidates = [self.candidates[i] for i in indices_to_keep]
|
|
174
|
+
self.labels = self.labels[indices_to_keep]
|
|
175
|
+
|
|
176
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
177
|
+
"""Convert the labelled candidates to a pandas DataFrame.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
A DataFrame with columns:
|
|
181
|
+
- "data": The formatted data of each candidate
|
|
182
|
+
- "label": The label value for each candidate
|
|
183
|
+
- Additional columns for any features present in the candidates
|
|
184
|
+
"""
|
|
185
|
+
rows = [
|
|
186
|
+
{"data": cand.to_serializable(), "label": label}
|
|
187
|
+
| (cand.features if isinstance(cand.features, dict) else {})
|
|
188
|
+
for cand, label in zip(self.candidates, self.labels)
|
|
189
|
+
]
|
|
190
|
+
return pd.DataFrame.from_records(rows)
|
|
191
|
+
|
|
192
|
+
def get_top_k(self, k: int) -> "LabelledCandidates":
|
|
193
|
+
"""Return the top k candidates based on their label values.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
k: Number of top candidates to select.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
New LabelledCandidates object containing the top k candidates sorted
|
|
200
|
+
by label values (highest first).
|
|
201
|
+
"""
|
|
202
|
+
top_k_indices = self.labels.argsort()[::-1][:k]
|
|
203
|
+
top_k_candidates = [self.candidates[i] for i in top_k_indices]
|
|
204
|
+
top_k_labels = self.labels[top_k_indices]
|
|
205
|
+
return LabelledCandidates(candidates=top_k_candidates, labels=top_k_labels)
|
|
206
|
+
|
|
207
|
+
def __iter__(self):
|
|
208
|
+
"""Iterate over candidates and labels, yielding (candidate, label) tuples.
|
|
209
|
+
|
|
210
|
+
Yields:
|
|
211
|
+
Tuple of (Candidate, float): A candidate and its corresponding label
|
|
212
|
+
"""
|
|
213
|
+
for candidate, label in zip(self.candidates, self.labels):
|
|
214
|
+
yield candidate, label
|
|
215
|
+
|
|
216
|
+
def __eq__(self, other: object) -> bool:
|
|
217
|
+
"""Compare two LabelledCandidates objects for equality.
|
|
218
|
+
|
|
219
|
+
Handles numpy array labels correctly.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
other: The object to compare with.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if the labeled candidates are equal, False otherwise.
|
|
226
|
+
"""
|
|
227
|
+
if not isinstance(other, LabelledCandidates):
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
return self.candidates == other.candidates and np.array_equal(
|
|
231
|
+
self.labels, other.labels, equal_nan=True
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
__hash__ = None # type: ignore[assignment]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Copyright 2026 InstaDeep Ltd. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from alf_core.dataclasses.candidate import Candidate
|
|
23
|
+
from alf_core.utils.enums import ProblemType
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("alf-core")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Predictions:
|
|
30
|
+
"""A data class for storing and managing predictions from a model.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
means: A numpy array of mean predictions across all candidates.
|
|
34
|
+
variances: An optional numpy array of prediction variances, representing
|
|
35
|
+
the model's uncertainty for each prediction.
|
|
36
|
+
empirical_dist: An optional 2D numpy array containing predictions
|
|
37
|
+
from individual models in an ensemble. Typically has the shape
|
|
38
|
+
(num_candidates, num_ensemble_models).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
means: np.ndarray
|
|
42
|
+
variances: np.ndarray | None = None
|
|
43
|
+
empirical_dist: np.ndarray | None = None
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
"""Validate prediction arrays have consistent lengths.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
AssertionError: If means is empty, or if variances or empirical_dist
|
|
50
|
+
don't match the length of means.
|
|
51
|
+
"""
|
|
52
|
+
assert len(self.means) > 0, (
|
|
53
|
+
"Means must have at least one prediction — expected shape (num_candidates,)"
|
|
54
|
+
)
|
|
55
|
+
if self.variances is not None:
|
|
56
|
+
assert len(self.variances) == len(self.means), (
|
|
57
|
+
"Variances must have the same length as means (num_candidates,), "
|
|
58
|
+
f"got {len(self.variances)} variances and {len(self.means)} means"
|
|
59
|
+
)
|
|
60
|
+
if self.empirical_dist is not None:
|
|
61
|
+
assert len(self.empirical_dist) == len(self.means), (
|
|
62
|
+
"Empirical_dist must have the same length as means - "
|
|
63
|
+
"shape (num_candidates, num_ensemble_models), "
|
|
64
|
+
f"but its first dimension ({len(self.empirical_dist)}) does not match "
|
|
65
|
+
f"len(means) ({len(self.means)})"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
"""Return the number of predictions.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The number of predictions (length of the means array).
|
|
73
|
+
"""
|
|
74
|
+
return len(self.means)
|
|
75
|
+
|
|
76
|
+
def to_dataframe(
|
|
77
|
+
self,
|
|
78
|
+
candidates: list[Candidate],
|
|
79
|
+
targets: np.ndarray,
|
|
80
|
+
problem_type: ProblemType,
|
|
81
|
+
) -> pd.DataFrame:
|
|
82
|
+
"""Convert predictions to a DataFrame.
|
|
83
|
+
|
|
84
|
+
Creates a DataFrame with predictions, targets, and optionally variances
|
|
85
|
+
and ensemble predictions.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
candidates: List of Candidate objects corresponding to the predictions.
|
|
89
|
+
targets: Ground truth target values corresponding to each candidate.
|
|
90
|
+
problem_type: ProblemType to determine the predictions type.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A DataFrame with predictions, targets, and optionally variances
|
|
94
|
+
and ensemble predictions.
|
|
95
|
+
"""
|
|
96
|
+
predictions_list = []
|
|
97
|
+
is_classification = problem_type in [ProblemType.BINARY, ProblemType.MULTICLASS]
|
|
98
|
+
|
|
99
|
+
for i in range(len(self.means)):
|
|
100
|
+
record_i: dict[str, str | float | np.floating | list[float] | np.ndarray] = {
|
|
101
|
+
"data": candidates[i].data,
|
|
102
|
+
"targets": targets[i],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if is_classification:
|
|
106
|
+
for cls_idx in range(self.means.shape[1]):
|
|
107
|
+
record_i[f"prob_class_{cls_idx}"] = self.means[i, cls_idx]
|
|
108
|
+
else:
|
|
109
|
+
record_i["mean"] = self.means[i]
|
|
110
|
+
record_i["variance"] = self.variances[i] if self.variances is not None else 0
|
|
111
|
+
|
|
112
|
+
if self.empirical_dist is not None:
|
|
113
|
+
for j in range(self.empirical_dist.shape[1]):
|
|
114
|
+
record_i[f"ensemble_pred_{j}"] = self.empirical_dist[i, j]
|
|
115
|
+
|
|
116
|
+
predictions_list.append(record_i)
|
|
117
|
+
|
|
118
|
+
df = pd.DataFrame.from_records(predictions_list)
|
|
119
|
+
return df
|