# TOPSIS实战:用Python手把手教你实现学生成绩综合评价(附完整代码)
你是不是也遇到过这样的困惑?面对一份包含多门课程成绩、出勤率、项目得分的学生数据表,想给出一个公平的综合排名,却不知道从何下手。简单地求平均分?那对选修了高难度课程的同学太不公平。手动赋予权重?又难免带上主观偏见,难以服众。今天,我们就来彻底解决这个问题。我将带你用Python一步步实现TOPSIS算法,这是一个在学术研究和商业分析中都备受青睐的多指标决策方法。它不依赖主观赋权,完全由数据说话,能帮你从一堆复杂的指标里,客观地“算”出一个最合理的排名。无论你是正在学习数据分析的学生,还是需要处理绩效评估的团队负责人,这篇文章都能让你获得一个即拿即用的强大工具。
## 1. 为什么是TOPSIS?超越平均分的科学评价思维
在深入代码之前,我们有必要先理解TOPSIS(逼近理想解排序法)到底解决了什么问题。想象一下,你要评价三位学生的综合表现,指标包括“数学成绩”(满分100)和“完成作业所需时间”(单位:小时)。学生A成绩95分,用时10小时;学生B成绩85分,用时5小时;学生C成绩75分,用时2小时。
如果只看数学成绩,A无疑是最优的。但结合效率来看,C用极短的时间获得了尚可的成绩,其时间利用效率可能更高。简单的平均分(将时间取倒数后平均)依然粗糙,因为它隐含了一个假设:成绩和时间的重要性是1:1的。而TOPSIS的核心思想非常直观:**先找出想象中的“完美学生”(各科成绩最高、用时最短)和“最差学生”(各科成绩最低、用时最长),然后计算每个真实学生与这两位“虚拟学生”的距离。谁更靠近“完美学生”,同时远离“最差学生”,谁的综合评价就更高。**
这种方法的优势显而易见:
* **客观性**:排名完全由数据驱动,避免了人为设定权重的主观性。
* **灵活性**:可以轻松容纳数十个甚至上百个评价指标。
* **普适性**:对数据的分布形态没有严格要求,应用范围极广。
> 注意:TOPSIS得出的结果是相对接近程度(分数介于0-1之间),用于排序非常有效,但其绝对分数值本身没有像“百分制”那样的绝对意义。它回答的是“在现有这批对象里,谁更好”的问题。
接下来,我们将这个思想转化为可操作的步骤,并用Python将其实现。
## 2. 搭建环境与准备数据:你的第一步
工欲善其事,必先利其器。我们选择Python,因为它丰富的数据科学生态库能让实现过程变得异常简洁。你需要准备以下环境:
* **Python 3.7及以上版本**:这是当前主流且稳定的版本。
* **必要的库**:我们将主要依赖`pandas`、`numpy`和`scipy`。如果你使用Anaconda,这些库通常已预装。如果没有,可以通过pip安装。
打开你的终端或命令提示符,执行以下命令来确保库已就绪:
```bash
pip install pandas numpy scipy
```
数据准备是分析的基础。假设我们有一个包含5名学生、3项指标的数据集,我们将它存为一个CSV文件`student_scores.csv`,内容如下:
```csv
Name,Math_Score,Physics_Score,Time_Cost
Alice,85,90,8
Bob,92,88,12
Charlie,78,85,5
David,88,92,15
Eva,95,78,10
```
这里,`Math_Score`和`Physics_Score`是**极大型指标**(分数越高越好),而`Time_Cost`是**极小型指标**(花费时间越少越好)。我们的任务就是将这三项指标统一,并计算出一个综合排名。
让我们用Python先读取并查看一下数据:
```python
import pandas as pd
# 读取数据
df = pd.read_csv('student_scores.csv')
print("原始数据:")
print(df)
print("\n数据基本信息:")
print(df.info())
```
运行这段代码,你会看到数据的结构,确保没有缺失值。在实际项目中,数据清洗(处理缺失值、异常值)是必不可少的前置步骤,为了聚焦TOPSIS核心,我们假设这份数据是干净的。
## 3. 核心算法四步走:从理论到Python代码
TOPSIS的实现可以清晰地分为四个步骤:**指标正向化**、**数据标准化**、**计算距离**和**得出评分**。下面我们逐一拆解,并给出对应的Python函数。
### 3.1 第一步:指标正向化
我们的数据中同时存在“越大越好”和“越小越好”的指标。为了统一计算,必须将所有指标转化为“极大型”。对于极小型指标,常用的转换公式是:
**正向化值 = 最大值 - 原始值**
或者,如果所有值均为正数,也可以使用 **1 / 原始值**。这里我们采用前者。
```python
import numpy as np
def data_direction_normalization(df, positive_indicies, negative_indicies):
"""
对数据进行正向化处理
:param df: 包含指标列的DataFrame
:param positive_indicies: 极大型指标列名列表
:param negative_indicies: 极小型指标列名列表
:return: 正向化后的DataFrame
"""
df_normalized = df.copy()
# 处理极小型指标:转换为极大型
for col in negative_indicies:
max_val = df_normalized[col].max()
df_normalized[col] = max_val - df_normalized[col]
# 重命名列,以示区别(可选)
df_normalized.rename(columns={col: f"{col}_正向化"}, inplace=True)
# 极大型指标保持不变
# 注意:正向化后,所有指标都应被视为极大型
return df_normalized
# 应用正向化
positive_cols = ['Math_Score', 'Physics_Score']
negative_cols = ['Time_Cost']
df_pos = data_direction_normalization(df, positive_cols, negative_cols)
print("\n正向化后的数据:")
print(df_pos)
```
此时,`Time_Cost`列变成了`Time_Cost_正向化`,其含义转变为“时间节约值”,值越大代表花费时间相对越少,越好。
### 3.2 第二步:数据标准化
不同指标的量纲和数量级不同(比如分数是几十到一百,而时间节约值可能只有几到十几),直接计算距离会被大数值的指标主导。标准化的目的就是消除量纲影响,使所有指标处于同一尺度。最常用的方法是**向量归一化(Z-score标准化也可行,但TOPSIS原文常用向量归一化)**。
公式为:对于矩阵中的每一个元素 \\( z_{ij} \\):
\\[ z_{ij} = \\frac{x_{ij}}{\\sqrt{\\sum_{i=1}^{m} x_{ij}^2}} \\]
其中,\\( i \\) 代表第 \\( i \\) 个学生(行),\\( j \\) 代表第 \\( j \\) 个指标(列)。
```python
def data_normalization(df_pos, indicator_cols):
"""
对正向化后的数据进行向量归一化标准化
:param df_pos: 正向化后的DataFrame
:param indicator_cols: 需要标准化的指标列名列表
:return: 标准化后的矩阵(numpy array)
"""
# 提取指标数据为numpy矩阵
X = df_pos[indicator_cols].values.astype(float)
# 向量归一化
# 对每一列(每个指标)计算其所有样本值的平方和,再开根号
norms = np.sqrt(np.sum(X**2, axis=0))
# 避免除以零
norms[norms == 0] = 1
Z = X / norms
return Z
# 确定标准化列(正向化后的所有指标列)
indicator_cols = ['Math_Score', 'Physics_Score', 'Time_Cost_正向化']
Z_matrix = data_normalization(df_pos, indicator_cols)
print("\n标准化矩阵 Z:")
print(Z_matrix)
```
标准化后,每个指标列的所有数值的平方和都为1,它们被“压缩”到了同一个相对尺度上。
### 3.3 第三步:确定正负理想解并计算距离
这是TOPSIS的灵魂步骤。在标准化矩阵 \\( Z \\) 中:
* **正理想解(Z+)**:由每个指标在所有样本中的最大值构成。因为所有指标都已正向化为极大型,所以就是取每列的最大值。
* **负理想解(Z-)**:由每个指标在所有样本中的最小值构成(即每列的最小值)。
```python
def calculate_ideal_solutions(Z):
"""
计算正理想解和负理想解
:param Z: 标准化矩阵
:return: Z_plus, Z_minus
"""
Z_plus = np.max(Z, axis=0) # 每列的最大值
Z_minus = np.min(Z, axis=0) # 每列的最小值
return Z_plus, Z_minus
def calculate_distances(Z, Z_plus, Z_minus):
"""
计算每个样本到正/负理想解的欧氏距离
:param Z: 标准化矩阵,形状 (m, n)
:param Z_plus: 正理想解,形状 (n,)
:param Z_minus: 负理想解,形状 (n,)
:return: D_plus, D_minus (每个样本的距离)
"""
# 利用广播机制计算差值
diff_plus = Z - Z_plus # (m,n) - (n,) -> (m,n)
diff_minus = Z - Z_minus # (m,n) - (n,) -> (m,n)
# 计算欧氏距离:对每个样本(行)的差值平方和再开方
D_plus = np.sqrt(np.sum(diff_plus**2, axis=1))
D_minus = np.sqrt(np.sum(diff_minus**2, axis=1))
return D_plus, D_minus
Z_plus, Z_minus = calculate_ideal_solutions(Z_matrix)
D_plus, D_minus = calculate_distances(Z_matrix, Z_plus, Z_minus)
print("\n正理想解 Z+:", Z_plus)
print("负理想解 Z-:", Z_minus)
print("\n各样本到正理想解的距离 D+:", D_plus)
print("各样本到负理想解的距离 D-:", D_minus)
```
### 3.4 第四步:计算综合得分并排序
最后,计算每个样本的相对贴近度 \\( C_i \\):
\\[ C_i = \\frac{D_i^-}{D_i^+ + D_i^-} \\]
\\( C_i \\) 的值介于0和1之间。\\( C_i \\) **越大**,说明该样本越接近正理想解,同时远离负理想解,综合表现越好。
```python
def calculate_scores(D_plus, D_minus):
"""
计算相对贴近度(综合得分)
:param D_plus: 到正理想解的距离
:param D_minus: 到负理想解的距离
:return: 综合得分数组
"""
# 避免分母为零
denominator = D_plus + D_minus
denominator[denominator == 0] = 1e-10
scores = D_minus / denominator
return scores
scores = calculate_scores(D_plus, D_minus)
print("\n各样本综合得分:", scores)
# 将结果整合到原始DataFrame中,并排序
df_result = df.copy()
df_result['D+'] = D_plus
df_result['D-'] = D_minus
df_result['TOPSIS_Score'] = scores
df_result['Rank'] = df_result['TOPSIS_Score'].rank(ascending=False, method='min').astype(int)
print("\n最终评价结果:")
print(df_result.sort_values('Rank'))
```
运行完所有代码,你就能得到一份清晰的排名表。从我们的示例数据中,你可以分析为什么某个学生排名第一,是因为他各科均衡优秀,还是因为在“时间节约”上表现格外突出?TOPSIS给出了一个量化的综合视角。
## 4. 进阶讨论与实战技巧
掌握了基础版本,我们可以让这个工具变得更加强大和实用。
### 4.1 引入指标权重
基础的TOPSIS假设所有指标同等重要。但在现实中,数学成绩的权重可能高于物理,而“时间成本”的权重可能又有所不同。引入权重非常简单,只需在计算距离时,对每个指标的差值进行加权。
假设我们通过熵权法、AHP层次分析法或业务经验,得到了权重向量 \\( w = [w_1, w_2, ..., w_n] \\),满足 \\( \\sum w_j = 1 \\)。那么加权标准化矩阵 \\( V \\) 为:
\\[ V_{ij} = w_j \\times Z_{ij} \\]
随后,用矩阵 \\( V \\) 代替 \\( Z \\) 去计算正负理想解和距离即可。
```python
def weighted_topsis(Z, weights):
"""
执行带权重的TOPSIS计算
:param Z: 标准化矩阵
:param weights: 权重数组,形状 (n,)
:return: 加权后的得分
"""
# 确保权重和为1
weights = np.array(weights)
weights = weights / weights.sum()
# 计算加权标准化矩阵
V = Z * weights # 利用广播,每列乘以对应权重
# 计算加权后的理想解和距离
V_plus = np.max(V, axis=0)
V_minus = np.min(V, axis=0)
D_plus_w = np.sqrt(np.sum((V - V_plus)**2, axis=1))
D_minus_w = np.sqrt(np.sum((V - V_minus)**2, axis=1))
scores_w = D_minus_w / (D_plus_w + D_minus_w + 1e-10)
return scores_w
# 示例:假设权重为 [0.4, 0.4, 0.2] (数学,物理,时间)
weights = [0.4, 0.4, 0.2]
weighted_scores = weighted_topsis(Z_matrix, weights)
df_result['Weighted_Score'] = weighted_scores
df_result['Weighted_Rank'] = df_result['Weighted_Score'].rank(ascending=False, method='min').astype(int)
print("\n引入权重后的结果:")
print(df_result[['Name', 'TOPSIS_Score', 'Rank', 'Weighted_Score', 'Weighted_Rank']].sort_values('Weighted_Rank'))
```
比较加权前后的排名变化,你能直观感受到权重对评价结果的巨大影响。
### 4.2 处理中间型与区间型指标
除了极大型和极小型,实际问题中还会遇到中间型指标(如PH值,越接近7越好)和区间型指标(如体温,落在36-37.5度最好)。它们的正向化公式略有不同:
| 指标类型 | 特点 | 正向化公式(转换为极大型) |
| :--- | :--- | :--- |
| **中间型** | 值越接近某个最佳值 \\( x_{best} \\) 越好 | \\( 1 - \\frac{\|x_i - x_{best}\|}{\\max(\|x - x_{best}\|)} \\) |
| **区间型** | 值落在某个最佳区间 \\( [a, b] \\) 内最好 | 设 \\( M = \\max\\{a - \\min(x), \\max(x) - b\\} \\) <br> 则 \\( x_i' = \\begin{cases} 1 - \\frac{a-x_i}{M} & x_i < a \\\\ 1 & a \\le x_i \\le b \\\\ 1 - \\frac{x_i-b}{M} & x_i > b \\end{cases} \\) |
在代码中,你需要根据指标类型,在`data_direction_normalization`函数中增加相应的处理分支。这增加了代码的复杂度,但也大大提升了模型的适用性。
### 4.3 常见陷阱与调试建议
第一次实现TOPSIS,你可能会踩到一些坑:
1. **数据类型错误**:确保从DataFrame提取出的矩阵是`float`类型,整数除法可能导致错误。
2. **包含零或负值**:在使用`1/x`进行极小型指标正向化时,必须确保所有值为正。否则使用`max - x`更安全。
3. **权重和不为1**:虽然不影响排序结果,但保持权重归一化是良好习惯。
4. **距离计算维度错误**:仔细检查`np.sum(..., axis=1)`是对行求和,得到每个样本的距离。
5. **结果解释**:牢记TOPSIS分数是相对值。新加入一个样本,所有人的分数和排名都可能发生变化。
调试时,建议先用手算一个小样本(比如3个样本,2个指标),验证每一步的Python输出是否与手算结果一致。这是排查逻辑错误最有效的方法。
## 5. 封装与复用:打造你自己的TOPSIS分析工具
每次都重写一遍代码显然不现实。我们可以将上述所有步骤封装成一个完整的、健壮的类,方便在不同项目中调用。
```python
class TOPSIS:
"""
一个完整的TOPSIS评价模型实现类。
"""
def __init__(self, data, indicator_config, weights=None):
"""
初始化
:param data: pandas DataFrame,包含样本名和所有原始指标
:param indicator_config: dict, 配置每个指标的类型和参数。
例如:{'Math': ('max', None), 'Cost': ('min', None), 'pH': ('mid', 7)}
:param weights: list/array, 各指标权重,默认为等权重
"""
self.raw_data = data.copy()
self.config = indicator_config
self.weights = np.array(weights) if weights else None
self.normalized_matrix = None
self.scores = None
self.ranking = None
def _normalize_direction(self):
"""内部方法:指标正向化"""
df = self.raw_data.copy()
cols_to_normalize = list(self.config.keys())
df_normalized = df[cols_to_normalize].copy()
for col, (ind_type, param) in self.config.items():
if ind_type == 'max':
# 极大型,无需处理
pass
elif ind_type == 'min':
# 极小型
max_val = df_normalized[col].max()
df_normalized[col] = max_val - df_normalized[col]
elif ind_type == 'mid':
# 中间型,param为最佳值x_best
x_best = param
M = np.abs(df_normalized[col] - x_best).max()
df_normalized[col] = 1 - np.abs(df_normalized[col] - x_best) / M
elif ind_type == 'interval':
# 区间型,param为最佳区间[a, b]
a, b = param
M = max(a - df_normalized[col].min(), df_normalized[col].max() - b)
series = df_normalized[col]
cond_list = [series < a, (series >= a) & (series <= b), series > b]
func_list = [lambda x: 1 - (a - x)/M, lambda x: 1, lambda x: 1 - (x - b)/M]
df_normalized[col] = np.piecewise(series, cond_list, func_list)
return df_normalized.values
def _normalize_scale(self, X):
"""内部方法:向量归一化标准化"""
norms = np.sqrt(np.sum(X**2, axis=0))
norms[norms == 0] = 1
return X / norms
def evaluate(self):
"""执行完整的TOPSIS评价流程"""
# 1. 正向化
X_pos = self._normalize_direction()
# 2. 标准化
Z = self._normalize_scale(X_pos)
self.normalized_matrix = Z
# 3. 加权(如果提供了权重)
if self.weights is not None:
self.weights = self.weights / self.weights.sum()
V = Z * self.weights
else:
V = Z
# 4. 确定理想解
V_plus = V.max(axis=0)
V_minus = V.min(axis=0)
# 5. 计算距离
D_plus = np.sqrt(((V - V_plus) ** 2).sum(axis=1))
D_minus = np.sqrt(((V - V_minus) ** 2).sum(axis=1))
# 6. 计算得分
self.scores = D_minus / (D_plus + D_minus + 1e-10)
self.ranking = pd.Series(self.scores).rank(ascending=False, method='min').astype(int).values
# 整合结果
result_df = self.raw_data.copy()
result_df['TOPSIS_Score'] = self.scores
result_df['Rank'] = self.ranking
return result_df.sort_values('Rank')
def get_ideal_solutions(self):
"""获取正负理想解(基于加权或未加权的标准化矩阵)"""
if self.normalized_matrix is None:
raise ValueError("请先调用 evaluate() 方法。")
V = self.normalized_matrix if self.weights is None else self.normalized_matrix * self.weights
return V.max(axis=0), V.min(axis=0)
# 使用示例
if __name__ == '__main__':
# 准备数据和配置
data = pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie'],
'Profit': [80, 90, 60], # 极大型
'Cost': [12, 8, 15], # 极小型
'pH': [6.5, 7.2, 8.0] # 中间型,最佳值7
})
config = {
'Profit': ('max', None),
'Cost': ('min', None),
'pH': ('mid', 7)
}
weights = [0.5, 0.3, 0.2] # 利润,成本,pH的权重
# 创建模型并计算
model = TOPSIS(data, config, weights)
result = model.evaluate()
print(result)
```
这个`TOPSIS`类将配置、计算和结果封装在一起,你只需要准备好数据、定义好指标类型和权重,调用`evaluate()`方法就能得到一切。代码中处理了四种指标类型,并加入了权重功能,已经是一个相当实用的工具原型了。
在实际项目中,我习惯将这类工具类保存在单独的`utils`或`models`模块中。当需要分析新的数据集时,导入这个类,往往只需要调整配置字典,几分钟内就能跑出可靠的综合评价结果。这种将复杂算法封装成“黑箱”工具的能力,正是数据工程师价值的重要体现。