FlyingNunchucks's picture
Deploy tested GitHub commit 0080b137632d1d201ef3a1f6f7eebcc53777d7f0
445125c verified
Raw History Blame Contribute Delete
7.47 kB
"""Symmetric decomposition of observed mean-resolution changes.
This is arithmetic attribution, not a causal estimate.
"""
from math import fsum, isclose
from typing import Literal
from pydantic import Field, model_validator
from service_analysis.analysis import (
ABS_TOL, REL_TOL, CalculationMismatch, ComparisonResult, Period,
ResultContract, _identity, compare_periods,
)
from service_analysis.quality import PreparedDataset
METHOD = "symmetric-case-mix-v1"
class CategoryContribution(ResultContract):
category: Literal["Routine", "Standard", "Complex"]
baseline_count: int = Field(ge=0)
comparison_count: int = Field(ge=0)
baseline_share: float | None = Field(ge=0, le=1)
comparison_share: float | None = Field(ge=0, le=1)
baseline_mean_hours: float | None = Field(ge=0)
comparison_mean_hours: float | None = Field(ge=0)
mix_contribution_hours: float | None
within_category_contribution_hours: float | None
class DecompositionResult(ResultContract):
result_id: str
dataset_id: str
method: Literal["symmetric-case-mix-v1"] = METHOD
overall: ComparisonResult
categories: ComparisonResult | None
rows: tuple[CategoryContribution, ...]
observed_mean_change_hours: float | None
mix_contribution_hours: float | None
within_category_contribution_hours: float | None
reconciliation_residual_hours: float | None
status: Literal["complete", "incomplete"]
warnings: tuple[str, ...]
units: Literal["elapsed_calendar_hours"] = "elapsed_calendar_hours"
@model_validator(mode="after")
def publication_boundary(self):
totals = (self.mix_contribution_hours, self.within_category_contribution_hours,
self.reconciliation_residual_hours)
if self.status == "incomplete":
if any(value is not None for value in totals) or any(
row.mix_contribution_hours is not None or row.within_category_contribution_hours is not None
for row in self.rows
):
raise ValueError("Incomplete decomposition cannot publish contributions")
else:
if self.categories is None or not self.rows or self.observed_mean_change_hours is None:
raise ValueError("Complete decomposition requires category evidence and an observed change")
if any(value is None for value in totals) or any(
row.mix_contribution_hours is None or row.within_category_contribution_hours is None
for row in self.rows
):
raise ValueError("Complete decomposition requires all contributions")
if not isclose(self.mix_contribution_hours + self.within_category_contribution_hours,
self.observed_mean_change_hours, rel_tol=REL_TOL, abs_tol=ABS_TOL):
raise ValueError("Contributions do not reconcile")
if self.categories.status != "complete" or self.overall.status != "complete":
raise ValueError("Complete attribution requires complete period evidence")
if not isclose(fsum(row.mix_contribution_hours for row in self.rows),
self.mix_contribution_hours, rel_tol=REL_TOL, abs_tol=ABS_TOL) or not isclose(
fsum(row.within_category_contribution_hours for row in self.rows),
self.within_category_contribution_hours, rel_tol=REL_TOL, abs_tol=ABS_TOL
):
raise ValueError("Category contributions do not reconcile to totals")
evidence_change = self.overall.rows[0].mean_change_hours
if evidence_change is None or not isclose(evidence_change, self.observed_mean_change_hours,
rel_tol=REL_TOL, abs_tol=ABS_TOL):
raise ValueError("Observed change does not match overall evidence")
return self
def decompose_case_mix(dataset: PreparedDataset, baseline: Period,
comparison: Period) -> DecompositionResult:
"""Decompose only when all observations are classified and groups overlap.
Missing categories, empty periods, or excluded classifications retain the
observed overall change but withhold every attribution term.
"""
overall = compare_periods(dataset, baseline, comparison)
categories = compare_periods(dataset, baseline, comparison, "category") if dataset.profile.category_ready else None
warnings = list(overall.warnings) + ["ARITHMETIC_ATTRIBUTION_NOT_CAUSATION", "MEAN_ONLY"]
if categories is not None:
warnings.extend(categories.warnings)
first = {row.group: row for row in categories.baseline.rows}
second = {row.group: row for row in categories.comparison.rows}
else:
first, second = {}, {}
warnings.append("NO_CATEGORY_EVIDENCE")
n0 = overall.baseline.population_size
n1 = overall.comparison.population_size
groups = sorted(first.keys() | second.keys())
incomplete = not n0 or not n1 or not groups
if not n0 or not n1:
warnings.append("EMPTY_PERIOD_DECOMPOSITION_UNAVAILABLE")
if categories is not None and (
categories.baseline.excluded_classification_count or
categories.comparison.excluded_classification_count
):
incomplete = True
warnings.append("UNCLASSIFIED_REQUESTS_DECOMPOSITION_UNAVAILABLE")
if set(first) != set(second):
incomplete = True
warnings.append("CATEGORY_SUPPORT_CHANGED")
rows = []
for group in groups:
a, b = first.get(group), second.get(group)
count0, count1 = (a.sample_size if a else 0), (b.sample_size if b else 0)
p0, p1 = (count0 / n0 if n0 else None), (count1 / n1 if n1 else None)
m0, m1 = (a.mean_hours if a else None), (b.mean_hours if b else None)
mix = within = None
if not incomplete:
mix = (p1 - p0) * (m1 + m0) / 2
within = (m1 - m0) * (p1 + p0) / 2
rows.append(CategoryContribution(category=group, baseline_count=count0,
comparison_count=count1, baseline_share=p0, comparison_share=p1,
baseline_mean_hours=m0, comparison_mean_hours=m1,
mix_contribution_hours=mix, within_category_contribution_hours=within))
observed = overall.rows[0].mean_change_hours
mix_total = within_total = residual = None
if not incomplete:
mix_total = fsum(row.mix_contribution_hours for row in rows)
within_total = fsum(row.within_category_contribution_hours for row in rows)
reconstructed = mix_total + within_total
residual = reconstructed - observed
if not isclose(reconstructed, observed, rel_tol=REL_TOL, abs_tol=ABS_TOL):
raise CalculationMismatch("Case-mix attribution does not reconcile to observed mean change")
identity = _identity("decomposition-", {
"method": METHOD, "overall": overall.result_id,
"categories": categories.result_id if categories is not None else None,
})
return DecompositionResult(result_id=identity, dataset_id=overall.dataset_id,
overall=overall, categories=categories, rows=tuple(rows),
observed_mean_change_hours=observed, mix_contribution_hours=mix_total,
within_category_contribution_hours=within_total,
reconciliation_residual_hours=residual,
status="incomplete" if incomplete else "complete",
warnings=tuple(dict.fromkeys(warnings)))