Skip to content
Merged
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
29 changes: 29 additions & 0 deletions src/maple/core/calibration/calibration_target_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
)
from maple.core.calibration.scenario import Scenario
from maple.core.calibration.shared_models import (
DistributionShape,
EstimateInput,
InputType,
ModelingAssumption,
Expand Down Expand Up @@ -339,6 +340,34 @@ def validate_observed_distribution_consistency(self) -> "CalibrationTargetEstima
)
return self

@model_validator(mode="after")
def validate_bounded_observable_uses_logit_normal(self) -> "CalibrationTargetEstimates":
"""A bounded observable's population spread (``moments`` form) must use
``shape: logit_normal``, not normal/lognormal.

Parity with the SubmodelTarget validator of the same name: for a
fraction / proportion / probability / percent observable, ``normal`` puts
mass outside the bound and ``lognormal`` is unbounded above; ``logit_normal``
keeps expanded quartiles in (0, 1). Only applies to the ``moments`` form —
the ``quantiles`` form carries the empirical shape directly and is exempt.
"""
od = self.observed_distribution
if od is None or od.moments is None:
return self
if od.moments.shape == DistributionShape.LOGIT_NORMAL:
return self
BOUNDED_UNITS = {"percent", "%", "fraction", "proportion", "probability"}
if (self.units or "").strip().lower() not in BOUNDED_UNITS:
return self
raise ValueError(
f"Observable units='{self.units}' are a bounded fraction/percentage, but "
f"observed_distribution.moments uses shape='{od.moments.shape.value}'. "
"Bounded observables must use shape='logit_normal', which expands quartiles "
"in logit space so they never escape (0, 1); normal puts mass outside the "
"bound and lognormal is unbounded above. logit_normal requires center in "
"(0, 1) with center_type='median' — express a percent as a fraction (12% -> 0.12)."
)

@field_validator("sample_size")
@classmethod
def validate_sample_size_positive(cls, v: int) -> int:
Expand Down
2 changes: 1 addition & 1 deletion src/maple/prompts/calibration_target_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ Use the `quantiles` form when the paper gives quartiles/percentiles/samples dire
experimental_unit_type: biological
```

Provide EXACTLY ONE of `moments` / `quantiles`. A population spread (`spread_source: across_patient`) REQUIRES `n_biological` + `experimental_unit_type: biological`. When both `observed_distribution` and `population_spread` are present they must agree that the width is (or is not) genuine spread — a validator enforces this. For a `[0,1]`-bounded observable (a fraction / proportion / response rate), use `shape: logit_normal` (with `center_type: median`) so expanded quartiles stay in-bounds. If a target ever carries several observables measured on the SAME patients/units and you want them treated as one shared spread, tag them with a common `unit_group` string (rare for full-model calibration targets, which are usually one observable each — omit it otherwise).
Provide EXACTLY ONE of `moments` / `quantiles`. A population spread (`spread_source: across_patient`) REQUIRES `n_biological` + `experimental_unit_type: biological`. When both `observed_distribution` and `population_spread` are present they must agree that the width is (or is not) genuine spread — a validator enforces this. For a `[0,1]`-bounded observable (a fraction / proportion / response rate), use `shape: logit_normal` (with `center_type: median`) so expanded quartiles stay in-bounds — **a validator rejects a `percent`/`fraction`/`proportion`/`probability` observable whose `moments` shape is `normal` or `lognormal`.** If the source reports the unit count as a LOWER BOUND ("n≥40", "at least 40 patients"), set `n_biological_is_floor: true` (with the floor value for `n_biological`) so precision-weighting consumers don't over-weight the panel. If a target ever carries several observables measured on the SAME patients/units and you want them treated as one shared spread, tag them with a common `unit_group` string (rare for full-model calibration targets, which are usually one observable each — omit it otherwise).

### Source Relevance Assessment

Expand Down
56 changes: 56 additions & 0 deletions tests/unit/core/test_calibration_target_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -1671,3 +1671,59 @@ def test_degenerate_samples_rejected(
)
with pytest.raises(ValidationError, match="zero variance"):
CalibrationTarget.model_validate(data, context={"model_structure": model_structure})


# ============================================================================
# Cal-side parity for the submodel bounded->logit_normal validator: a bounded
# observable's moments-form observed_distribution must use shape=logit_normal.
# ============================================================================

from maple.core.calibration.calibration_target_models import CalibrationTargetEstimates


def _bounded_cal_estimates(shape: str) -> dict:
return {
"median": [0.5],
"ci95": [[0.3, 0.7]],
"units": "percent",
"sample_size": 40,
"sample_size_rationale": "n=40 patients, Table 1",
"inputs": [
{
"name": "resp_fraction",
"value": 0.5,
"units": "percent",
"description": "objective response fraction",
"source_ref": "smith_2020",
"value_location": "Table 1",
"value_snippet": "response rate 0.5",
}
],
"distribution_code": (
"def derive_distribution(inputs, ureg):\n"
" v = inputs['resp_fraction']\n"
" return {'median_obs': v, 'ci95_lower': v * 0.6, 'ci95_upper': v * 1.4}"
),
"population_spread": "center_only",
"observed_distribution": {
"moments": {
"center": 0.5,
"center_type": "median",
"scale": 0.1,
"scale_type": "sd",
"shape": shape,
},
"spread_source": "center_only",
},
}


class TestCalBoundedObservableLogitNormal:
"""CalibrationTargetEstimates: bounded moments-form observable must use logit_normal."""

def test_percent_with_normal_shape_raises(self):
with pytest.raises(ValidationError, match="logit_normal"):
CalibrationTargetEstimates.model_validate(_bounded_cal_estimates("normal"))

def test_percent_with_logit_normal_passes(self):
CalibrationTargetEstimates.model_validate(_bounded_cal_estimates("logit_normal"))
Loading