TOPSIS多指标评价全流程:正向化、熵权法与Python实现 简介面向多准则决策分析、课程实验和技术选型场景的Topsis综合评价法配套代码与数据适合需要掌握优劣解距离法完整流程的学生、教师及工程人员使用。压缩包共2个文件约10KB含1个xlsx评价数据表和1个m脚本数据表提供指标矩阵与银行数据示例便于核对评价对象、指标口径和原始数值脚本对应MATLAB实现可串联数据标准化、权重设定、正负理想解构建、欧式距离计算、贴近度求解与方案排序等关键环节。已有643人学习具备一定参考热度。读者可据此复现从原始矩阵到综合评分的计算链路观察不同权重与标准化方式对排序结果的影响并替换自有数据扩展评价指标形成可复用的多准则评价流程和结果解读思路为软件或插件选型提供量化依据。1. 一张评分表拍不出结论TOPSIS 把「谁更好」变成可复算的距离年底做供应商评级十几列指标往 Excel 里一摊加权求和算出个排名会上有人问一句「这个权重怎么来的」场面就冷了。TOPSIS 综合评价法解决的正是这类问题把 m 个待评价对象放进 n 维指标空间人为构造一个「各项都最优」的正理想解和一个「各项都最劣」的负理想解谁离正理想解近、离负理想解远谁就排在前面。整个过程没有黑匣子每一步都能拿出数来对。这份资源把代码和配套数据放在一起读进来改列名就能跑适合做供应商选型、方案评审、工艺参数优选、绩效排序这类「多指标、要排名、还得讲得清理由」的场景。2. 决策矩阵预处理三类指标正向化与向量归一化的边界2.1 决策矩阵的行列约定与指标方向表决策矩阵是整条流水线的起点行是待评价对象列是指标。真正容易出错的不是算而是列的语义没标清楚。我在项目里一般强制维护一张「指标方向表」把每一列的类型、参数、业务含义写死代码只认这张表不认列名。指标按方向分四类类型业务含义正向化方式常见例子极大型效益型越大越好保持原值良品率、覆盖率、收益率极小型成本型越小越好max − x交付周期、响应时长、故障率中间型适中型越接近某个值越好1 − |x − x_best| / M反应温度、pH、张力区间型落在 [a, b] 内最好分段线性映射产能利用率、库存周转、负载率读数据时第一件事是把标识列和指标列拆开。常见做法是用 pandas 读进来之后标识列单独抽成 list数值部分.to_numpy(dtypefloat)别指望 numpy 帮你处理字符串列——它不会只会把整张表变成 object 数组后面所有矩阵运算全部失效。2.2 正向化公式与三个必须防的边界正向化的目标是把所有指标统一成「越大越好」并且尽量落在 [0, 1]。四类的实现写成一个函数用 kind 分发import numpy as np def positivize(x, kind, paramNone): 把一列原始指标统一转成极大型值域尽量落在 [0, 1] x np.asarray(x, dtypefloat) if kind max: # 极大型原样返回 return x.copy() if kind min: # 极小型max - x平移不改变相对次序 return x.max() - x if kind mid: # 中间型param 是最优值 x_best M np.abs(x - param).max() if M 0: # 整列都等于最优值没有区分度 return np.ones_like(x) return 1.0 - np.abs(x - param) / M if kind interval: # 区间型param (a, b) a, b param M max(a - x.min(), x.max() - b) if M 0: # 全部落在区间内同样无区分度 return np.ones_like(x) y np.ones_like(x) lo, hi x a, x b y[lo] 1.0 - (a - x[lo]) / M y[hi] 1.0 - (x[hi] - b) / M return y raise ValueError(f未登记的指标类型: {kind})逻辑说明极小型用「最大值减原值」而不是1/x是因为1/x在 x 为 0 或负数时直接爆炸而平移法对任意实数都安全代价是值域不再是 [0,1]不过后面还有归一化兜着。中间型的 M 取max|x − x_best|含义是「最偏离最优值的那个样本」这样正向化后最优点为 1、最偏点为 0。区间型的 M 取max(a − min, max − b)即「最坏情况偏离区间多远」保证落在区间内为 1、最偏处为 0。参数说明param对中间型是标量最优值对区间型是二元组 (a, b)。这两个值必须来自业务判断而不是数据本身——温度最优值 7.0 是工艺给的不是这一列的平均数。我见过有人把区间型的 (a, b) 设成 (min, max)结果整列全是 1这个指标相当于被静默删掉了。2.3 向量归一化为什么不能随手用 min-max正向化只解决了方向没解决量纲。良品率是 0.9 量级交付周期是 30 量级直接算距离的话周期这一列会彻底压死其他指标。归一化有两条路min-max(x − min) / (max − min)结果严格落在 [0,1]向量归一化x / sqrt(sum(x²))每列平方和为 1min-max 看起来更顺手但它有个隐蔽后果归一化后每列的最小值恒为 0、最大值恒为 1于是「负理想解」天然就是全 0 向量「正理想解」天然就是全 1 向量。表面上没报错实际上理想解已经和数据脱钩变成了硬编码常量贴近度的区分能力会被削弱尤其是某一列存在极端值时其余样本被压到 0 附近全都贴着负理想解。向量归一化保留的是相对比例关系不会把最小值强行拉到 0。def normalize(mat): 按列做向量归一化防御整列全 0 的情况 norm np.sqrt((mat ** 2).sum(axis0)) norm[norm 0] 1.0 # 整列全 0除零保护该列不贡献距离 return mat / norm这一步之后矩阵里可能有负值吗不会。正向化保证所有元素非负非负数的平方和开方必然大于等于原值的绝对值所以归一化后元素一定在 [0, 1]。如果有人跳过了正向化直接对原始数据做向量归一化就会出现负数元素正负理想解的语义立刻模糊——这是第二章最该记住的一句话。3. 权重从哪来熵权法、CRITIC 与加权规范化矩阵3.1 熵权法的完整实现与 ln(0) 的处理熵权法的逻辑是某个指标在各方案之间差异越大说明它携带的区分信息越多权重就该越大。差异用信息熵衡量熵越小、差异越大、权重越大。步骤是三步计算比重p_ij z_ij / sum_i(z_ij)每列归一化成概率分布计算信息熵e_j −(1/ln m) · sum_i(p_ij · ln p_ij)计算差异系数d_j 1 − e_j归一化得w_j d_j / sum(d_j)这里有个必然踩的点只要某个p_ij恰好是 0ln(0)就是负无穷0 · (−inf)得到 nan一个 nan 顺着 sum 污染整列熵最后所有权重都变 nan。标准处理是把p 0的这一项直接记作 0信息论里的约定代码上用np.where兜住def entropy_weight(z): 熵权法输入归一化后的非负矩阵返回权重向量 m, n z.shape if m 2: raise ValueError(样本数少于 2熵权法无意义) col_sum z.sum(axis0, keepdimsTrue) col_sum[col_sum 0] 1.0 # 整列全 0该列不参与 p z / col_sum with np.errstate(divideignore, invalidignore): plogp np.where(p 0, p * np.log(p), 0.0) # 约定 0·ln0 0 e -plogp.sum(axis0) / np.log(m) # 信息熵∈ [0,1] d 1.0 - e # 差异系数 if d.sum() 0: # 所有指标都无差异 return np.full(n, 1.0 / n) return d / d.sum()逻辑说明np.errstate那层是压警告真正干活的是np.where它保证 p 为 0 的位置贡献 0 而不是 nan。e除以ln(m)是为了把熵归一到 [0,1]这样不同样本数下的熵可以横向比较如果 m 很大而某些列几乎均匀分布e会非常接近 1d接近 0这些列自动被压权重这恰恰是熵权法想要的。参数说明这段代码没有可调参数唯一要盯的是输入。输入必须是正向化归一化之后的矩阵直接喂原始数据会得到完全相反的权重结论。另一点m 2时ln(m) 0直接除零所以开头拦一道。3.2 CRITIC指标之间高度相关时该换一种算法熵权法只看单列的离散程度不看列与列之间的关系。如果「良品率」和「一次通过率」两条指标高度正相关本质上是同一份信息熵权法会给它们各分一份权重等于变相加倍。CRITIC 用两个维度修正这个问题对比强度标准差和冲突性1 − 相关系数。相关性越高冲突性越小权重被压低。def critic_weight(z): CRITIC对比强度 × 冲突性 std z.std(axis0, ddof1) # 对比强度 corr np.corrcoef(z, rowvarFalse) # 指标间相关系数矩阵 conflict (1.0 - corr).sum(axis0) # 冲突性与其余指标的不相关程度之和 c std * conflict if c.sum() 0: return np.full(z.shape[1], 1.0 / z.shape[1]) return c / c.sum()什么时候换指标数量少五六列以内且业务上对重要性有明确排序用 AHP 主观赋权更贴合决策意图记得做一致性检验CR 0.1指标多、相关性明显、希望完全由数据说话用熵权法或 CRITIC。实际项目里我一般两种都算一遍如果排出来的名次差异很大说明结论对权重敏感报告里必须把这个敏感性写进去而不是只报一个名次。3.3 加权规范化矩阵与正负理想解权重拿到之后加权规范化矩阵就是逐列乘上去V Z * Wnumpy 的广播会把长度为 n 的权重按列对齐前提是列顺序和 SPEC 完全一致这一点后面还会提。正理想解取每列最大值负理想解取每列最小值——注意是从加权矩阵里「取出来」不是拿 1 和 0 填。V Z * W v_pos V.max(axis0) # 正理想解每列最大 v_neg V.min(axis0) # 负理想解每列最小这两个向量的业务含义是「一个现实中不存在的、各项都最好的假想方案」。正因为它是从数据里取出来的所以当数据更新时理想解会自动跟着变这也是判断一份 TOPSIS 代码写没写对的最快方式把输入数据整体放大十倍如果正负理想解没变那多半是被硬编码了。4. 距离与贴近度正负理想解那 30 行代码逐段读4.1 欧氏距离与相对贴近度拿到正负理想解之后每个方案到它们的欧氏距离分别是d_i sqrt(sum_j (v_ij − v_j)²)离最优有多远d_i− sqrt(sum_j (v_ij − v_j−)²)离最劣有多远贴近度C_i d_i− / (d_i d_i−)取值在 [0, 1]越接近 1 越好。这个公式的妙处是它同时考虑了两端一个方案可以既不特别好也不特别差两个距离都中等C 值约 0.5而一个在某一项上极端突出的方案如果其他项拖后腿C 值未必高。这正是它比简单加权求和更适合「均衡型选型」的原因。分母有个坑如果所有方案在所有指标上完全一致v_pos v_neg每个方案的d和d−都是 00/0得到 nan。工程上必须加一道保护赋一个无意义的默认值并打日志而不是让 nan 静默流入排序。4.2 一份可以直接跑的完整脚本把前面几块拼起来从数据到排名一次跑完。依赖只有 numpy 和 pandasPython 3.8 以上都行。import numpy as np import pandas as pd # ---------- 1. 原始决策矩阵行方案列指标 ---------- raw pd.DataFrame({ 方案: [甲, 乙, 丙, 丁, 戊], 交付周期: [32, 25, 41, 28, 36], # 极小型 良品率: [0.962, 0.978, 0.941, 0.955, 0.969], # 极大型 温度偏差: [6.8, 7.3, 7.0, 8.1, 6.5], # 中间型最优 7.0 产能利用率: [12.4, 18.6, 21.3, 9.8, 15.2], # 区间型 [12, 20] 响应时长: [4.1, 3.6, 4.8, 3.9, 4.4], # 极小型 覆盖率: [0.81, 0.74, 0.88, 0.69, 0.77], # 极大型 }) # ---------- 2. 指标方向表顺序必须与 raw 中指标列一一对应 ---------- SPEC [ (交付周期, min, None), (良品率, max, None), (温度偏差, mid, 7.0), (产能利用率, interval, (12.0, 20.0)), (响应时长, min, None), (覆盖率, max, None), ] names raw[方案].tolist() X raw[[c for c, _, _ in SPEC]].to_numpy(dtypefloat) # ---------- 3. 正向化 ---------- P np.column_stack([ positivize(X[:, j], kind, param) for j, (_, kind, param) in enumerate(SPEC) ]) # ---------- 4. 向量归一化 ---------- Z normalize(P) # ---------- 5. 权重换成 critic_weight(Z) 即可做对比 ---------- W entropy_weight(Z) # ---------- 6. 加权规范化矩阵与正负理想解 ---------- V Z * W v_pos V.max(axis0) v_neg V.min(axis0) # ---------- 7. 距离与贴近度 ---------- d_pos np.sqrt(((V - v_pos) ** 2).sum(axis1)) d_neg np.sqrt(((V - v_neg) ** 2).sum(axis1)) denom d_pos d_neg denom[denom 0] 1.0 # 全方案完全相同时的除零保护 C d_neg / denom result ( pd.DataFrame({方案: names, d: d_pos, d-: d_neg, 贴近度: C}) .sort_values(贴近度, ascendingFalse) .reset_index(dropTrue) ) result[排名] result.index 1 result[[d, d-, 贴近度]] result[[d, d-, 贴近度]].round(6) print(result.to_string(indexFalse)) print(权重:, {c: round(float(w), 4) for (c, _, _), w in zip(SPEC, W)})跑完之后输出的是排名表和权重字典。改数据只需要动raw和SPEC两处其余不用碰。如果要把结果导出result.to_excel(topsis_result.xlsx, indexFalse)就行但记得把权重单独存一张表评审时被问「为什么是这个权重」直接翻那一页。4.3 跑完之后先做三项校验别急着交报告代码不报错不等于算对了。我每次跑完固定过一遍下面这几项校验项期望值不通过时先查什么权重之和1 ± 1e-9归一化那一步是不是漏了除以 sum权重全非负全部 ≥ 0CRITIC 里相关系数出现负值冲突项可能大于 n贴近度范围落在 [0, 1] 且无 nand / d− 是否出现 0/0或熵权法 ln(0) 未处理排序合理性与等权 TOPSIS 的 Spearman 相关系数 0.8权重在主导结论需补做灵敏度分析最后一项最容易被跳过但它决定了这份排名能不能拿去开会。如果等权排第一的方案在熵权排到第四说明结论完全由权重驱动这时候正确的做法不是挑一个「好看」的权重而是把两组结果都摆出来说明分歧点在哪一列指标上。5. 避坑与排查五个让排序结果翻车的细节5.1 现象排名和业务直觉完全相反原因指标方向表和数据列错位。SPEC里的顺序是按业务文档写的而 Excel 里那几列被人调过位置Z * W广播时权重被乘到了错误的列上。更隐蔽的情况是「交付周期」被标成了 max这一列的贡献直接反向。解决不要靠肉眼对齐。把列名连同方向打进一张表打印出来逐行核对或者干脆在代码里用列名字符串取列raw[c]让 pandas 帮你对齐方向表只做类型分发。我现在的习惯是方向表第一列写列名取数时用列名索引而不是位置索引。5.2 现象权重全部变成 nan或者某几列权重正好是 0原因熵权法里某一列出现p_ij 0ln(0)产生 −inf0 × (−inf)得到 nan另一种是某列在所有方案上取值完全相同熵e 1差异系数d 0权重自然是 0——这个不是 bug是数据本身没有区分度。解决前者用np.where(p 0, p * np.log(p), 0.0)兜住并加np.errstate压制警告后者先检查这一列是不是常量列或近似常量比如全是 0.99如果是业务上就该把它剔掉而不是让它挂着占位。剔列之前记一笔日志报告里说明「该指标在所有候选中无差异未参与评价」。5.3 现象所有方案的贴近度挤在 0.5 附近排名没有区分度原因归一化用了 min-max而且数据里存在一个极端离群值。极值把整列压缩到 0 附近除了那一个离群点其余方案在加权矩阵里几乎重合正负理想解的距离差被抹平。解决换成向量归一化或者对离群值先做业务判断——是录入错误就修正是真实极端值就保留但改用 CRITIC 降低该列权重。不要用「把极值截断到 99 分位」这种默认操作那等于篡改数据评审时说不清。5.4 现象脚本报维度不匹配或者算出来的矩阵多了一列原因从 Excel 读数据时index_col没设第一列的序号被当成一个指标读进来了或者read_excel把表头的合并单元格读成了Unnamed: 0。这一列混进去之后X.shape[1]比SPEC多 1np.column_stack凑出来的矩阵列数不对。解决读入后先打印df.columns.tolist()和df.shape肉眼确认列数等于指标数加标识列数。取数时显式指定列名列表不要用df.iloc[:, 1:]这种位置切片——表结构一变就全线崩。5.5 现象中间型、区间型正向化后出现大于 1 或小于 0 的值原因中间型的 M 用了max(x) − min(x)而不是max|x − x_best|。后者才是「最大偏离量」前者只是极差两者不等混用会让不同指标的正向化结果值域不一致。解决整个项目只允许一种 M 的定义写进函数 docstring 并统一调用。区间型同理M 必须是max(a − min, max − b)并且要处理「所有值都落在区间内」导致 M ≤ 0 的退化情况此时该列全为 1等于无区分度需要在日志里提示。6. 进阶权重扰动测试与组合赋权的落地写法排名出来只是半成品。真正让结论站得住的是下一步权重变动 10%名次会不会翻。做法很朴素对每一列权重乘以 [0.9, 1.1] 的随机扰动重复几百次统计每个方案进入前三的频率。频率稳定在 90% 以上的结论可以说在 50% 上下横跳的说明这个名次是权重噪声不该写进结论。rng np.random.default_rng(42) top3_hits np.zeros(len(names)) TRIALS 500 for _ in range(TRIALS): w W * rng.uniform(0.9, 1.1, sizelen(W)) # 每个指标独立扰动 ±10% w w / w.sum() # 扰动后仍要归一化 Vp Z * w dp np.sqrt(((Vp - Vp.max(axis0)) ** 2).sum(axis1)) dn np.sqrt(((Vp - Vp.min(axis0)) ** 2).sum(axis1)) c dn / (dp dn) top3_hits[np.argsort(-c)[:3]] 1 print(dict(zip(names, (top3_hits / TRIALS).round(3))))逻辑说明扰动只作用在权重上正向化矩阵 Z 保持不动这样每次重算的差异完全来自权重排除数据变动干扰。种子固定是为了结果可复现评审时别人拿同样的代码能跑出同样的数。如果嫌 500 次慢200 次已经足够看出趋势这个规模的计算量在普通笔记本上是秒级。组合赋权则是另一条路把熵权法的客观权重和 AHP 的主观权重做乘法合成w (w_obj · w_sub) / sum(w_obj · w_sub)再归一化。合成的意义是既不丢业务判断也不完全由数据离散度说了算。我一般只在两种权重排序分歧很大时才用它否则优先用单一方法理由简单——方法越多解释成本越高会上越说不清。一个具体技巧把正向化后的矩阵 P、权重 W、加权矩阵 V 全部落盘成 csv和最终排名放在同一个目录。半年后有人质疑某个名次直接翻出中间结果逐列核对比重新跑一遍猜当时用了什么参数省事得多。血泪经验是TOPSIS 翻车几乎从不发生在距离公式上全部发生在进入公式之前的那几列数据里。从那以后我每次跑之前都强制走一遍「列名—方向—参数」三列对照表的打印肉眼扫一遍再按回车。希望帮到你。本文还有配套的精品资源点击获取