manager¶
Constraint-aware parameter calibration for Lintul5Model.
CalibrationManager is the runtime counterpart of the declarative
torchcrop.calibration.spec description, structurally parallel to
torchcrop.nn.hybrid.HybridManager:
- it owns the unconstrained latent
nn.Parameterset (what the optimizer sees viaparameters); materializemaps the latents through their bijections — and through the joint reconstruction of ordered groups — into the physical crop / soil / site parameter tensors (rebuilding[N, 2]tables as needed) before each forward pass;flatten/set_flatexpose the latent vector so the same problem definition can be driven by a gradient-free optimizer (CMA-ES, scipy, …) instead of autograd.
Because every latent is unconstrained and every map is feasible-by-construction, each optimizer iterate yields a valid parameter set — no penalty terms, no post-step projection.
CalibrationManager (Module)
¶
Owns latent variables and writes constrained parameters into a model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model |
Any |
The |
required |
specs |
Sequence[ParameterSpec] |
The free parameters and their bounds / types. |
required |
groups |
Sequence[ConstraintGroup] | None |
Optional ordering / cross-parameter relations. Every member of
an |
None |
Exceptions:
| Type | Description |
|---|---|
ValueError |
On duplicate spec names, unknown group members, or bounds incompatible with a requested ordering. |
Source code in torchcrop/calibration/manager.py
class CalibrationManager(nn.Module):
"""Owns latent variables and writes constrained parameters into a model.
Args:
model: The `Lintul5Model` (or any object
exposing ``crop_params`` / ``soil_params`` / ``site_params``) whose
parameters are being calibrated.
specs: The free parameters and their bounds / types.
groups: Optional ordering / cross-parameter relations. Every member of
an ``ascending`` / ``descending`` group must also appear in
``specs`` with ``bounds`` set.
Raises:
ValueError: On duplicate spec names, unknown group members, or bounds
incompatible with a requested ordering.
"""
def __init__(
self,
model: Any,
specs: Sequence[ParameterSpec],
groups: Sequence[ConstraintGroup] | None = None,
) -> None:
super().__init__()
# Hold the parameter containers WITHOUT registering ``model`` as a
# submodule (it is a plain dict of dataclasses), so ``self.parameters()``
# yields only the calibration latents — exactly what the optimizer wants.
self._containers: dict[str, Any] = {
"crop": model.crop_params,
"soil": model.soil_params,
"site": model.site_params,
}
self._specs: dict[str, ParameterSpec] = {}
for s in specs:
if s.name in self._specs:
raise ValueError(f"duplicate spec for {s.name!r}")
self._specs[s.name] = s
self._targets: dict[str, Target] = {
name: parse_path(name, self._containers) for name in self._specs
}
self._groups: list[ConstraintGroup] = list(groups or [])
# member name -> owning ordered group index (free groups excluded)
self._member_group: dict[str, int] = {}
for gi, g in enumerate(self._groups):
for m in g.members:
if m not in self._specs:
raise ValueError(
f"group member {m!r} has no ParameterSpec"
)
if g.order == "free":
continue
for m in g.members:
if m in self._member_group:
raise ValueError(
f"{m!r} belongs to more than one ordered group"
)
self._member_group[m] = gi
self._validate_ordered_bounds(g)
# Snapshot original tables BEFORE any materialize so frozen ordinates
# and all abscissae stay pinned to the preset values.
self._table_originals: dict[tuple[str, str], torch.Tensor] = {}
for t in self._targets.values():
if t.is_table and t.table_key not in self._table_originals:
tbl = getattr(self._containers[t.container], t.field)
self._table_originals[t.table_key] = tbl.detach().clone()
self._latents = nn.ParameterDict()
self._build_latents()
# ------------------------------------------------------------------ #
# Construction helpers
# ------------------------------------------------------------------ #
def _ref_tensor(self, name: str) -> torch.Tensor:
"""Return the live tensor backing ``name`` (scalar field or table y)."""
t = self._targets[name]
obj = self._containers[t.container]
field = getattr(obj, t.field)
return field[t.row, 1] if t.is_table else field
def _init_value(self, name: str) -> torch.Tensor:
"""Initial physical value: explicit ``init`` or the current value."""
spec = self._specs[name]
ref = self._ref_tensor(name)
v = spec.init if spec.init is not None else read_value(self._targets[name], self._containers)
return torch.as_tensor(v, dtype=ref.dtype, device=ref.device)
def _validate_ordered_bounds(self, g: ConstraintGroup) -> None:
"""Ensure member bounds are compatible with the requested ordering."""
bnds = []
for m in g.members:
b = self._specs[m].bounds
if b is None:
raise ValueError(
f"ordered group member {m!r} requires bounds"
)
bnds.append(b)
for (lo0, hi0), (lo1, hi1), m in zip(bnds, bnds[1:], g.members[1:]):
if g.order == "ascending" and not (lo1 >= lo0 and hi1 >= hi0):
raise ValueError(
f"ascending group: bounds of {m!r} {(lo1, hi1)} must be "
f">= previous member's bounds {(lo0, hi0)} (lo and hi)"
)
if g.order == "descending" and not (lo1 <= lo0 and hi1 <= hi0):
raise ValueError(
f"descending group: bounds of {m!r} {(lo1, hi1)} must be "
f"<= previous member's bounds {(lo0, hi0)} (lo and hi)"
)
def _build_latents(self) -> None:
"""Create one latent per free / ordered-member spec (not categorical)."""
# Free specs (not in any ordered group, not categorical): invert the
# bijector at the init value to seed the latent.
for name, spec in self._specs.items():
if spec.kind == "categorical" or name in self._member_group:
continue
bij = build_transform(spec.resolved_transform(), spec.bounds)
z = bij.inverse(self._init_value(name)).detach().clone()
self._latents[_key(name)] = nn.Parameter(z)
# Ordered-group members: seed latents from the incremental inverse.
for g in self._groups:
if g.order == "free":
continue
self._seed_ordered_latents(g)
def _seed_ordered_latents(self, g: ConstraintGroup) -> None:
"""Seed per-member latents so materialize reproduces the init values."""
inits = [self._init_value(m) for m in g.members]
bnds = [self._specs[m].bounds for m in g.members]
prev: torch.Tensor | None = None
for m, v, (lo, hi) in zip(g.members, inits, bnds):
lo_t = torch.as_tensor(lo, dtype=v.dtype, device=v.device)
hi_t = torch.as_tensor(hi, dtype=v.dtype, device=v.device)
if prev is None:
lower, upper = lo_t, hi_t
elif g.order == "ascending":
lower, upper = torch.maximum(lo_t, prev), hi_t
else: # descending
lower, upper = lo_t, torch.minimum(hi_t, prev)
u = (v - lower) / (upper - lower).clamp(min=_EPS)
self._latents[_key(m)] = nn.Parameter(_logit(u).detach().clone())
prev = v
# ------------------------------------------------------------------ #
# Materialization
# ------------------------------------------------------------------ #
def _materialize_values(self) -> dict[str, torch.Tensor]:
"""Compute the physical value tensor for every spec."""
values: dict[str, torch.Tensor] = {}
for name, spec in self._specs.items():
if spec.kind == "categorical" or name in self._member_group:
continue
bij = build_transform(spec.resolved_transform(), spec.bounds)
v = bij.forward(self._latents[_key(name)])
values[name] = round_ste(v) if spec.kind == "integer" else v
for g in self._groups:
if g.order == "free":
continue
for m, v in zip(g.members, self._reconstruct_ordered(g)):
spec = self._specs[m]
values[m] = round_ste(v) if spec.kind == "integer" else v
for name, spec in self._specs.items():
if spec.kind != "categorical":
continue
ref = self._ref_tensor(name)
cur = read_value(self._targets[name], self._containers)
nearest = min(spec.categories, key=lambda c: abs(c - cur)) # type: ignore[arg-type]
values[name] = torch.as_tensor(
nearest, dtype=ref.dtype, device=ref.device
)
return values
def _reconstruct_ordered(self, g: ConstraintGroup) -> list[torch.Tensor]:
"""Map a group's latents to monotone values inside their boxes."""
out: list[torch.Tensor] = []
prev: torch.Tensor | None = None
for m in g.members:
lo, hi = self._specs[m].bounds # type: ignore[misc]
z = self._latents[_key(m)]
lo_t = torch.as_tensor(lo, dtype=z.dtype, device=z.device)
hi_t = torch.as_tensor(hi, dtype=z.dtype, device=z.device)
if prev is None:
lower, upper = lo_t, hi_t
elif g.order == "ascending":
lower, upper = torch.maximum(lo_t, prev), hi_t
else: # descending
lower, upper = lo_t, torch.minimum(hi_t, prev)
v = lower + (upper - lower) * torch.sigmoid(z)
out.append(v)
prev = v
return out
def materialize(self) -> dict[str, torch.Tensor]:
"""Write all constrained values into the parameter containers.
Call once **before each forward pass**. Scalar targets overwrite their
dataclass field; table targets are batched per owning table and the
``[N, 2]`` tensor is rebuilt once (non-in-place, so autograd is
preserved).
Returns:
A mapping ``target name -> materialized value tensor`` for
inspection / logging.
"""
values = self._materialize_values()
table_updates: dict[tuple[str, str], dict[int, torch.Tensor]] = {}
for name, value in values.items():
target = self._targets[name]
if target.is_table:
table_updates.setdefault(target.table_key, {})[target.row] = value
else:
write_scalar(target, value, self._containers)
for table_key, updates in table_updates.items():
container, field = table_key
new_table = rebuild_table(self._table_originals[table_key], updates)
setattr(self._containers[container], field, new_table)
return values
forward = materialize # so ``manager()`` materializes, like other nn.Modules
# ------------------------------------------------------------------ #
# Gradient-free / Bayesian bridge
# ------------------------------------------------------------------ #
def latent_names(self) -> tuple[str, ...]:
"""Stable, sorted order of latent target names."""
return tuple(sorted(self._latents.keys()))
def flatten(self) -> torch.Tensor:
"""Return the latent vector as a flat 1-D tensor (detached)."""
return torch.stack(
[self._latents[k].detach().reshape(()) for k in self.latent_names()]
)
@torch.no_grad()
def set_flat(self, flat: torch.Tensor) -> None:
"""Overwrite the latent vector from a flat 1-D tensor.
Args:
flat: 1-D tensor matching `flatten`'s length and order.
Raises:
ValueError: If ``flat`` has the wrong length.
"""
names = self.latent_names()
if flat.numel() != len(names):
raise ValueError(
f"expected {len(names)} latents, got {flat.numel()}"
)
for k, v in zip(names, flat.reshape(-1)):
self._latents[k].copy_(v.to(self._latents[k].dtype))
def set_categorical(self, name: str, value: float) -> None:
"""Set a categorical target's current value (for an outer loop).
Args:
name: A spec name with ``kind="categorical"``.
value: One of the spec's ``categories``.
Raises:
ValueError: If ``name`` is not categorical or ``value`` is not an
allowed category.
"""
spec = self._specs.get(name)
if spec is None or spec.kind != "categorical":
raise ValueError(f"{name!r} is not a categorical target")
if value not in spec.categories: # type: ignore[operator]
raise ValueError(
f"{value} not in categories {spec.categories} for {name!r}"
)
ref = self._ref_tensor(name)
write_scalar(
self._targets[name],
torch.as_tensor(value, dtype=ref.dtype, device=ref.device),
self._containers,
)
def named_values(self) -> dict[str, float]:
"""Materialize and return current physical values as Python floats."""
return {k: float(v) for k, v in self.materialize().items()}
flatten(self)
¶
Return the latent vector as a flat 1-D tensor (detached).
Source code in torchcrop/calibration/manager.py
def flatten(self) -> torch.Tensor:
"""Return the latent vector as a flat 1-D tensor (detached)."""
return torch.stack(
[self._latents[k].detach().reshape(()) for k in self.latent_names()]
)
forward(self)
¶
Write all constrained values into the parameter containers.
Call once before each forward pass. Scalar targets overwrite their
dataclass field; table targets are batched per owning table and the
[N, 2] tensor is rebuilt once (non-in-place, so autograd is
preserved).
Returns:
| Type | Description |
|---|---|
dict[str, torch.Tensor] |
A mapping |
Source code in torchcrop/calibration/manager.py
def materialize(self) -> dict[str, torch.Tensor]:
"""Write all constrained values into the parameter containers.
Call once **before each forward pass**. Scalar targets overwrite their
dataclass field; table targets are batched per owning table and the
``[N, 2]`` tensor is rebuilt once (non-in-place, so autograd is
preserved).
Returns:
A mapping ``target name -> materialized value tensor`` for
inspection / logging.
"""
values = self._materialize_values()
table_updates: dict[tuple[str, str], dict[int, torch.Tensor]] = {}
for name, value in values.items():
target = self._targets[name]
if target.is_table:
table_updates.setdefault(target.table_key, {})[target.row] = value
else:
write_scalar(target, value, self._containers)
for table_key, updates in table_updates.items():
container, field = table_key
new_table = rebuild_table(self._table_originals[table_key], updates)
setattr(self._containers[container], field, new_table)
return values
latent_names(self)
¶
Stable, sorted order of latent target names.
Source code in torchcrop/calibration/manager.py
def latent_names(self) -> tuple[str, ...]:
"""Stable, sorted order of latent target names."""
return tuple(sorted(self._latents.keys()))
materialize(self)
¶
Write all constrained values into the parameter containers.
Call once before each forward pass. Scalar targets overwrite their
dataclass field; table targets are batched per owning table and the
[N, 2] tensor is rebuilt once (non-in-place, so autograd is
preserved).
Returns:
| Type | Description |
|---|---|
dict[str, torch.Tensor] |
A mapping |
Source code in torchcrop/calibration/manager.py
def materialize(self) -> dict[str, torch.Tensor]:
"""Write all constrained values into the parameter containers.
Call once **before each forward pass**. Scalar targets overwrite their
dataclass field; table targets are batched per owning table and the
``[N, 2]`` tensor is rebuilt once (non-in-place, so autograd is
preserved).
Returns:
A mapping ``target name -> materialized value tensor`` for
inspection / logging.
"""
values = self._materialize_values()
table_updates: dict[tuple[str, str], dict[int, torch.Tensor]] = {}
for name, value in values.items():
target = self._targets[name]
if target.is_table:
table_updates.setdefault(target.table_key, {})[target.row] = value
else:
write_scalar(target, value, self._containers)
for table_key, updates in table_updates.items():
container, field = table_key
new_table = rebuild_table(self._table_originals[table_key], updates)
setattr(self._containers[container], field, new_table)
return values
named_values(self)
¶
Materialize and return current physical values as Python floats.
Source code in torchcrop/calibration/manager.py
def named_values(self) -> dict[str, float]:
"""Materialize and return current physical values as Python floats."""
return {k: float(v) for k, v in self.materialize().items()}
set_categorical(self, name, value)
¶
Set a categorical target's current value (for an outer loop).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name |
str |
A spec name with |
required |
value |
float |
One of the spec's |
required |
Exceptions:
| Type | Description |
|---|---|
ValueError |
If |
Source code in torchcrop/calibration/manager.py
def set_categorical(self, name: str, value: float) -> None:
"""Set a categorical target's current value (for an outer loop).
Args:
name: A spec name with ``kind="categorical"``.
value: One of the spec's ``categories``.
Raises:
ValueError: If ``name`` is not categorical or ``value`` is not an
allowed category.
"""
spec = self._specs.get(name)
if spec is None or spec.kind != "categorical":
raise ValueError(f"{name!r} is not a categorical target")
if value not in spec.categories: # type: ignore[operator]
raise ValueError(
f"{value} not in categories {spec.categories} for {name!r}"
)
ref = self._ref_tensor(name)
write_scalar(
self._targets[name],
torch.as_tensor(value, dtype=ref.dtype, device=ref.device),
self._containers,
)
set_flat(self, flat)
¶
Overwrite the latent vector from a flat 1-D tensor.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
flat |
torch.Tensor |
1-D tensor matching |
required |
Exceptions:
| Type | Description |
|---|---|
ValueError |
If |
Source code in torchcrop/calibration/manager.py
@torch.no_grad()
def set_flat(self, flat: torch.Tensor) -> None:
"""Overwrite the latent vector from a flat 1-D tensor.
Args:
flat: 1-D tensor matching `flatten`'s length and order.
Raises:
ValueError: If ``flat`` has the wrong length.
"""
names = self.latent_names()
if flat.numel() != len(names):
raise ValueError(
f"expected {len(names)} latents, got {flat.numel()}"
)
for k, v in zip(names, flat.reshape(-1)):
self._latents[k].copy_(v.to(self._latents[k].dtype))