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>
This commit is contained in:
2026-06-21 21:50:30 +08:00
parent 4df6c71628
commit fe8bed58f5
9 changed files with 8 additions and 222 deletions

View File

@@ -116,7 +116,6 @@ class KeyValueCount(BaseModel):
class InpatientKpis(BaseModel):
total_admissions: int
median_los_days: float
mean_cost: float
cure_rate: float
emergency_admit_ratio: float
@@ -129,17 +128,6 @@ class LosByDisease(BaseModel):
n: int
class CostByDisease(BaseModel):
diagnosis: str
mean_cost: float
n: int
class CostVsLos(BaseModel):
los: int
cost: float
class LabelCount(BaseModel):
outcome: Optional[str] = None
route: Optional[str] = None
@@ -168,9 +156,6 @@ class InpatientClinicalResponse(BaseModel):
kpis: InpatientKpis
los_histogram: list[KeyValueCount]
los_by_disease: list[LosByDisease]
cost_histogram: list[KeyValueCount]
cost_by_disease: list[CostByDisease]
cost_vs_los: list[CostVsLos]
outcome_counts: list[OutcomeCount]
admission_route_counts: list[RouteCount]
bmi_by_age_band: list[BmiByAge]
@@ -252,11 +237,10 @@ class TemporalResponse(BaseModel):
def _empty_inpatient_clinical() -> InpatientClinicalResponse:
return InpatientClinicalResponse(
kpis=InpatientKpis(
total_admissions=0, median_los_days=0.0, mean_cost=0.0,
total_admissions=0, median_los_days=0.0,
cure_rate=0.0, emergency_admit_ratio=0.0,
),
los_histogram=[], los_by_disease=[], cost_histogram=[],
cost_by_disease=[], cost_vs_los=[], outcome_counts=[],
los_histogram=[], los_by_disease=[], outcome_counts=[],
admission_route_counts=[], bmi_by_age_band=[],
)
@@ -287,8 +271,6 @@ def _compute_inpatient_clinical() -> InpatientClinicalResponse:
total = len(df)
median_los = float(df_los["los"].median()) if len(df_los) else 0.0
cost = pd.to_numeric(df["住院总费用"], errors="coerce")
mean_cost = float(cost.mean()) if cost.notna().any() else 0.0
outcome = df["出院情况"].fillna("未知")
cure_n = int(outcome.isin(["治愈", "好转"]).sum())
@@ -301,7 +283,6 @@ def _compute_inpatient_clinical() -> InpatientClinicalResponse:
kpis = InpatientKpis(
total_admissions=total,
median_los_days=round(median_los, 2),
mean_cost=round(mean_cost, 2),
cure_rate=round(cure_rate, 4),
emergency_admit_ratio=round(emerg_ratio, 4),
)
@@ -328,39 +309,6 @@ def _compute_inpatient_clinical() -> InpatientClinicalResponse:
n=int(len(grp)),
))
# Cost histogram: 0-2k,2-4k,4-6k,6-8k,8-10k,10k+
cost_valid = cost.dropna()
cost_bins = [(0, 2000, "0-2k"), (2000, 4000, "2-4k"), (4000, 6000, "4-6k"),
(6000, 8000, "6-8k"), (8000, 10000, "8-10k")]
cost_histogram: list[KeyValueCount] = []
for lo, hi, label in cost_bins:
cost_histogram.append(KeyValueCount(
bin_label=label, count=int(((cost_valid >= lo) & (cost_valid < hi)).sum())))
cost_histogram.append(KeyValueCount(bin_label="10k+", count=int((cost_valid >= 10000).sum())))
# Cost by disease (top 8 by n)
cost_by_disease: list[CostByDisease] = []
df_cost = df[cost.notna()].copy()
df_cost["_cost"] = cost[cost.notna()]
if len(df_cost):
top_cd = df_cost["诊断名称"].value_counts().head(8).index.tolist()
for d in top_cd:
grp = df_cost[df_cost["诊断名称"] == d]["_cost"]
cost_by_disease.append(CostByDisease(
diagnosis=str(d),
mean_cost=round(float(grp.mean()), 2),
n=int(len(grp)),
))
# cost vs los scatter (up to 500 points)
cost_vs_los: list[CostVsLos] = []
scatter_df = df_los[cost.reindex(df_los.index).notna()].copy()
scatter_df["_cost"] = cost.reindex(scatter_df.index)
if len(scatter_df) > 500:
scatter_df = scatter_df.sample(n=500, random_state=42)
for _, r in scatter_df.iterrows():
cost_vs_los.append(CostVsLos(los=int(r["los"]), cost=round(float(r["_cost"]), 2)))
# outcome counts
outcome_counts = [
OutcomeCount(outcome=str(k), count=int(v))
@@ -399,9 +347,6 @@ def _compute_inpatient_clinical() -> InpatientClinicalResponse:
kpis=kpis,
los_histogram=los_histogram,
los_by_disease=los_by_disease,
cost_histogram=cost_histogram,
cost_by_disease=cost_by_disease,
cost_vs_los=cost_vs_los,
outcome_counts=outcome_counts,
admission_route_counts=admission_route_counts,
bmi_by_age_band=bmi_by_age_band,

View File

@@ -10,7 +10,6 @@ const CLINICAL_FIXTURE = {
kpis: {
total_admissions: 5822,
median_los_days: 4,
mean_cost: 6294,
cure_rate: 0.991,
emergency_admit_ratio: 0.47,
},
@@ -23,19 +22,6 @@ const CLINICAL_FIXTURE = {
{ diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 },
{ diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 },
],
cost_histogram: [
{ bin_label: '0-3k', count: 1800 },
{ bin_label: '3k-6k', count: 2200 },
],
cost_by_disease: [
{ diagnosis: '肺炎', mean_cost: 7200, n: 800 },
{ diagnosis: '支气管炎', mean_cost: 5100, n: 600 },
],
cost_vs_los: [
{ los: 3, cost: 5000 },
{ los: 5, cost: 7200 },
{ los: 7, cost: 9100 },
],
outcome_counts: [
{ outcome: '治愈', count: 3474 },
{ outcome: '好转', count: 2298 },

View File

@@ -1,5 +1,5 @@
import { memo } from 'react';
import { Users, CalendarDays, Wallet, HeartPulse, Siren } from 'lucide-react';
import { Users, CalendarDays, HeartPulse, Siren } from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import { TESTIDS } from '@/utils/testids';
import type { InpatientClinicalResponse } from '@/services/api';
@@ -8,12 +8,12 @@ interface ClinicalKpiRowProps {
kpis: InpatientClinicalResponse['kpis'];
}
/** 住院临床 5 项核心指标。375px 下 2 列sm 起 5 列。 */
/** 住院临床 4 项核心指标。375px 下 2 列sm 起 4 列。 */
export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
return (
<div
data-testid={TESTIDS.clinicalKpis}
className="grid grid-cols-2 sm:grid-cols-5 gap-3"
className="grid grid-cols-2 sm:grid-cols-4 gap-3"
>
<StatCard
icon={<Users className="w-4 h-4 text-primary" />}
@@ -25,11 +25,6 @@ export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpi
label="中位住院日"
value={`${kpis.median_los_days}`}
/>
<StatCard
icon={<Wallet className="w-4 h-4 text-primary" />}
label="人均费用"
value={`¥${Math.round(kpis.mean_cost).toLocaleString()}`}
/>
<StatCard
icon={<HeartPulse className="w-4 h-4 text-success" />}
label="治愈好转率"

View File

@@ -1,62 +0,0 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface CostByDiseaseChartProps {
data: { diagnosis: string; mean_cost: number; n: number }[];
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + '…' : s;
}
/** 各病种平均费用横向柱状图。 */
export const CostByDiseaseChart = memo(function CostByDiseaseChart({
data,
}: CostByDiseaseChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
const chartData = [...data]
.sort((a, b) => a.mean_cost - b.mean_cost)
.map((d) => ({ ...d, displayName: truncate(d.diagnosis, 8) }));
return (
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 34)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 20, left: 12, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axisLabel }}
width={72}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number) => [`¥${Math.round(v).toLocaleString()}`, '人均费用']}
/>
<Bar dataKey="mean_cost" fill={CLINICAL_COLORS.cost} barSize={16} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
);
});

