Spaces:
Sleeping
Sleeping
File size: 17,912 Bytes
0fff343 | 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 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 | """Typed program synthesis β init, crossover, mutation β full grammar.
Termination guarantee: every type closes at the depth floor.
- Matrix β MatrixTerminal
- Vector β Reduce(MatrixTerminal, agg)
- Scalar β Associate(Reduce(MatrixTerminal, "mean"), target, "spearman")
- Model β (not a leaf β only via FitApply which itself is Vector)
The richer operators (Effect, Split, FitApply, Search) are introduced at
configurable rates so the population genuinely contains programs that
use them. ``Search`` is gated OFF by default (rate 0) β it's the
recursive operator and the prompt explicitly says "flag it and gate
behind a toggle" until we're satisfied with the cost.
"""
from __future__ import annotations
import random
from typing import Sequence
from engine_v2.nodes import (
Associate,
Combine,
Effect,
FeatureSet,
FitApply,
MatrixTerminal,
Node,
Reduce,
Search,
Select,
Split,
)
from engine_v2.types import (
AGGS,
ASSOC_KINDS,
ASSOC_TARGETS,
DEFAULT_MAX_DEPTH,
DEFAULT_MAX_GENES_PER_SET,
MIN_GENES_PER_SET,
OPS,
PREDICATE_KINDS,
SEARCH_MAX_K,
TType,
)
# Init / mutation rates for the richer operators. Keep them modest:
# enough that the population genuinely contains them, low enough that
# the search space stays tractable.
DEFAULT_RATES = {
"split": 0.10, # Vector slot picks Split instead of Reduce/Combine.
"fitapply": 0.10, # Vector slot picks FitApply.
"effect": 0.40, # Scalar slot picks Effect (vs Associate).
# Matrix slot picks Search instead of Select/M. ON at a modest
# rate so Search appears in the population without dominating β
# every Search runs an inner gene ranking, which is the
# heaviest per-node operation. Caps stay (k β€ 4 selected,
# β€ 200 candidate columns) so each Search's cost is bounded.
"search": 0.05,
}
# ---------------------------------------------------------------------------
# Feature-set sampling
# ---------------------------------------------------------------------------
def _sample_feature_set(
rng: random.Random,
pool: Sequence[str],
*,
max_genes_per_set: int,
) -> FeatureSet:
if not pool:
raise ValueError("synth: empty gene pool")
upper = min(max_genes_per_set, len(pool))
size = rng.randint(MIN_GENES_PER_SET, max(MIN_GENES_PER_SET, upper))
return FeatureSet(rng.sample(list(pool), size))
# ---------------------------------------------------------------------------
# Grow β typed recursive expansion
# ---------------------------------------------------------------------------
def _grow_matrix(rng: random.Random, pool, depth: int, *,
mgps: int, full: bool, rates: dict,
objective_target: str) -> Node:
"""Generate a Matrix-typed subtree with depth <= ``depth``.
Every Matrix leaf is wrapped in a ``Select`` β a bare
``MatrixTerminal()`` would let a program score on the global
expression vector with zero gene choice (a "whole-matrix mean"
detector that conflates detection with gene discovery). The rule
applies to ALL objectives: MSI/TMB/HPV/unsup. ``Search`` is gated
off under unsupervised (the rate is already 0 by default but the
gate makes it explicit).
"""
is_unsup = objective_target == "none"
if depth < 1:
depth = 1
if depth == 1:
return Select(
MatrixTerminal(),
_sample_feature_set(rng, pool, max_genes_per_set=mgps),
)
# Matrix options: Select(Matrix, FeatureSet) | Search(Matrix, k).
# The bare-MatrixTerminal fall-through is gone β it would have
# produced a global-mean detector that scores well via bulk
# expression rather than gene choice.
r = rng.random()
if not is_unsup and rates["search"] > 0 and r < rates["search"]:
inner = _grow_matrix(
rng, pool, depth - 1, mgps=mgps, full=full, rates=rates,
objective_target=objective_target,
)
return Search(inner, k=rng.randint(2, SEARCH_MAX_K))
inner = _grow_matrix(
rng, pool, depth - 1, mgps=mgps, full=full, rates=rates,
objective_target=objective_target,
)
return Select(inner, _sample_feature_set(rng, pool, max_genes_per_set=mgps))
def _grow_vector(rng: random.Random, pool, depth: int, *,
mgps: int, full: bool, rates: dict,
objective_target: str,
in_split: bool = False) -> Node:
"""Generate a Vector-typed subtree with depth <= ``depth``.
Operators that score against a label (FitApply) are constructed
with ``target = objective_target`` β the engine never picks its
own target. See ``_check_target_binding`` in ``fitness.py``.
"""
is_unsup = objective_target == "none"
if depth < 2:
depth = 2
if depth == 2:
# Depth-floor: every objective wraps in Select so no Reduce
# ever sits on a bare MatrixTerminal (would be a global-mean
# shortcut that detects without choosing genes).
leaf: Node = Select(
MatrixTerminal(),
_sample_feature_set(rng, pool, max_genes_per_set=mgps),
)
return Reduce(leaf, rng.choice(AGGS))
r = rng.random()
# Split has ONE-level rule β never inside another Split.
if not in_split and r < rates["split"]:
inner = _grow_vector(
rng, pool, depth - 1,
mgps=mgps, full=full, rates=rates,
objective_target=objective_target, in_split=True,
)
# Under unsup the structure must come from the gene-based score,
# not the known clinical axis (stage_late) β force "score".
predicate = (
"score" if is_unsup else rng.choice(PREDICATE_KINDS)
)
return Split(inner, predicate=predicate)
if r < rates["split"] + rates["fitapply"]:
inner = _grow_vector(
rng, pool, depth - 1,
mgps=mgps, full=full, rates=rates,
objective_target=objective_target, in_split=in_split,
)
# FitApply's target is BOUND to the active objective, never picked.
return FitApply(inner, target=objective_target)
if full or rng.random() < 0.5:
return Combine(
_grow_vector(rng, pool, depth - 1, mgps=mgps, full=full, rates=rates,
objective_target=objective_target, in_split=in_split),
_grow_vector(rng, pool, depth - 1, mgps=mgps, full=full, rates=rates,
objective_target=objective_target, in_split=in_split),
rng.choice(OPS),
)
return Reduce(
_grow_matrix(
rng, pool, depth - 1, mgps=mgps, full=full, rates=rates,
objective_target=objective_target,
),
rng.choice(AGGS),
)
def _grow_scalar(rng: random.Random, pool, depth: int, *,
mgps: int, full: bool, rates: dict,
objective_target: str) -> Node:
"""Generate a Scalar-typed subtree (Associate or Effect).
The target is BOUND to the active objective β the engine picks the
kind (pearson / spearman) and whether to use Effect (adjusted) or
Associate (raw), never the target.
"""
if depth < 3:
depth = 3
inner_depth = max(2, depth - 1)
inner = _grow_vector(rng, pool, inner_depth,
mgps=mgps, full=full, rates=rates,
objective_target=objective_target)
kind = rng.choice(ASSOC_KINDS)
if rng.random() < rates["effect"]:
return Effect(inner, target=objective_target, kind=kind)
return Associate(inner, target=objective_target, kind=kind)
def random_program(
rng: random.Random,
pool: Sequence[str],
*,
objective_target: str,
max_depth: int = DEFAULT_MAX_DEPTH,
max_genes_per_set: int = DEFAULT_MAX_GENES_PER_SET,
full: bool | None = None,
rates: dict | None = None,
return_type: TType = TType.VECTOR,
) -> Node:
"""A random tree of the requested return type. ``return_type=Vector``
is the engine's normal root β but the GP also explores Scalar-rooted
programs (Associate / Effect) since A3 allows Scalar outputs. The
objective's target is bound; the engine never chooses it.
"""
rates = rates or DEFAULT_RATES
if full is None:
full = rng.random() < 0.5
if return_type is TType.SCALAR:
return _grow_scalar(rng, pool, max_depth,
mgps=max_genes_per_set, full=full, rates=rates,
objective_target=objective_target)
return _grow_vector(rng, pool, max_depth,
mgps=max_genes_per_set, full=full, rates=rates,
objective_target=objective_target)
def ramped_population(
rng: random.Random,
pool: Sequence[str],
*,
n: int,
objective_target: str,
max_depth: int = DEFAULT_MAX_DEPTH,
max_genes_per_set: int = DEFAULT_MAX_GENES_PER_SET,
rates: dict | None = None,
scalar_share: float = 0.20,
) -> list[Node]:
"""Ramped half-and-half across depths, with ``scalar_share`` of the
population rooted at a Scalar (Associate / Effect)."""
out: list[Node] = []
depths = list(range(2, max_depth + 1)) or [2]
for i in range(n):
d = depths[i % len(depths)]
full = (i // len(depths)) % 2 == 0
rt = TType.SCALAR if rng.random() < scalar_share else TType.VECTOR
out.append(random_program(
rng, pool, objective_target=objective_target,
max_depth=d, max_genes_per_set=max_genes_per_set,
full=full, rates=rates, return_type=rt,
))
return out
# ---------------------------------------------------------------------------
# Crossover β swap subtrees of matching return type
# ---------------------------------------------------------------------------
def _enumerate(parent: Node, slot: TType) -> list[tuple[Node, "_Cursor"]]:
targets: list[tuple[Node, _Cursor]] = []
if parent.ttype is slot:
targets.append((parent, _Cursor.root(parent, slot)))
_walk_for_replacement(parent, slot, targets)
return targets
def _walk_for_replacement(parent: Node, slot: TType,
out: list[tuple[Node, "_Cursor"]]) -> None:
# Single-child carriers
one_child_attrs = {
Select: "matrix",
Reduce: "matrix",
Split: "inner",
Associate: "inner",
Effect: "inner",
FitApply: "inner",
Search: "matrix",
}
for cls, attr in one_child_attrs.items():
if isinstance(parent, cls):
child: Node = getattr(parent, attr)
if child.ttype is slot:
out.append((child, _Cursor.field(parent, attr, slot)))
_walk_for_replacement(child, slot, out)
return
if isinstance(parent, Combine):
for attr in ("left", "right"):
ch = getattr(parent, attr)
if ch.ttype is slot:
out.append((ch, _Cursor.field(parent, attr, slot)))
_walk_for_replacement(ch, slot, out)
return
# MatrixTerminal has no children.
class _Cursor:
def __init__(self, applier, slot):
self._apply = applier
self.slot = slot
def apply(self, new_node: Node) -> Node:
return self._apply(new_node)
@staticmethod
def root(root: Node, slot: TType) -> "_Cursor":
def apply(new_node: Node) -> Node:
return new_node
return _Cursor(apply, slot)
@staticmethod
def field(parent: Node, name: str, slot: TType) -> "_Cursor":
def apply(new_node: Node) -> Node:
setattr(parent, name, new_node)
return new_node
return _Cursor(apply, slot)
def crossover(
rng: random.Random,
p1: Node,
p2: Node,
*,
max_depth: int = DEFAULT_MAX_DEPTH,
max_nodes: int = 64,
) -> Node:
from copy import deepcopy
child = deepcopy(p1)
candidate_slots = (
_enumerate(child, TType.VECTOR)
+ _enumerate(child, TType.MATRIX)
+ _enumerate(child, TType.SCALAR)
)
options: list[tuple[TType, _Cursor, Node]] = []
seen: set[int] = set()
for sub, cur in candidate_slots:
if id(sub) in seen:
continue
seen.add(id(sub))
options.append((sub.ttype, cur, sub))
rng.shuffle(options)
for slot_t, cur, _sub in options:
donor_slots = _enumerate(p2, slot_t)
if not donor_slots:
continue
donor_sub, _ = rng.choice(donor_slots)
donor_copy = deepcopy(donor_sub)
replaced = cur.apply(donor_copy)
if cur.slot is child.ttype and id(replaced) is id(donor_copy):
new_root = donor_copy
else:
new_root = child
if new_root.depth() <= max_depth and new_root.node_count() <= max_nodes:
return new_root
child = deepcopy(p1)
return deepcopy(p1)
# ---------------------------------------------------------------------------
# Mutation
# ---------------------------------------------------------------------------
def mutate(
rng: random.Random,
program: Node,
pool: Sequence[str],
*,
objective_target: str,
p_mut: float = 0.7,
max_depth: int = DEFAULT_MAX_DEPTH,
max_genes_per_set: int = DEFAULT_MAX_GENES_PER_SET,
max_nodes: int = 64,
rates: dict | None = None,
) -> Node:
"""Subtree + point mutation. The objective's target is BOUND β it is
never a point-mutation spot, and freshly-grown subtrees inherit the
same binding."""
from copy import deepcopy
if rng.random() > p_mut:
return program
rates = rates or DEFAULT_RATES
program = deepcopy(program)
# ----- Subtree mutation -----
if rng.random() < 0.5:
candidates = (
_enumerate(program, TType.VECTOR)
+ _enumerate(program, TType.MATRIX)
+ _enumerate(program, TType.SCALAR)
)
seen: set[int] = set()
unique = []
for sub, cur in candidates:
if id(sub) in seen:
continue
seen.add(id(sub))
unique.append((sub, cur))
if not unique:
return program
original = deepcopy(program)
sub, cur = rng.choice(unique)
sub_budget = 3 if cur.slot is TType.VECTOR else 2
if cur.slot is TType.SCALAR:
new_sub = _grow_scalar(
rng, pool, 3,
mgps=max_genes_per_set, full=False, rates=rates,
objective_target=objective_target,
)
elif cur.slot is TType.MATRIX:
new_sub = _grow_matrix(
rng, pool, sub_budget,
mgps=max_genes_per_set, full=False, rates=rates,
objective_target=objective_target,
)
else:
new_sub = _grow_vector(
rng, pool, sub_budget,
mgps=max_genes_per_set, full=False, rates=rates,
objective_target=objective_target,
)
new_root = cur.apply(new_sub)
if cur.slot is program.ttype and id(new_root) is id(new_sub):
program = new_sub
if program.depth() > max_depth or program.node_count() > max_nodes:
return original
return program
# ----- Point mutation -----
# NOTE: the target field on Associate / Effect / FitApply is NOT a
# point-mutation spot β the engine must not flip a program's target
# mid-run.
spots: list[tuple[str, object]] = []
for n in program.walk():
if isinstance(n, Reduce):
spots.append(("agg", n))
elif isinstance(n, Combine):
spots.append(("op", n))
elif isinstance(n, Select):
spots.append(("gene", n.features))
elif isinstance(n, Associate) or isinstance(n, Effect):
spots.append(("scalar_kind", n))
elif isinstance(n, Split):
# Under unsupervised the only legal predicate is "score" β
# don't emit a mutation spot that could flip it to a clinical
# variable (stage_late).
if objective_target != "none":
spots.append(("predicate", n))
elif isinstance(n, Search):
spots.append(("search_k", n))
if not spots:
return program
kind, target = rng.choice(spots)
if kind == "agg":
target.agg = rng.choice( # type: ignore[attr-defined]
[a for a in AGGS if a != target.agg] or list(AGGS)
)
elif kind == "op":
target.op = rng.choice( # type: ignore[attr-defined]
[o for o in OPS if o != target.op] or list(OPS)
)
elif kind == "gene":
fs = target # FeatureSet
if not pool or not fs.ids: # type: ignore[attr-defined]
return program
idx = rng.randrange(len(fs.ids)) # type: ignore[attr-defined]
replacements = [g for g in pool if g not in fs.ids] # type: ignore[attr-defined]
if not replacements:
return program
fs.ids[idx] = rng.choice(replacements) # type: ignore[attr-defined]
elif kind == "scalar_kind":
# Flip only the correlation kind (pearson/spearman) β never the
# target. The target is the objective's, by construction.
target.kind = rng.choice( # type: ignore[attr-defined]
[k for k in ASSOC_KINDS if k != target.kind] or list(ASSOC_KINDS)
)
elif kind == "predicate":
target.predicate = rng.choice( # type: ignore[attr-defined]
[p for p in PREDICATE_KINDS if p != target.predicate]
or list(PREDICATE_KINDS)
)
elif kind == "search_k":
target.k = max(2, min(SEARCH_MAX_K, target.k + rng.choice([-1, 1]))) # type: ignore[attr-defined]
return program
|