Files
CA/backend/routers/statistics.py
Akiba So fe8bed58f5 feat: remove cost/费用 statistics from clinical analytics
Per request — drop all monetary statistics (住院费用 is sensitive).
- Backend statistics.py: remove mean_cost KPI + cost_histogram / cost_by_disease
  / cost_vs_los from /inpatient-clinical (models, computation, response)
- Frontend: drop 人均费用 KPI card (now 4 KPIs), 住院费用分布, 各病种平均费用,
  费用×住院天数散点; delete CostByDiseaseChart + CostVsLosScatter components;
  trim statsApi type + e2e fixture + chartColors

Clinical page now: KPI(总人次/中位住院日/治愈好转率/急诊占比) + LOS dist + LOS-by-disease
box + outcome donut + admission-route donut + age-band BMI box.

Gates: backend 106 pytest · tsc 0 · build ok · clinical+user-flows e2e 19/19 ·
live endpoint confirmed cost-free

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:50:30 +08:00

567 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
统计分析 API 路由 (prefix /api/stats)
为前端统计仪表盘提供聚合后的临床、症状、发病率、环境相关性和时序数据。
所有数据从 processed/*.parquet 计算得出(文件型后端,无数据库)。
设计原则:
- 模块级缓存载入的 parquet与其他路由一致
- 仅返回聚合结果,绝不直接 dump 原始行,保持 payload 小。
- 每个端点用 try/except 包裹,失败时返回合法的空结构(绝不让 UI 收到 500
- pandas 计算放入线程池 (asyncio.to_thread),避免阻塞事件循环。
"""
import asyncio
import glob
import logging
import threading
from pathlib import Path
from typing import Optional, cast
import numpy as np
import pandas as pd
from fastapi import APIRouter, Query
from pydantic import BaseModel
from data.case_loader import (
get_inpatient_data,
get_outpatient_data,
get_combined_data,
load_cases_by_district_daily,
normalize_district,
)
logger = logging.getLogger("cbpoa.statistics")
router = APIRouter(prefix="/api/stats", tags=["statistics"])
PROJECT_ROOT = Path(__file__).parent.parent.parent
PROCESSED_DIR = PROJECT_ROOT / "processed"
# ============== Module-level caches ==============
_cache: dict[str, object] = {}
_cache_lock = threading.RLock()
# 7 个污染物(与 feature snapshot 列名一致)
POLLUTANTS = ["AQI", "PM25", "PM10", "SO2", "NO2", "O3", "CO"]
# 主诉症状关键词(固定列表,子串匹配)。注意顺序:更具体的在前避免被宽泛词吞掉,
# 但因为是独立子串计数,顺序不影响结果,仅为可读性分组。
SYMPTOM_KEYWORDS = [
"发热", "咳嗽", "", "喘息", "", "流涕", "鼻塞", "咽痛", "咽喉",
"", "气促", "呼吸困难", "肺炎", "复诊", "随诊", "复查",
"腹泻", "呕吐", "头痛", "乏力", "胸闷", "鼻涕", "发烧", "感冒",
]
REVISIT_KEYWORDS = ["复诊", "随诊", "复查"]
def _district_population() -> pd.Series:
"""各区人口population_density 求和,按 grid_district_mapping 归属)。
返回 index 为规范化区名(13)、值为人口的 Series。结果缓存。
"""
cached = _cache.get("district_population")
if cached is not None:
return cast(pd.Series, cached)
with _cache_lock:
cached = _cache.get("district_population")
if cached is not None:
return cast(pd.Series, cached)
mapping = pd.read_parquet(PROCESSED_DIR / "grid_district_mapping.parquet")
grid = pd.read_parquet(
PROCESSED_DIR / "grid_100m_with_dem_pop.parquet",
columns=["grid_id", "population_density"],
)
joined = mapping.merge(grid, on="grid_id", how="inner")
joined = joined.dropna(subset=["district_name"])
joined["district_name"] = joined["district_name"].map(normalize_district)
pop = joined.groupby("district_name")["population_density"].sum()
_cache["district_population"] = pop
return pop
def _feature_snapshots() -> pd.DataFrame:
"""合并所有可用的 features_*.parquet 快照(缓存)。
用于污染物 vs 病例的相关性分析。每个快照按格点给出污染物 + 病例计数 + 区。
"""
cached = _cache.get("features")
if cached is not None:
return cast(pd.DataFrame, cached)
with _cache_lock:
cached = _cache.get("features")
if cached is not None:
return cast(pd.DataFrame, cached)
paths = sorted(glob.glob(str(PROCESSED_DIR / "features_*.parquet")))
if not paths:
df = pd.DataFrame(
columns=POLLUTANTS + ["outpatient_count", "inpatient_count", "total_cases", "district"]
)
else:
frames = [pd.read_parquet(p) for p in paths]
df = pd.concat(frames, ignore_index=True)
_cache["features"] = df
return df
# ============== Response Models ==============
class KeyValueCount(BaseModel):
bin_label: str
count: int
class InpatientKpis(BaseModel):
total_admissions: int
median_los_days: float
cure_rate: float
emergency_admit_ratio: float
class LosByDisease(BaseModel):
diagnosis: str
p25: float
median: float
p75: float
n: int
class LabelCount(BaseModel):
outcome: Optional[str] = None
route: Optional[str] = None
count: int
class OutcomeCount(BaseModel):
outcome: str
count: int
class RouteCount(BaseModel):
route: str
count: int
class BmiByAge(BaseModel):
age_band: str
p25: float
median: float
p75: float
n: int
class InpatientClinicalResponse(BaseModel):
kpis: InpatientKpis
los_histogram: list[KeyValueCount]
los_by_disease: list[LosByDisease]
outcome_counts: list[OutcomeCount]
admission_route_counts: list[RouteCount]
bmi_by_age_band: list[BmiByAge]
class SymptomItem(BaseModel):
keyword: str
count: int
class SymptomsResponse(BaseModel):
symptoms: list[SymptomItem]
revisit_ratio: float
class IncidenceItem(BaseModel):
district: str
total_cases: int
population: float
rate_per_10k: float
class IncidenceResponse(BaseModel):
districts: list[IncidenceItem]
class CorrItem(BaseModel):
pollutant: str
corr_with_cases: float
class ScatterPoint(BaseModel):
pm25: float
aqi: float
cases: float
class PairwiseCorr(BaseModel):
a: str
b: str
corr: float
class EnvCorrelationResponse(BaseModel):
correlation_matrix: list[CorrItem]
scatter: list[ScatterPoint]
pollutant_pairwise: list[PairwiseCorr]
class WeekdayPoint(BaseModel):
weekday: str
outpatient: int
inpatient: int
total: int
class MonthYearPoint(BaseModel):
year: int
month: int
total: int
class YoYPoint(BaseModel):
period: str
current: int
previous: int
growth_pct: float
class TemporalResponse(BaseModel):
weekday: list[WeekdayPoint]
month_year: list[MonthYearPoint]
yoy: list[YoYPoint]
# ============== Helpers ==============
def _empty_inpatient_clinical() -> InpatientClinicalResponse:
return InpatientClinicalResponse(
kpis=InpatientKpis(
total_admissions=0, median_los_days=0.0,
cure_rate=0.0, emergency_admit_ratio=0.0,
),
los_histogram=[], los_by_disease=[], outcome_counts=[],
admission_route_counts=[], bmi_by_age_band=[],
)
def _safe_float(v) -> float:
try:
f = float(v)
if np.isnan(f) or np.isinf(f):
return 0.0
return round(f, 4)
except (TypeError, ValueError):
return 0.0
# ============== Endpoint 1: inpatient clinical ==============
def _compute_inpatient_clinical() -> InpatientClinicalResponse:
df = get_inpatient_data().copy()
if df.empty:
return _empty_inpatient_clinical()
# LOS = (出院日期 - 入院日期).days, valid 0-60
in_date = pd.to_datetime(df["入院日期"], errors="coerce")
out_date = pd.to_datetime(df["出院日期"], errors="coerce")
df["los"] = (out_date - in_date).dt.days
df_los = df[(df["los"] >= 0) & (df["los"] <= 60)]
total = len(df)
median_los = float(df_los["los"].median()) if len(df_los) else 0.0
outcome = df["出院情况"].fillna("未知")
cure_n = int(outcome.isin(["治愈", "好转"]).sum())
cure_rate = cure_n / total if total else 0.0
route = df["入院途径"].fillna("未知")
emerg_n = int((route == "急诊").sum())
emerg_ratio = emerg_n / total if total else 0.0
kpis = InpatientKpis(
total_admissions=total,
median_los_days=round(median_los, 2),
cure_rate=round(cure_rate, 4),
emergency_admit_ratio=round(emerg_ratio, 4),
)
# LOS histogram bins: 0,1,2,3,4,5,6,7,8-14,15+
los_histogram: list[KeyValueCount] = []
los_vals = df_los["los"]
for b in range(0, 8):
los_histogram.append(KeyValueCount(bin_label=str(b), count=int((los_vals == b).sum())))
los_histogram.append(KeyValueCount(bin_label="8-14", count=int(((los_vals >= 8) & (los_vals <= 14)).sum())))
los_histogram.append(KeyValueCount(bin_label="15+", count=int((los_vals >= 15).sum())))
# LOS by disease (top 8 diagnoses by n)
los_by_disease: list[LosByDisease] = []
if len(df_los):
top_diag = df_los["诊断名称"].value_counts().head(8).index.tolist()
for d in top_diag:
grp = df_los[df_los["诊断名称"] == d]["los"]
los_by_disease.append(LosByDisease(
diagnosis=str(d),
p25=round(float(grp.quantile(0.25)), 2),
median=round(float(grp.median()), 2),
p75=round(float(grp.quantile(0.75)), 2),
n=int(len(grp)),
))
# outcome counts
outcome_counts = [
OutcomeCount(outcome=str(k), count=int(v))
for k, v in outcome.value_counts().items()
]
# admission route counts
admission_route_counts = [
RouteCount(route=str(k), count=int(v))
for k, v in route.value_counts().items()
]
# BMI by age band. BMI = 体重kg / (身高m)^2; plausible 8-40.
bmi_by_age_band: list[BmiByAge] = []
h = pd.to_numeric(df["身高"], errors="coerce") # cm
w = pd.to_numeric(df["体重"], errors="coerce") # kg
age = pd.to_numeric(df["年龄"], errors="coerce")
bmi = w / ((h / 100.0) ** 2)
bmi_df = pd.DataFrame({"age": age, "bmi": bmi})
bmi_df = bmi_df[(bmi_df["bmi"] >= 8) & (bmi_df["bmi"] <= 40) & bmi_df["age"].notna()]
age_bands = [(0, 3, "0-2"), (3, 6, "3-5"), (6, 9, "6-8"),
(9, 12, "9-11"), (12, 15, "12-14"), (15, 19, "15-18")]
for lo, hi, label in age_bands:
grp = bmi_df[(bmi_df["age"] >= lo) & (bmi_df["age"] < hi)]["bmi"]
if len(grp) == 0:
continue
bmi_by_age_band.append(BmiByAge(
age_band=label,
p25=round(float(grp.quantile(0.25)), 2),
median=round(float(grp.median()), 2),
p75=round(float(grp.quantile(0.75)), 2),
n=int(len(grp)),
))
return InpatientClinicalResponse(
kpis=kpis,
los_histogram=los_histogram,
los_by_disease=los_by_disease,
outcome_counts=outcome_counts,
admission_route_counts=admission_route_counts,
bmi_by_age_band=bmi_by_age_band,
)
@router.get("/inpatient-clinical", response_model=InpatientClinicalResponse, summary="住院临床统计")
async def inpatient_clinical():
"""住院临床概览KPI、住院天数(LOS)分布、费用分布、转归、入院途径、BMI 分布。"""
try:
return await asyncio.to_thread(_compute_inpatient_clinical)
except Exception:
logger.exception("inpatient-clinical failed")
return _empty_inpatient_clinical()
# ============== Endpoint 2: symptoms ==============
def _compute_symptoms(top: int) -> SymptomsResponse:
df = get_outpatient_data()
if df.empty or "主诉" not in df.columns:
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
chief = df["主诉"].dropna().astype(str)
total = len(chief)
if total == 0:
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
counts: list[SymptomItem] = []
for kw in SYMPTOM_KEYWORDS:
c = int(chief.str.contains(kw, regex=False).sum())
if c > 0:
counts.append(SymptomItem(keyword=kw, count=c))
counts.sort(key=lambda x: x.count, reverse=True)
counts = counts[:top]
revisit_mask = chief.str.contains("|".join(REVISIT_KEYWORDS), regex=True)
revisit_ratio = float(revisit_mask.sum()) / total if total else 0.0
return SymptomsResponse(symptoms=counts, revisit_ratio=round(revisit_ratio, 4))
@router.get("/symptoms", response_model=SymptomsResponse, summary="门诊主诉症状词频")
async def symptoms(top: int = Query(20, ge=1, le=50, description="返回前 N 个症状词")):
"""从门诊主诉中提取固定症状关键词的出现频次,并计算复诊比例。"""
try:
return await asyncio.to_thread(_compute_symptoms, top)
except Exception:
logger.exception("symptoms failed")
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
# ============== Endpoint 3: incidence rate ==============
def _compute_incidence() -> IncidenceResponse:
daily = load_cases_by_district_daily()
if daily.empty:
return IncidenceResponse(districts=[])
case_totals = daily.groupby("district")["total_cases"].sum()
pop = _district_population()
items: list[IncidenceItem] = []
for district in case_totals.index:
total_cases = int(case_totals.get(district, 0))
population = float(pop.get(district, 0.0))
rate = (total_cases / population * 10000) if population > 0 else 0.0
items.append(IncidenceItem(
district=str(district),
total_cases=total_cases,
population=round(population, 1),
rate_per_10k=round(rate, 2),
))
items.sort(key=lambda x: x.rate_per_10k, reverse=True)
return IncidenceResponse(districts=items)
@router.get("/incidence-rate", response_model=IncidenceResponse, summary="各区发病率")
async def incidence_rate():
"""各区病例总数 / 区人口 * 10000得到每万人发病率13 区)。"""
try:
return await asyncio.to_thread(_compute_incidence)
except Exception:
logger.exception("incidence-rate failed")
return IncidenceResponse(districts=[])
# ============== Endpoint 4: env correlation ==============
def _compute_env_correlation() -> EnvCorrelationResponse:
df = _feature_snapshots()
if df.empty or "total_cases" not in df.columns:
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
# 污染物 vs 病例 的 Pearson 相关(按格点,汇集所有快照)
correlation_matrix: list[CorrItem] = []
cases = pd.to_numeric(df["total_cases"], errors="coerce")
for p in POLLUTANTS:
if p not in df.columns:
continue
series = pd.to_numeric(df[p], errors="coerce")
valid = series.notna() & cases.notna()
if valid.sum() < 2 or series[valid].std() == 0 or cases[valid].std() == 0:
corr = 0.0
else:
corr = float(series[valid].corr(cases[valid]))
correlation_matrix.append(CorrItem(pollutant=p, corr_with_cases=_safe_float(corr)))
# scatter: 采样 cases>0 的格点up to 500
scatter: list[ScatterPoint] = []
has_cols = all(c in df.columns for c in ["PM25", "AQI", "total_cases"])
if has_cols:
sdf = df[["PM25", "AQI", "total_cases"]].copy()
sdf = sdf[pd.to_numeric(sdf["total_cases"], errors="coerce") > 0].dropna()
if len(sdf) > 500:
sdf = sdf.sample(n=500, random_state=42)
for _, r in sdf.iterrows():
scatter.append(ScatterPoint(
pm25=_safe_float(r["PM25"]),
aqi=_safe_float(r["AQI"]),
cases=_safe_float(r["total_cases"]),
))
# pollutant pairwise (upper triangle)
pollutant_pairwise: list[PairwiseCorr] = []
present = [p for p in POLLUTANTS if p in df.columns]
pol_df = df[present].apply(pd.to_numeric, errors="coerce")
corr_mat = pol_df.corr()
for i, a in enumerate(present):
for b in present[i + 1:]:
try:
v = corr_mat.loc[a, b]
except KeyError:
v = 0.0
pollutant_pairwise.append(PairwiseCorr(a=a, b=b, corr=_safe_float(v)))
return EnvCorrelationResponse(
correlation_matrix=correlation_matrix,
scatter=scatter,
pollutant_pairwise=pollutant_pairwise,
)
@router.get("/env-correlation", response_model=EnvCorrelationResponse, summary="环境-病例相关性")
async def env_correlation():
"""污染物与病例的相关矩阵、PM2.5/AQI 散点、污染物两两相关(热力图)。"""
try:
return await asyncio.to_thread(_compute_env_correlation)
except Exception:
logger.exception("env-correlation failed")
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
# ============== Endpoint 5: temporal ==============
_WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def _compute_temporal() -> TemporalResponse:
df = get_combined_data().copy()
if df.empty:
return TemporalResponse(weekday=[], month_year=[], yoy=[])
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df = df[df["date"].notna()]
if df.empty:
return TemporalResponse(weekday=[], month_year=[], yoy=[])
# weekday (0=周一..6=周日)
df["wd"] = df["date"].dt.weekday
weekday: list[WeekdayPoint] = []
for wd in range(7):
sub = df[df["wd"] == wd]
out = int((sub["type"] == "outpatient").sum())
inp = int((sub["type"] == "inpatient").sum())
weekday.append(WeekdayPoint(
weekday=_WEEKDAY_LABELS[wd], outpatient=out, inpatient=inp, total=out + inp,
))
# month_year (seasonality grid)
df["year"] = df["date"].dt.year
df["month"] = df["date"].dt.month
my = df.groupby(["year", "month"]).size()
month_year = [
MonthYearPoint(year=int(y), month=int(m), total=int(c))
for (y, m), c in my.items()
]
month_year.sort(key=lambda x: (x.year, x.month))
# yoy: monthly current vs same-month-prior-year (only if multiple years exist)
yoy: list[YoYPoint] = []
years = sorted(df["year"].unique().tolist())
if len(years) > 1:
monthly_totals = {(int(y), int(m)): int(c) for (y, m), c in my.items()}
for (y, m), cur in sorted(monthly_totals.items()):
prev = monthly_totals.get((y - 1, m))
if prev is None:
continue
growth = ((cur - prev) / prev * 100) if prev else 0.0
yoy.append(YoYPoint(
period=f"{y}-{m:02d}",
current=cur,
previous=prev,
growth_pct=round(growth, 2),
))
return TemporalResponse(weekday=weekday, month_year=month_year, yoy=yoy)
@router.get("/temporal", response_model=TemporalResponse, summary="时序统计")
async def temporal():
"""按星期、年-月(季节性网格)聚合,以及同比(YoY)增长(若有多年数据)。"""
try:
return await asyncio.to_thread(_compute_temporal)
except Exception:
logger.exception("temporal failed")
return TemporalResponse(weekday=[], month_year=[], yoy=[])