View File

@@ -1,55 +0,0 @@
import { memo } from 'react';
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface CostVsLosScatterProps {
data: { los: number; cost: number }[];
}
/** 费用 vs 住院天数散点。 */
export const CostVsLosScatter = memo(function CostVsLosScatter({ data }: CostVsLosScatterProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ top: 10, right: 16, left: 6, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} />
<XAxis
type="number"
dataKey="los"
name="住院天数"
unit="天"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
/>
<YAxis
type="number"
dataKey="cost"
name="费用"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
width={52}
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
cursor={{ strokeDasharray: '3 3' }}
formatter={(value: number, name: string) =>
name === '费用'
? [`¥${value.toLocaleString()}`, name]
: [`${value}`, name]
}
/>
<Scatter data={data} fill={CLINICAL_COLORS.scatter} fillOpacity={0.5} />
</ScatterChart>
</ResponsiveContainer>
);
});

View File

@@ -13,11 +13,11 @@ import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface HistogramChartProps {
data: { bin_label: string; count: number }[];
color?: string;
/** tooltip 中数量的标签,如 "住院天数" / "费用区间"。 */
/** tooltip 中数量的标签,如 "住院天数"。 */
countLabel?: string;
}
/** 通用直方图。用于「住院天数分布」与「住院费用分布」。 */
/** 通用直方图。用于「住院天数分布」。 */
export const HistogramChart = memo(function HistogramChart({
data,
color = CLINICAL_COLORS.los,

View File

@@ -5,8 +5,6 @@
export const CLINICAL_COLORS = {
primary: '#2563EB', // primary
los: '#2563EB',
cost: '#0891B2', // cyan — 费用维度
scatter: '#7C3AED', // violet — 散点
box: '#3B82F6', // 箱体填充
boxMedian: '#1D4ED8', // 中位刻度
grid: '#E2E8F0',

View File

@@ -7,8 +7,6 @@ import { TESTIDS } from '@/utils/testids';
import { ClinicalKpiRow } from '@/components/clinical/ClinicalKpiRow';
import { HistogramChart } from '@/components/clinical/HistogramChart';
import { BoxPlotRows, type BoxRow } from '@/components/clinical/BoxPlotRows';
import { CostVsLosScatter } from '@/components/clinical/CostVsLosScatter';
import { CostByDiseaseChart } from '@/components/clinical/CostByDiseaseChart';
import { DonutChart, type DonutSlice } from '@/components/clinical/DonutChart';
import { CLINICAL_COLORS } from '@/components/clinical/chartColors';
@@ -58,7 +56,7 @@ export function ClinicalAnalysis() {
</h1>
<p className="text-[12px] text-text-secondary">
BMI
</p>
</div>
);
@@ -140,21 +138,6 @@ export function ClinicalAnalysis() {
</Card>
</div>
{/* 费用:分布 + 各病种平均费用 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="住院费用分布">
<HistogramChart data={data.cost_histogram ?? []} color={CLINICAL_COLORS.cost} countLabel="人次" />
</Card>
<Card title="各病种平均费用">
<CostByDiseaseChart data={data.cost_by_disease ?? []} />
</Card>
</div>
{/* 费用 vs 住院天数 散点 */}
<Card title="费用 vs 住院天数">
<CostVsLosScatter data={data.cost_vs_los ?? []} />
</Card>
{/* 出院结局 + 入院途径 双环 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="出院结局构成">

View File

@@ -348,15 +348,11 @@ export interface InpatientClinicalResponse {
kpis: {
total_admissions: number;
median_los_days: number;
mean_cost: number;
cure_rate: number;
emergency_admit_ratio: number;
};
los_histogram: { bin_label: string; count: number }[];
los_by_disease: { diagnosis: string; p25: number; median: number; p75: number; n: number }[];
cost_histogram: { bin_label: string; count: number }[];
cost_by_disease: { diagnosis: string; mean_cost: number; n: number }[];
cost_vs_los: { los: number; cost: number }[];
outcome_counts: { outcome: string; count: number }[];
admission_route_counts: { route: string; count: number }[];
bmi_by_age_band: { age_band: string; p25: number; median: number; p75: number; n: number }[];