Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
483 changes: 483 additions & 0 deletions .claude/skills/optimize-model-parameters/SKILL.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ temp/
# Sandbox nodes (local experimentation only)
src/neuroworkflow/nodes/sandbox/

# Simulation output written by the tutorial notebooks (SONATA networks, spike
# files, figures, optimization ledgers) — regenerated by running them
notebooks/results/

# Remote (Slurm) execution runtime dirs: per-run staged inputs + fetched
# results, co-located under each project (codes/projects/<id>/batch/<run_id>/)
gui/workflow_backend/django-project/codes/projects/*/batch/
377 changes: 377 additions & 0 deletions docs/OPTIMIZATION.md

Large diffs are not rendered by default.

317 changes: 317 additions & 0 deletions docs/OPTIMIZATION_GUI_HANDOFF.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
"Key rules:\n"
"- When reading parameter values, always use 'default_value' as the current value. Ignore any 'value' field.\n"
"- When a user asks to change a parameter, call update_node_parameter with parameter_field='default_value'.\n"
"- Node instance names become Python variable names in the generated code and are NOT sanitized: use "
"only letters, digits and underscores (e.g. 'Excitatory_Pop1' or 'excPop1'). A name containing a space "
"produces invalid Python and the whole workflow fails to run.\n"
"- Give every population a unique pop_name. This matters most for the generic NW_* builder nodes, where "
"pop_name names the generated network files: the default is 'v1', so a workflow with more than one "
"population must rename them (e.g. 'exc_1', 'exc_2', 'inh'). Populations sharing a name overwrite each "
"other's network files and cannot be told apart in the simulation output.\n"
"- When a user asks to generate a report, methods section, or paper section about a workflow:\n"
" 1. Call get_workflow_facts (NOT get_flow) to collect all node parameters and results.\n"
" 2. Write the report as a proper scientific Methods section following these rules:\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

from typing import Dict, List, Any, Callable, Optional, Type, Union
import copy
import inspect

from neuroworkflow.core.schema import NodeDefinitionSchema, PortDefinition, ParameterDefinition, MethodDefinition
Expand Down Expand Up @@ -52,7 +53,19 @@ def __init__(self, name: str, description: str = ""):
description: Description of the node (optional)
"""
self.name = name
self.description = description or self.__class__.NODE_DEFINITION.description
# Each node gets its own copy of its definition, the same way it gets its own
# parameter values below. Without this, two nodes of the same class share one
# NodeDefinitionSchema, so per-node schema edits — an optimization range on
# one population but not the other — would overwrite each other.
try:
self.NODE_DEFINITION = copy.deepcopy(type(self).NODE_DEFINITION)
except Exception:
# A default_value that cannot be copied (an open handle, a simulator
# object) must not stop the node from being constructed; fall back to
# the shared definition, which is how it behaved before.
self.NODE_DEFINITION = type(self).NODE_DEFINITION

self.description = description or self.NODE_DEFINITION.description
self._input_ports: Dict[str, InputPort] = {}
self._output_ports: Dict[str, OutputPort] = {}
self._process_steps: List[ProcessStep] = []
Expand All @@ -68,7 +81,7 @@ def __init__(self, name: str, description: str = ""):

def _initialize_parameters(self) -> None:
"""Initialize parameters from NODE_DEFINITION schema."""
for name, param_def in self.__class__.NODE_DEFINITION.parameters.items():
for name, param_def in self.NODE_DEFINITION.parameters.items():
if isinstance(param_def, ParameterDefinition):
self._parameters[name] = param_def.default_value

Expand Down Expand Up @@ -98,7 +111,7 @@ def _initialize_parameters(self) -> None:
def _define_ports_from_definition(self) -> None:
"""Define input and output ports from NODE_DEFINITION."""
# Create input ports from NODE_DEFINITION
for name, port_def in self.__class__.NODE_DEFINITION.inputs.items():
for name, port_def in self.NODE_DEFINITION.inputs.items():
if isinstance(port_def, PortDefinition):
port_type = port_def.type if isinstance(port_def.type, PortType) else None
data_type = port_def.type.to_python_type() if isinstance(port_def.type, PortType) else port_def.type
Expand All @@ -118,7 +131,7 @@ def _define_ports_from_definition(self) -> None:
self.register_input(name, object, str(port_def))

# Create output ports from NODE_DEFINITION
for name, port_def in self.__class__.NODE_DEFINITION.outputs.items():
for name, port_def in self.NODE_DEFINITION.outputs.items():
if isinstance(port_def, PortDefinition):
port_type = port_def.type if isinstance(port_def.type, PortType) else None
data_type = port_def.type.to_python_type() if isinstance(port_def.type, PortType) else port_def.type
Expand Down Expand Up @@ -205,8 +218,8 @@ def add_process_step(self, name: str, method: Callable, description: str = "",
method_key = method.__name__

# If the method is in NODE_DEFINITION, use its definition
if method_key in self.__class__.NODE_DEFINITION.methods:
method_def = self.__class__.NODE_DEFINITION.methods[method_key]
if method_key in self.NODE_DEFINITION.methods:
method_def = self.NODE_DEFINITION.methods[method_key]

if isinstance(method_def, MethodDefinition):
# Use description from NODE_DEFINITION if not explicitly provided
Expand Down Expand Up @@ -258,7 +271,7 @@ def get_info(self) -> Dict[str, Any]:
"""
return {
'name': self.name,
'type': self.__class__.NODE_DEFINITION.type,
'type': self.NODE_DEFINITION.type,
'description': self.description,
'parameters': self._parameters,
'optimizable_parameters': self._optimizable_parameters,
Expand All @@ -277,7 +290,7 @@ def get_info(self) -> Dict[str, Any]:
for step in self._process_steps],
'methods': {name: (method_def.description if isinstance(method_def, MethodDefinition) else
(method_def.get('description', '') if isinstance(method_def, dict) else str(method_def)))
for name, method_def in self.__class__.NODE_DEFINITION.methods.items()}
for name, method_def in self.NODE_DEFINITION.methods.items()}
}

def get_input_port(self, name: str) -> InputPort:
Expand Down Expand Up @@ -325,8 +338,8 @@ def connect_to(self, output_port: str, target_node: 'Node', input_port: str) ->
TypeError: If the ports are not compatible
"""
# Get node types for informational purposes only
source_type = self.__class__.NODE_DEFINITION.type
target_type = target_node.__class__.NODE_DEFINITION.type
source_type = self.NODE_DEFINITION.type
target_type = target_node.NODE_DEFINITION.type

source_port = self.get_output_port(output_port)
target_port = target_node.get_input_port(input_port)
Expand Down Expand Up @@ -397,7 +410,7 @@ def configure(self, **parameters) -> 'Node':
for param_name, value in parameters.items():
if param_name in self._parameters:
# Get parameter definition
param_def = self.__class__.NODE_DEFINITION.parameters.get(param_name)
param_def = self.NODE_DEFINITION.parameters.get(param_name)

# Check parameter constraints
if isinstance(param_def, ParameterDefinition) and param_def.constraints:
Expand Down Expand Up @@ -514,7 +527,7 @@ def __str__(self) -> str:
Returns:
String representation
"""
result = [f"Node: {self.name} ({self.__class__.NODE_DEFINITION.type})"]
result = [f"Node: {self.name} ({self.NODE_DEFINITION.type})"]
result.append(f"Description: {self.description}")

if self._parameters:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
node definitions, ports, parameters, and methods in the workflow system.
"""

import warnings
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Dict, List, Union, Any, Type, Optional, Tuple
Expand Down Expand Up @@ -83,6 +84,23 @@ def is_memory_port(self) -> bool:
return not self.is_io_port()


def _range_problem(value: Any) -> Optional[str]:
"""Describe what is wrong with a [min, max] pair, or None if it is usable.

Non-numeric bounds are left alone: a range may legitimately be expressed in
terms this module does not interpret.
"""
if not isinstance(value, (list, tuple)) or len(value) != 2:
return f"must be [min, max], got {value}"
low, high = value
if not all(isinstance(v, (int, float)) and not isinstance(v, bool)
for v in (low, high)):
return None
if low > high:
return f"min {low} is above max {high}"
return None


@dataclass
class ParameterDefinition:
"""Definition of a parameter in a node.
Expand All @@ -92,21 +110,80 @@ class ParameterDefinition:
description: Human-readable description
constraints: Validation constraints (min, max, allowed_values, etc.)
optimizable: Whether this parameter can be tuned during optimization
optimization_range: [min, max] range for parameter tuning
optimization_range: [min, max] range for parameter tuning. For a
dict-valued parameter, a range per key instead:
{"V_th": [-60.0, -45.0], "C_m": [200.0, 300.0]}
is_objective: Whether this parameter serves as an optimization objective/target
objective_range: [min, max] acceptable range for the objective value
suggested_values: List of suggested values for the parameter
unit: Physical unit of the value (e.g. "Hz", "pF", "ms")
measures: For an objective, the address of the output value it is compared
against, as "NodeName.output_port[.key...]" (e.g.
"Analysis.firing_rate_hz.exc"). Resolved against a baseline run before
an optimization starts.
"""
default_value: Any = None
description: str = ""
constraints: Dict[str, Any] = field(default_factory=dict)
optimizable: bool = False
optimization_range: Optional[List[Any]] = None
optimization_range: Optional[Union[List[Any], Dict[str, Any]]] = None
is_objective: bool = False
objective_range: Optional[List[Any]] = None
metadata_sources: List[str] = field(default_factory=list)
species_specific: bool = False
suggested_values: List[Dict[str, Any]] = field(default_factory=list)
unit: str = ""
measures: Optional[str] = None

def __post_init__(self) -> None:
"""Warn when the search window does not lie inside the constraints.

``constraints`` are hard bounds: ``configure()`` rejects values outside
them. ``optimization_range`` only says where a search should look, so a
range reaching past the constraints describes points that could never be
evaluated.

A dict-valued parameter declares one range per key
(``{"V_th": [-60.0, -45.0]}``). Only the shape of each pair is checked
there: ``constraints`` belongs to the parameter as a whole and cannot
bound an individual key.

This only warns. A partly-specified optimization declaration must never
stop a node from being imported or uploaded.
"""
if not self.optimization_range: # None or {} or [] all mean "unspecified"
return

def warn(problem: str) -> None:
hint = self.description[:60] or f"default_value={self.default_value!r}"
warnings.warn(
f"optimization_range {problem} ({hint})",
UserWarning,
stacklevel=3,
)

if isinstance(self.optimization_range, dict):
for key, pair in self.optimization_range.items():
problem = _range_problem(pair)
if problem:
warn(f"for key {key!r} {problem}")
return

problem = _range_problem(self.optimization_range)
if problem:
warn(problem)
return

low, high = self.optimization_range
if not all(isinstance(v, (int, float)) for v in (low, high)):
return # non-numeric ranges are not checked against constraints

c_min = self.constraints.get('min')
c_max = self.constraints.get('max')
if isinstance(c_min, (int, float)) and low < c_min:
warn(f"min {low} is below the constraint min {c_min}")
if isinstance(c_max, (int, float)) and high > c_max:
warn(f"max {high} is above the constraint max {c_max}")


@dataclass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ def validate(self) -> bool:
return False

# Get node types for informational purposes only
source_type = source_node.__class__.NODE_DEFINITION.type
target_type = target_node.__class__.NODE_DEFINITION.type
source_type = source_node.NODE_DEFINITION.type
target_type = target_node.NODE_DEFINITION.type

# Check type compatibility
if not target_port.is_compatible_with(source_port):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Parameter optimization for NeuroWorkflow workflows.

The workflow is the objective function; the optimizer wraps it. Typical use::

from neuroworkflow.optimization import build_spec, optimize, AlgorithmConfig

spec = build_spec(workflow, AlgorithmConfig(name="cmaes", pop_size=12))
print(spec.summary()) # review, edit, or hand to an agent
result = optimize(workflow, spec=spec)
print(result.configure_snippet())
"""

from .addressing import discover_measurables, read_output, set_parameter
from .engine import (OptimizationResult, objective_fitness, optimize,
reusable_paths)
from .ledger import Ledger
from .optimizers import Optimizer, available, register_optimizer
from .spec import (
AlgorithmConfig,
Dimension,
Objective,
OptimizationSpec,
build_spec,
collect_dimensions,
collect_objectives,
)

__all__ = [
"AlgorithmConfig",
"Dimension",
"Ledger",
"Objective",
"OptimizationResult",
"OptimizationSpec",
"Optimizer",
"available",
"build_spec",
"collect_dimensions",
"collect_objectives",
"discover_measurables",
"objective_fitness",
"optimize",
"read_output",
"reusable_paths",
"register_optimizer",
"set_parameter",
]
Loading