File size: 16,029 Bytes
77dc6ef | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 | """
Scaffold optimisation
=====================
Searches the formulation space for the composition and printing conditions that
maximise WSSQ, using the deployed classifiers as the objective.
Extracted from the Streamlit application deliberately. In the previous version
the objective function lived inside the interface, which meant it could not be
run headlessly, could not be tested, and could not produce a result the
manuscript was able to report. Here the interface calls this module rather than
containing it.
How a classifier becomes a continuous objective
-----------------------------------------------
Printability and Cell Response are ordinal classes, so a classifier returns a
probability vector rather than a number, and an optimiser handed the arg-max
would be climbing a step function with four or five levels. Instead each model's
full probability vector is collapsed to its EXPECTED CLASS VALUE,
E[class] = sum_k P(class = k) * label_k
which is continuous, respects the ordinal spacing of the labels, and carries the
model's uncertainty: a formulation the model is unsure about lands between
classes rather than being rounded confidently to one. Those two expected values
are then passed to WSSQ.
What is searched, and what is not
---------------------------------
Biomaterial concentrations, cell density and the printing parameters are
searched within user-supplied ranges. The CELL LINE IS NOT: it is chosen by the
user and held fixed for the run. This matters more than it looks. WSSQ falls
back to printability alone when Cell Response is 1, and an acellular
formulation therefore scores on printability only - so an optimiser allowed to
vary the cell line could raise its objective simply by removing the cells.
Fixing the line makes the acellular case a mode the user selects, not a
degenerate optimum the search can drift into.
Extrapolation is reported, not prevented
----------------------------------------
Nothing stops a user setting a range beyond anything in the corpus, and nothing
here silently clips it. `distance_report` instead states how far the returned
optimum sits outside the observed range of each variable and how close it is to
the nearest real formulation, so a candidate that the models are extrapolating
to is visible as such rather than presented with the same confidence as one
inside the training distribution.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from mlate import config as cfg
from mlate import wssq as wssq_mod
# Optuna's own logging is noisy inside a UI and uninformative in a script.
try: # pragma: no cover - import guard only
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
except Exception: # pragma: no cover
optuna = None
@dataclass
class Variable:
"""One searchable input, with the range and grid the user specified."""
name: str
low: float
high: float
step: float | None = None
def suggest(self, trial):
if self.low >= self.high:
return float(self.low)
if self.step:
return float(trial.suggest_float(self.name, self.low, self.high,
step=self.step))
return float(trial.suggest_float(self.name, self.low, self.high))
@dataclass
class SearchSpace:
"""Everything the optimiser may vary, plus the cell line it may not."""
cell_line: str
biomaterials: list[Variable] = field(default_factory=list)
printing: list[Variable] = field(default_factory=list)
cell_density: Variable | None = None
@property
def is_acellular(self) -> bool:
return self.cell_line == cfg.ACELLULAR_TOKEN
def class_distribution(estimator, X: np.ndarray, classes: np.ndarray
) -> tuple[np.ndarray, list[dict[float, float]]]:
"""
Expected class values and the distributions they were taken over.
Both come from a single `predict_proba` over the whole batch, because the
two are wanted together and a second call would double the cost of a
search. They carry different information and both are used. An expected
value of 2.5 is what a confident prediction split evenly between classes 2
and 3 produces, and also what a diffuse prediction over 1 and 4 produces;
the optimiser needs only the number, while the protocol-generation prompt
is given the distribution so that it can state which case it describes.
Falls back to the predicted label when a model exposes no probabilities;
five of the conventional classifiers do not, and for those the objective is
a step function rather than a smooth one and the distributions are empty.
"""
labels = np.asarray(classes, dtype=float)
if not hasattr(estimator, "predict_proba"):
idx = np.asarray(estimator.predict(X)).astype(int)
return labels[idx], [{} for _ in idx]
proba = np.asarray(estimator.predict_proba(X), dtype=float)
return proba @ labels, [{float(l): float(pr) for l, pr in zip(labels, row)}
for row in proba]
def expected_class_value(estimator, X: np.ndarray,
classes: np.ndarray) -> np.ndarray:
"""
Probability-weighted mean of the class labels.
Falls back to the predicted label when a model exposes no probabilities;
five of the conventional classifiers do not, and for those the objective is
a step function rather than a smooth one. The application ranks models by
benchmarked performance and the probability-capable models occupy the top of
both leaderboards, so this path is a safeguard rather than the normal case.
"""
labels = np.asarray(classes, dtype=float)
if hasattr(estimator, "predict_proba"):
return np.asarray(estimator.predict_proba(X), dtype=float) @ labels
idx = np.asarray(estimator.predict(X)).astype(int)
return labels[idx]
# Candidates scored per pass through the models. Thirty-two because the cost
# of an in-context model is the pass over its context and is flat in the batch
# size up to at least sixty-four, while a larger batch buys the sampler fewer
# updates for no further saving.
DEFAULT_BATCH_SIZE = 32
def _as_scorer(model):
"""
Accept either a pickled classifier bundle or a `mlate.serving.Servable`.
The three model families no longer share a storage format - a deep network
is a state dictionary and a foundation model is a stored context - so the
application passes the uniform wrapper from `mlate.serving`. A plain bundle
is still accepted, because the analysis stages load their own artefacts
directly and have no reason to go through the serving layer.
"""
if isinstance(model, dict):
return {"estimator": model["estimator"],
"classes": np.asarray(model["classes"], dtype=float)}
return {"estimator": model, "classes": np.asarray(model.classes,
dtype=float)}
class Objective:
"""
WSSQ of one candidate formulation, as the optimiser sees it.
Holds the preprocessor and both models so they are loaded once rather than
per trial, and records every evaluated candidate so the search can be
inspected afterwards instead of yielding only its arg-max.
"""
def __init__(self, space: SearchSpace, preprocessor, feature_columns,
print_model, cell_model,
print_weight: float = wssq_mod.DEFAULT_PRINT_WEIGHT,
cell_weight: float = wssq_mod.DEFAULT_CELL_WEIGHT):
self.space = space
self.pre = preprocessor
self.feature_columns = list(feature_columns)
self.print_model = _as_scorer(print_model)
self.cell_model = _as_scorer(cell_model)
self.print_weight = print_weight
self.cell_weight = cell_weight
self.history: list[dict] = []
def candidate_frame(self, values: dict | list[dict]) -> pd.DataFrame:
"""One row per candidate, in the raw feature layout the preprocessor
expects."""
batch = [values] if isinstance(values, dict) else list(values)
rows = []
for v in batch:
row = {c: 0.0 for c in self.feature_columns}
row.update({k: x for k, x in v.items() if k in row})
row[cfg.CELL_COLS[0]] = self.space.cell_line
rows.append(row)
return pd.DataFrame(rows)[self.feature_columns]
def evaluate_batch(self, batch: list[dict]) -> list[dict]:
"""
Score many candidates in one pass through each model.
This is the form the search uses, and the reason a foundation model can
be offered at all. Their cost is dominated by the pass over the 2,646
context rows, which is paid once per call however many candidates ride
in it: on this corpus a batch of thirty-two costs what a single row
costs. Scoring candidates one at a time, as the previous release did,
charged that pass once per candidate and made an in-context model look
two orders of magnitude more expensive than it is. The conventional
classifiers gain from it too, for the ordinary reason that per-call
Python overhead is then divided across the batch.
"""
X = np.asarray(self.pre.transform(self.candidate_frame(batch)),
dtype=float)
exp_p, proba_p = class_distribution(
self.print_model["estimator"], X, self.print_model["classes"])
# An acellular formulation has no cell response to predict. Asking the
# model anyway would feed the optimiser a number with no referent; the
# rating scale defines class 1 as "not applicable", so that is what is
# used and WSSQ takes its printability-only branch.
if self.space.is_acellular:
exp_c = np.ones(len(batch))
proba_c = [{}] * len(batch)
else:
exp_c, proba_c = class_distribution(
self.cell_model["estimator"], X, self.cell_model["classes"])
out = []
for i in range(len(batch)):
out.append({
"wssq": float(wssq_mod.compute_wssq(
float(exp_p[i]), float(exp_c[i]),
self.print_weight, self.cell_weight)),
"expected_printability": float(exp_p[i]),
"expected_cell_response": float(exp_c[i]),
"printability_proba": proba_p[i],
"cell_response_proba": proba_c[i]})
return out
def evaluate(self, values: dict) -> dict:
"""Score one explicit formulation. Used to report the winner."""
return self.evaluate_batch([values])[0]
def suggest(self, trial) -> dict:
"""One candidate drawn from the search space, as parameter values."""
values = {v.name: v.suggest(trial)
for v in self.space.biomaterials + self.space.printing}
if self.space.cell_density is not None and not self.space.is_acellular:
values[cfg.CELL_COLS[1]] = self.space.cell_density.suggest(trial)
else:
values[cfg.CELL_COLS[1]] = 0.0
return values
def record(self, values: dict, out: dict) -> None:
"""Append one evaluated candidate to the search history."""
# history is exported to a spreadsheet; the two probability dictionaries
# do not belong in it.
self.history.append({**values, **{k: v for k, v in out.items()
if not k.endswith("_proba")}})
def __call__(self, trial) -> float:
"""Sequential form, kept for a caller that drives Optuna directly."""
values = self.suggest(trial)
out = self.evaluate(values)
self.record(values, out)
return out["wssq"]
def optimise(objective: Objective, n_trials: int = 100,
seed: int = cfg.RANDOM_STATE, progress=None,
batch_size: int = DEFAULT_BATCH_SIZE):
"""
Run the search and return (best_values, best_score, study).
Tree-structured Parzen estimation, which is the Bayesian method used in the
published version: it models P(parameters | score) and samples where good
scores are likely, rather than sampling the space uniformly. `multivariate`
and `group` let it model interactions between parameters instead of treating
each in isolation, which matters here because printability depends on
combinations - a concentration that prints well at one nozzle diameter does
not at another.
Candidates are drawn and scored in batches through Optuna's ask-and-tell
interface rather than one at a time. The trade is small and one-sided: the
candidates within a batch are proposed from the same posterior, so the
sampler sees `batch_size` fewer updates over a run, against a reduction in
scoring cost of one to two orders of magnitude. It is what makes the
in-context foundation models usable in a search at all - see
`Objective.evaluate_batch` - and it leaves the conventional classifiers
faster as well. Set `batch_size=1` to recover strictly sequential
behaviour.
"""
if optuna is None: # pragma: no cover
raise RuntimeError("optuna is required for scaffold optimisation")
sampler = optuna.samplers.TPESampler(
seed=seed, n_startup_trials=min(30, max(5, n_trials // 4)),
multivariate=True, group=True, consider_prior=True)
study = optuna.create_study(direction="maximize", sampler=sampler)
done = 0
while done < n_trials:
size = min(max(1, batch_size), n_trials - done)
trials = [study.ask() for _ in range(size)]
batch = [objective.suggest(t) for t in trials]
results = objective.evaluate_batch(batch)
for trial, values, out in zip(trials, batch, results):
objective.record(values, out)
study.tell(trial, out["wssq"])
done += size
if progress is not None:
progress(done, n_trials, study.best_value)
return study.best_trial.params, float(study.best_value), study
def distance_report(values: dict, corpus: pd.DataFrame) -> dict:
"""
How far outside the observed data the proposed formulation sits.
Two numbers, because they answer different questions. `out_of_range` names
the variables whose value exceeds anything recorded in the corpus - a
concentration no one has published. `nearest_neighbour_distance` is the
scaled Euclidean distance to the closest real formulation, which catches the
subtler case where every individual value is ordinary but the combination
has never been attempted.
"""
out_of_range = {}
for name, v in values.items():
if name not in corpus.columns:
continue
col = pd.to_numeric(corpus[name], errors="coerce")
col = col[col > 0]
if len(col) == 0:
continue
lo, hi = float(col.min()), float(col.max())
if v > 0 and (v < lo or v > hi):
out_of_range[name] = {"value": float(v), "observed_min": lo,
"observed_max": hi}
shared = [c for c in values if c in corpus.columns]
nn = float("nan")
if shared:
sub = corpus[shared].apply(pd.to_numeric, errors="coerce").fillna(0.0)
span = (sub.max() - sub.min()).replace(0, 1.0)
target = pd.Series({c: values[c] for c in shared})
nn = float((((sub - target) / span) ** 2).sum(axis=1).pow(0.5).min())
return {"n_out_of_range": len(out_of_range),
"out_of_range": out_of_range,
"nearest_neighbour_distance": nn}
|