Files
model-platform/frontend/app/features/operations/modelData.ts
T

386 lines
20 KiB
TypeScript
Raw 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.
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<string, number> = {
江城银行: 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<string, ModelLifecycle> = {
"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<ModelRecord, "ranking" | "ks" | "psi" | "ksDrop">): 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<ModelRecord, "ranking" | "ks" | "psi" | "ksDrop" | "secondaryHits" | "status">): 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));
}