Observations ============== ``calibrate`` and ``finetune`` accept a plain array of observations, which asserts that every value was measured exactly. Real measurement records routinely are not: * a concentration below an instrument's detection limit is known only to be *somewhere below* it; * a specimen that survived the whole test is known only to have failed *after* the test ended; * a reading logged to the nearest bin is known only to lie *within* it; * a failed sensor produced no information at all. Substituting a number for any of those — the limit, a half-limit, a bin midpoint, an interpolated fill — changes the likelihood the fit maximizes, and so changes the fitted parameters. :class:`~prophys.domain.Observations` carries what the record actually says. .. list-table:: :header-rows: 1 :widths: 30 30 40 * - Kind - Contributes - Constructor * - exact - :math:`\log f(x)` - :meth:`~prophys.domain.Observations.exact` * - left-censored - :math:`\log F(\text{limit})` - :meth:`~prophys.domain.Observations.detection_limit` * - right-censored - :math:`\log(1 - F(\text{limit}))` - :meth:`~prophys.domain.Observations.right_censored` * - interval - :math:`\log(F(\text{upper}) - F(\text{lower}))` - :meth:`~prophys.domain.Observations.interval` * - missing - :math:`0` - ``from_arrays(..., missing=mask)`` Pass an ``Observations`` anywhere an observation array was accepted; the engine dispatches on the type. Non-detects ------------ .. code-block:: python import prophys as prp obs = prp.Observations.detection_limit(concentrations, limit=0.5) result = prp.finetune(compiled, {"c": obs}, n_steps=3000) Values at or below the limit become left-censored at it; the rest stay exact. Compared with substituting the limit for every non-detect, this recovers the generating parameters rather than pulling the mean up and the scale down. Survival and reliability ------------------------- .. code-block:: python obs = prp.Observations.right_censored(recorded, censored=still_running) Specimens still unfailed when the test ended are known only to exceed their recorded time. Ignoring the censoring treats every truncated value as a failure, which understates the lifetime and overstates the hazard rate. Binned readings ---------------- .. code-block:: python obs = prp.Observations.interval(lower_edges, lower_edges + width) Every observation known only to lie between two bounds — readings rounded or binned at recording time. Missing entries ---------------- .. code-block:: python obs = prp.Observations.from_arrays(values, missing=np.isnan(values)) Marked records contribute nothing, which gives the same fit as omitting them while keeping the record's length and row order intact. Values at those positions may be ``nan`` in the caller's array; they are replaced by a finite placeholder, so a ``nan`` cannot reach a gradient through an unselected branch. Downstream effects ------------------- An ``Observations`` record changes more than the fit: * :func:`~prophys.engine.information_criteria` counts only informative observations, so missing records do not inflate the sample-size penalty; * :func:`~prophys.engine.parameter_uncertainty` reports the same count; * :func:`~prophys.engine.calibration` and :func:`~prophys.engine.crps` use only the exactly-observed values, since a censored record carries no PIT value; * :class:`~prophys.export.RunManifest` records the counts by kind, so an exported result says how much of its data was censored. :func:`~prophys.io.read_observations` builds one straight from a file — see :doc:`data`. .. autoclass:: prophys.domain.Observations :members: .. autofunction:: prophys.domain.as_observations