export const MODEL_CATEGORIES = [ { id: "std", name: "标准A卡" }, { id: "bai", name: "白户A卡" }, { id: "big", name: "大额A卡" }, { id: "afd", name: "反欺诈评分" }, ] as const; export type ModelCategoryId = (typeof MODEL_CATEGORIES)[number]["id"]; export type ModelGrade = "A" | "B" | "C"; export type AbnormalLevel = "三级" | "二级" | "一级" | "正常"; export type ModelStatus = "正常" | "陪跑" | "陪跑结束" | "下线"; export type RankingStatus = "相符" | "不符" | "—"; export type ModelRecord = { bank: string; category: ModelCategoryId; name: string; modelId: string; version: string; status: ModelStatus; iteratedAt: string; ranking: RankingStatus; ks: number; psi: number; ksDrop: number; secondaryHits: number; wuji: boolean; processedAt: string | null; previousAdvice: string; commonModel: string | null; }; export type MonitoringRow = ModelRecord & { monitorMonth: string; }; export const MONITOR_MONTHS = [ "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", ] as const; export const MODELS: ModelRecord[] = [ { bank: "江城银行", category: "std", name: "标准A卡", modelId: "JC-STD-001", version: "v2.3", status: "正常", iteratedAt: "2026-06-18", ranking: "不符", ks: 36.84, psi: 27.4, ksDrop: 24.1, secondaryHits: 3, wuji: true, processedAt: "2026-06-18", previousAdvice: "建议启动模型微调或重构评估", commonModel: "标准A卡通用版 v2.3", }, { bank: "滨海银行", category: "std", name: "标准A卡", modelId: "BH-STD-001", version: "v2.3", status: "陪跑", iteratedAt: "2026-07-28", ranking: "相符", ks: 44.1, psi: 6.2, ksDrop: 3.4, secondaryHits: 0, wuji: false, processedAt: "2026-06-03", previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3", }, { bank: "华东银行", category: "std", name: "标准A卡", modelId: "HD-STD-001", version: "v2.2", status: "正常", iteratedAt: "2026-03-20", ranking: "相符", ks: 43.5, psi: 8.8, ksDrop: 6.1, secondaryHits: 0, wuji: false, processedAt: "2026-05-20", previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3", }, { bank: "南岭银行", category: "std", name: "标准A卡", modelId: "NL-STD-001", version: "v1.6", status: "正常", iteratedAt: "2025-08-22", ranking: "相符", ks: 41.12, psi: 13.6, ksDrop: 22.5, secondaryHits: 1, wuji: false, processedAt: "2026-06-12", previousAdvice: "建议重点关注 PSI 与 KS 环比变化", commonModel: "标准A卡通用版 v2.3", }, { bank: "云岭银行", category: "std", name: "标准A卡", modelId: "YL-STD-001", version: "v2.1", status: "正常", iteratedAt: "2026-01-09", ranking: "相符", ks: 45.8, psi: 5.1, ksDrop: 2.2, secondaryHits: 0, wuji: false, processedAt: "2026-04-28", previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3", }, { bank: "通汇银行", category: "std", name: "标准A卡", modelId: "TH-STD-001", version: "v2.0", status: "正常", iteratedAt: "2025-11-30", ranking: "不符", ks: 38.73, psi: 9.4, ksDrop: 11.8, secondaryHits: 4, wuji: false, processedAt: "2026-05-16", previousAdvice: "建议开展模型微调可行性评估", commonModel: "标准A卡通用版 v2.3", }, { bank: "北岸银行", category: "std", name: "标准A卡", modelId: "BA-STD-001", version: "v1.9", status: "下线", iteratedAt: "2024-12-11", ranking: "—", ks: 0, psi: 0, ksDrop: 0, secondaryHits: 0, wuji: false, processedAt: "2026-03-02", previousAdvice: "模型已下线,不再参与月度监控", commonModel: null, }, { bank: "江城银行", category: "big", name: "大额A卡", modelId: "JC-BIG-001", version: "v1.4", status: "正常", iteratedAt: "2026-07-03", ranking: "相符", ks: 46.3, psi: 4.8, ksDrop: 1.5, secondaryHits: 0, wuji: false, processedAt: "2026-06-24", previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4", }, { bank: "华东银行", category: "big", name: "大额A卡", modelId: "HD-BIG-001", version: "v1.3", status: "正常", iteratedAt: "2026-04-16", ranking: "相符", ks: 44.9, psi: 7.3, ksDrop: 4.9, secondaryHits: 0, wuji: false, processedAt: "2026-05-30", previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4", }, { bank: "云岭银行", category: "big", name: "大额A卡", modelId: "YL-BIG-001", version: "v1.2", status: "陪跑结束", iteratedAt: "2026-08-05", ranking: "相符", ks: 42.7, psi: 9.6, ksDrop: 8.2, secondaryHits: 0, wuji: false, processedAt: null, previousAdvice: "暂无历史处理建议", commonModel: "大额A卡通用版 v1.4", }, { bank: "滨海银行", category: "big", name: "大额A卡", modelId: "BH-BIG-001", version: "v1.1", status: "正常", iteratedAt: "2025-09-25", ranking: "相符", ks: 39.4, psi: 11.2, ksDrop: 9.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-19", previousAdvice: "建议持续跟踪,下期复核", commonModel: "大额A卡通用版 v1.4", }, { bank: "南岭银行", category: "bai", name: "白户A卡", modelId: "NL-BAI-001", version: "v1.5", status: "正常", iteratedAt: "2026-05-22", ranking: "不符", ks: 34.2, psi: 18.7, ksDrop: 15.3, secondaryHits: 2, wuji: true, processedAt: "2026-06-09", previousAdvice: "建议启动模型微调或重构评估", commonModel: null, }, { bank: "通汇银行", category: "bai", name: "白户A卡", modelId: "TH-BAI-001", version: "v1.4", status: "正常", iteratedAt: "2026-02-11", ranking: "相符", ks: 37.6, psi: 12.1, ksDrop: 7.4, secondaryHits: 1, wuji: false, processedAt: "2026-05-11", previousAdvice: "建议重点关注并持续跟踪", commonModel: "白户A卡通用版 v1.4", }, { bank: "江城银行", category: "bai", name: "白户A卡", modelId: "JC-BAI-001", version: "v1.3", status: "正常", iteratedAt: "2025-12-05", ranking: "相符", ks: 40.8, psi: 9.9, ksDrop: 5.6, secondaryHits: 0, wuji: false, processedAt: "2026-03-26", previousAdvice: "维持现状,继续监控", commonModel: "白户A卡通用版 v1.4", }, { bank: "华东银行", category: "afd", name: "反欺诈评分", modelId: "HD-AFD-001", version: "v2.0", status: "正常", iteratedAt: "2026-04-10", ranking: "不符", ks: 28.6, psi: 31.5, ksDrop: 18.9, secondaryHits: 3, wuji: false, processedAt: "2026-06-21", previousAdvice: "建议启动模型微调或重构评估", commonModel: null, }, { bank: "滨海银行", category: "afd", name: "反欺诈评分", modelId: "BH-AFD-001", version: "v1.8", status: "正常", iteratedAt: "2025-10-30", ranking: "相符", ks: 36.4, psi: 8.1, ksDrop: 4.2, secondaryHits: 3, wuji: true, processedAt: "2026-05-25", previousAdvice: "建议重点关注并持续跟踪", commonModel: "反欺诈评分通用版 v1.8", }, { bank: "云岭银行", category: "afd", name: "反欺诈评分", modelId: "YL-AFD-001", version: "v1.7", status: "正常", iteratedAt: "2025-08-19", ranking: "相符", ks: 38.2, psi: 14.6, ksDrop: 12.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-15", previousAdvice: "建议持续跟踪,下期复核", commonModel: null, }, ]; export const FEATURE_METRICS = [ { key: "age", name: "申请人年龄", iv: 0.284, ivDrop: 6.8, csi: 0.041, csiRise: 0.8, ksContribution: -0.7, psiContribution: 1.2 }, { key: "income", name: "月均收入", iv: 0.251, ivDrop: 12.4, csi: 0.058, csiRise: 1.7, ksContribution: -1.2, psiContribution: 2.4 }, { key: "debt_ratio", name: "负债收入比", iv: 0.219, ivDrop: 18.6, csi: 0.076, csiRise: 2.9, ksContribution: -2.1, psiContribution: 4.1 }, { key: "credit_age", name: "信贷账龄", iv: 0.193, ivDrop: 21.3, csi: 0.083, csiRise: 3.5, ksContribution: -2.8, psiContribution: 5.2 }, { key: "query_3m", name: "近三月查询次数", iv: 0.171, ivDrop: 24.7, csi: 0.091, csiRise: 4.2, ksContribution: -3.4, psiContribution: 6.8 }, ] as const; export const SORTING_DISTRIBUTION = { bins: ["(0,580]", "[580,600)", "[600,620)", "[620,640)", "[640,660)", "[660,680)", "[680,+)"], counts: [307, 418, 730, 1100, 1545, 1895, 6104], badRates: [10.42, 5.98, 5.48, 3.64, 2.85, 1.64, 0.31], } as const; export const BANK_AVERAGE_CYCLE: Record = { 江城银行: 8.6, 滨海银行: 11.2, 华东银行: 9.8, 南岭银行: 12.4, 云岭银行: 10.1, 通汇银行: 13.6, 北岸银行: 16.0, }; export type ModelLifecycle = { onlineAt: string | null; escortStartAt: string | null; escortEndAt: string | null; offlineAt: string | null; developer: string; developmentKs: number; developmentPsi: number; maxLift: number; }; export const MODEL_LIFECYCLE: Record = { "JC-STD-001": { onlineAt: "2025-03-12", escortStartAt: "2025-01-20", escortEndAt: "2025-03-10", offlineAt: null, developer: "李伟", developmentKs: 46.0, developmentPsi: 3.92, maxLift: 3.24 }, "BH-STD-001": { onlineAt: null, escortStartAt: "2026-07-28", escortEndAt: null, offlineAt: null, developer: "王芳", developmentKs: 47.5, developmentPsi: 3.48, maxLift: 3.37 }, "HD-STD-001": { onlineAt: "2024-11-05", escortStartAt: "2024-09-18", escortEndAt: "2024-11-01", offlineAt: null, developer: "李伟", developmentKs: 46.9, developmentPsi: 3.71, maxLift: 3.18 }, "NL-STD-001": { onlineAt: "2025-08-22", escortStartAt: "2025-06-30", escortEndAt: "2025-08-18", offlineAt: null, developer: "王芳", developmentKs: 45.4, developmentPsi: 4.06, maxLift: 3.09 }, "YL-STD-001": { onlineAt: "2025-05-14", escortStartAt: "2025-03-22", escortEndAt: "2025-05-10", offlineAt: null, developer: "李伟", developmentKs: 48.2, developmentPsi: 3.26, maxLift: 3.42 }, "TH-STD-001": { onlineAt: "2024-06-18", escortStartAt: "2024-04-25", escortEndAt: "2024-06-14", offlineAt: null, developer: "王芳", developmentKs: 44.8, developmentPsi: 4.31, maxLift: 2.96 }, "BA-STD-001": { onlineAt: "2023-09-01", escortStartAt: null, escortEndAt: null, offlineAt: "2026-02-28", developer: "李伟", developmentKs: 42.7, developmentPsi: 4.62, maxLift: 2.81 }, "JC-BIG-001": { onlineAt: "2025-10-08", escortStartAt: "2025-08-15", escortEndAt: "2025-10-05", offlineAt: null, developer: "王芳", developmentKs: 49.1, developmentPsi: 3.05, maxLift: 3.55 }, "HD-BIG-001": { onlineAt: "2025-02-19", escortStartAt: "2024-12-20", escortEndAt: "2025-02-15", offlineAt: null, developer: "李伟", developmentKs: 47.8, developmentPsi: 3.37, maxLift: 3.31 }, "YL-BIG-001": { onlineAt: "2026-08-05", escortStartAt: "2026-06-10", escortEndAt: "2026-08-01", offlineAt: null, developer: "王芳", developmentKs: 46.6, developmentPsi: 3.68, maxLift: 3.12 }, "BH-BIG-001": { onlineAt: "2024-12-03", escortStartAt: "2024-10-11", escortEndAt: "2024-11-29", offlineAt: null, developer: "李伟", developmentKs: 43.9, developmentPsi: 4.25, maxLift: 2.91 }, "NL-BAI-001": { onlineAt: "2025-07-16", escortStartAt: "2025-05-20", escortEndAt: "2025-07-12", offlineAt: null, developer: "王芳", developmentKs: 42.6, developmentPsi: 4.74, maxLift: 2.73 }, "TH-BAI-001": { onlineAt: "2025-04-09", escortStartAt: "2025-02-14", escortEndAt: "2025-04-05", offlineAt: null, developer: "李伟", developmentKs: 43.7, developmentPsi: 4.18, maxLift: 2.88 }, "JC-BAI-001": { onlineAt: "2024-08-27", escortStartAt: "2024-07-01", escortEndAt: "2024-08-23", offlineAt: null, developer: "王芳", developmentKs: 45.1, developmentPsi: 3.89, maxLift: 3.03 }, "HD-AFD-001": { onlineAt: "2025-01-22", escortStartAt: "2024-11-28", escortEndAt: "2025-01-18", offlineAt: null, developer: "李伟", developmentKs: 40.8, developmentPsi: 5.16, maxLift: 2.54 }, "BH-AFD-001": { onlineAt: "2024-10-14", escortStartAt: "2024-08-20", escortEndAt: "2024-10-10", offlineAt: null, developer: "王芳", developmentKs: 42.3, developmentPsi: 4.69, maxLift: 2.68 }, "YL-AFD-001": { onlineAt: "2024-05-08", escortStartAt: "2024-03-15", escortEndAt: "2024-05-04", offlineAt: null, developer: "李伟", developmentKs: 43.5, developmentPsi: 4.42, maxLift: 2.79 }, }; export function categoryName(category: ModelCategoryId): string { return MODEL_CATEGORIES.find((item) => item.id === category)?.name ?? category; } export function abnormalLevelOf(model: Pick): AbnormalLevel { const { ranking, ks, psi, ksDrop } = model; if (ranking === "—") return "正常"; if (ranking === "不符" && ks < 40 && psi > 10) return "三级"; if (ranking === "相符" && ks < 40 && psi > 25) return "三级"; if (ranking === "不符" && ks >= 40 && psi > 25) return "二级"; if (ranking === "不符" && ks >= 40 && psi > 10 && psi <= 25 && ksDrop > 20) return "二级"; if (ranking === "不符" && ks < 40 && psi <= 10) return "二级"; if (ranking === "相符" && ks < 40 && psi <= 25) return "二级"; if (ranking === "相符" && ks >= 40 && psi > 25 && ksDrop > 20) return "二级"; if (ranking === "不符" && ks >= 40 && psi > 10 && psi <= 25 && ksDrop <= 20) return "一级"; if (ranking === "不符" && ks >= 40 && psi <= 10) return "一级"; if (ranking === "相符" && ks >= 40 && psi > 25 && ksDrop <= 20) return "一级"; if (ranking === "相符" && ks >= 40 && psi > 10 && psi <= 25) return "一级"; if (ranking === "相符" && ks >= 40 && psi <= 10 && ksDrop > 20) return "一级"; return "正常"; } export function gradeOf(model: Pick): ModelGrade { if (model.status === "下线") return "A"; const abnormal = abnormalLevelOf(model); if (abnormal === "三级") return "C"; if (abnormal === "二级" && model.secondaryHits >= 4) return "C"; if (abnormal === "正常") return "A"; return "B"; } export function abnormalReasonOf(model: ModelRecord | MonitoringRow): string { const abnormal = abnormalLevelOf(model); if (abnormal === "正常") return "各项指标均在阈值内"; const reasons: string[] = []; if (model.ranking === "不符") reasons.push("排序性不符"); if (model.ks < 40) reasons.push("KS 低于 40%"); if (model.psi > 25) reasons.push("PSI 高于 25%"); else if (model.psi > 10) reasons.push("PSI 处于 10%–25%"); if (model.ksDrop > 20) reasons.push("KS 环比降幅高于 20%"); if (abnormal === "二级") { const result = model.secondaryHits >= 4 ? ",已触发监控结果升级为 C" : ",未达到累计升级条件"; return `${reasons.join("、")};近 6 个月累计 ${model.secondaryHits} 次二级异常${result}`; } return reasons.join("、"); } function hash(value: string): number { let result = 0; for (const character of value) result = (result * 31 + character.charCodeAt(0)) >>> 0; return result; } export function modelTrend( model: ModelRecord, metric: "ks" | "psi", months: readonly string[] = MONITOR_MONTHS, ): number[] { const end = model[metric]; const amplitude = metric === "ks" ? 2.6 : 4.4; const seed = hash(`${model.modelId}-${metric}`); const waveAt = (index: number) => (((seed >> (index % 12)) % 9) - 4) * (amplitude / 12); const lastWave = waveAt(Math.max(0, months.length - 1)); return months.map((_, index) => { const distance = months.length - 1 - index; const drift = metric === "ks" ? distance * 0.32 : -distance * 0.74; const wave = waveAt(index) - lastWave; return Math.max(0, Number((end + drift + wave).toFixed(2))); }); } export function monitoringRows(source: ModelRecord[] = MODELS): MonitoringRow[] { const rows: MonitoringRow[] = []; for (const model of source) { if (model.status === "下线") { rows.push({ ...model, monitorMonth: "2026-02", ranking: "相符", ks: 39.8, psi: 9.1, ksDrop: 6.2, secondaryHits: 1 }); continue; } const ksTrend = modelTrend(model, "ks"); const psiTrend = modelTrend(model, "psi"); MONITOR_MONTHS.forEach((monitorMonth, index) => { rows.push({ ...model, monitorMonth, ks: ksTrend[index] ?? model.ks, psi: psiTrend[index] ?? model.psi, ksDrop: Math.max(0, Number((model.ksDrop - (MONITOR_MONTHS.length - 1 - index) * 0.9).toFixed(2))), secondaryHits: Math.max(0, model.secondaryHits - (MONITOR_MONTHS.length - 1 - index)), }); }); } return rows.sort((left, right) => ( right.monitorMonth.localeCompare(left.monitorMonth) || left.bank.localeCompare(right.bank, "zh-CN") || left.modelId.localeCompare(right.modelId) )); } export function monthsBetween(from: string, to: string): string[] { const [fromYear, fromMonth] = from.split("-").map(Number); const [toYear, toMonth] = to.split("-").map(Number); if (!fromYear || !fromMonth || !toYear || !toMonth) return [...MONITOR_MONTHS]; const start = fromYear * 12 + fromMonth - 1; const end = toYear * 12 + toMonth - 1; if (start > end || end - start > 23) return [...MONITOR_MONTHS]; return Array.from({ length: end - start + 1 }, (_, index) => { const value = start + index; const year = Math.floor(value / 12); const month = value % 12 + 1; return `${year}-${String(month).padStart(2, "0")}`; }); } export function average(values: number[]): number { return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; } export function categoryTrend( category: ModelCategoryId, metric: "ks" | "psi", months: string[], source: ModelRecord[] = MODELS, ): number[] { const models = source.filter((model) => model.category === category && model.status !== "下线"); const series = models.map((model) => modelTrend(model, metric, months)); return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2))); } export function bankTrend( bank: string, metric: "ks" | "psi", months: string[], source: ModelRecord[] = MODELS, ): number[] { const models = source.filter((model) => model.bank === bank && model.status !== "下线"); const series = models.map((model) => modelTrend(model, metric, months)); return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2))); } export function modelsTrend( models: ModelRecord[], metric: "ks" | "psi", months: string[], ): number[] { const activeModels = models.filter((model) => model.status !== "下线"); const series = activeModels.map((model) => modelTrend(model, metric, months)); return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2))); } export function latestIterationDate(models: ModelRecord[]): string { return models.map((model) => model.iteratedAt).filter(Boolean).sort().at(-1) ?? "—"; } export function modelIterationCycleMonths(model: ModelRecord): number | null { const lifecycle = MODEL_LIFECYCLE[model.modelId]; const start = lifecycle?.onlineAt ?? lifecycle?.escortStartAt; if (!start || !model.iteratedAt) return null; const [startYear, startMonth] = start.slice(0, 7).split("-").map(Number); const [endYear, endMonth] = model.iteratedAt.slice(0, 7).split("-").map(Number); if (![startYear, startMonth, endYear, endMonth].every(Number.isFinite)) return null; return Math.max(0, (endYear * 12 + endMonth) - (startYear * 12 + startMonth)); } export function averageIterationCycle(models: ModelRecord[]): number { const values = models.map(modelIterationCycleMonths).filter((value): value is number => value !== null); return Number(average(values).toFixed(1)); } export function averageIterationMonths(models: ModelRecord[]): number { const months = models .filter((model) => model.status !== "下线") .map((model) => { const [year, month] = model.iteratedAt.slice(0, 7).split("-").map(Number); return (2026 * 12 + 7) - (year * 12 + month); }) .filter(Number.isFinite); return Number(average(months).toFixed(1)); }