Download src/service_analysis/decomposition.py from FlyingNunchucks/14-data-analysis-agent: direct link, hf CLI and curl.
- Browser
- Download file 7.47 kB
-
https://huggingface.co/spaces/FlyingNunchucks/14-data-analysis-agent/resolve/main/src/service_analysis/decomposition.py
- Command line
-
hf download hf://spaces/FlyingNunchucks/14-data-analysis-agent/src/service_analysis/decomposition.py
-
curl -L -o decomposition.py https://huggingface.co/spaces/FlyingNunchucks/14-data-analysis-agent/resolve/main/src/service_analysis/decomposition.py
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" | |
| 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))) | |