news 2026/10/3 14:56:18

K-Means在MNIST上的原理、实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
K-Means在MNIST上的原理、实现与避坑指南

简介:本资源是深圳大学计算机软件专业《最优化方法》课程配套实验材料,面向机器学习初学者与高校算法实践者,聚焦无监督学习核心任务——利用K-Means聚类实现MNIST手写数字图像的自动分组与结构发现。资源包共2个文件(1个可运行Python脚本+1份详细实验文档),总大小873KB,脚本完整实现数据加载、归一化预处理、肘部法则选K、聚类执行与结果可视化;文档则系统梳理算法原理、关键参数设计依据、scikit-learn调用细节及聚类效果评估方法。已有3177人学习下载,内容紧扣教学实验场景,代码模块清晰、注释详尽,附带可直接复现的绘图与分簇分析逻辑,特别适合理解K-Means在真实图像数据上的行为边界与应用局限,为后续监督分类打下扎实的无监督建模基础。

1. K-Means 聚类真能分清手写数字?——别急着调sklearn.cluster.KMeans,先搞懂它在 MNIST 上为什么“看起来准、实际懵”

你跑通了KMeans(n_clusters=10).fit(X_train),聚类结果轮廓系数 0.12,每个簇中心画出来像模像样,甚至用匈牙利算法匹配标签后准确率冲到 53%——恭喜,你成功复现了深大计软《最优化方法》实验1的经典翻车现场。这不是代码错了,而是你还没意识到:K-Means 在 MNIST 上不是分类器,它是个“无监督的形状归组器”。它不认“3 是三”,只认“这一坨像素和那一坨像素离得近”。当数字“1”和“7”在像素空间里挤在一起(比如都写得细长倾斜),K-Means 会毫不犹豫把它们划进同一个簇;而“4”和“9”如果都带圆弧+竖线,也可能被强行拆散。这不是算法缺陷,是欧氏距离 + 球形簇假设 + 无标签先验的必然结果。本实验的核心价值,从来不是“用聚类替代分类”,而是亲手推导目标函数、观察质心迭代如何卡在局部极小、验证维度灾难对距离失效的影响——这才是最优化方法课要锤你的点。适合人群:刚学完梯度下降、拉格朗日乘子,想把抽象公式落到像素矩阵上的计软本科生;或想搞懂“为什么无监督评估指标总比有监督低一截”的算法初学者。别指望靠它上 Kaggle,但如果你能手动实现 E-step/M-step 并画出每轮质心漂移轨迹,你对“迭代优化”的理解就落地了。


2. 从零推导 K-Means 目标函数:为什么最小化平方误差等价于最大似然?

K-Means 表面是“找 10 个中心点让所有样本离最近中心最近”,但它的数学根基藏在概率模型里。我们不直接讲 EM 算法,而是从最优化视角拆解:它本质是在求解一个带隐变量的非凸优化问题。先明确符号:设训练集 $X = {x^{(1)}, x^{(2)}, ..., x^{(N)}}$,其中 $x^{(i)} \in \mathbb{R}^D$(MNIST 是 784 维);簇中心为 $\mu_1, \mu_2, ..., \mu_K \in \mathbb{R}^D$;隐变量 $z^{(i)} \in {1,2,...,K}$ 表示第 $i$ 个样本所属簇。K-Means 的目标函数是:

$$ J(\mu, z) = \sum_{i=1}^{N} \sum_{k=1}^{K} \mathbb{1}(z^{(i)} = k) \cdot |x^{(i)} - \mu_k|^2 $$

这个式子直白说就是:对每个样本,只算它到自己所属簇中心的距离平方,再全部加起来。注意两点:第一,$z^{(i)}$ 是离散变量,无法求导;第二,$J$ 关于 $\mu_k$ 是凸的,但关于 $z^{(i)}$ 完全非凸。所以标准解法是坐标下降(Coordinate Descent):固定 $z$ 更新 $\mu$,再固定 $\mu$ 更新 $z$,交替进行直到收敛。这正是 K-Means 的两步迭代逻辑。

2.1 E-step:给定质心,分配样本到最近簇(硬分配)

这步不需要概率,纯几何。对每个样本 $x^{(i)}$,计算它到所有 $K$ 个质心的欧氏距离平方,选最小的那个:

# 手动实现 E-step(不用 sklearn) import numpy as np def e_step(X, mu): """ X: (N, D) 样本矩阵 mu: (K, D) 质心矩阵 返回: (N,) 每个样本的簇索引数组 """ N, D = X.shape K, _ = mu.shape # 向量化计算所有距离平方:避免 for 循环 # 利用 (a-b)^2 = a^2 - 2ab + b^2 展开 X_sq = np.sum(X**2, axis=1, keepdims=True) # (N, 1) mu_sq = np.sum(mu**2, axis=1, keepdims=True) # (K, 1) cross = 2 * X @ mu.T # (N, K) # dist_sq[i, k] = ||x_i - mu_k||^2 dist_sq = X_sq - cross + mu_sq.T # (N, K) return np.argmin(dist_sq, axis=1) # (N,) # 验证:用 MNIST 前 1000 张图测试 from sklearn.datasets import fetch_openml mnist = fetch_openml('mnist_784', version=1, as_frame=False, parser='auto') X, y = mnist.data.astype('float32'), mnist.target.astype('int') X_sample = X[:1000] / 255.0 # 归一化到 [0,1] y_sample = y[:1000] # 随机初始化 10 个质心(均匀分布) np.random.seed(42) mu_init = np.random.rand(10, 784) z_pred = e_step(X_sample, mu_init) print(f"初始分配:簇大小分布 {np.bincount(z_pred, minlength=10)}") # 输出类似:[112 98 105 92 101 95 103 97 99 98] —— 基本均匀

参数说明:e_step中的dist_sq计算用了向量化技巧,避免for i in range(N): for k in range(K):的 O(NKD) 复杂度。关键在X_sq - cross + mu_sq.T这一行:X_sq是每个样本自身模长平方,mu_sq.T是每个质心模长平方转置,cross是内积矩阵。这样一次广播运算就得到全部距离平方,速度提升 100 倍以上。新手常在这里写嵌套循环,跑 MNIST 直接卡死。

2.2 M-step:给定分配,更新质心为簇内均值(解析解)

这步是目标函数 $J$ 关于 $\mu_k$ 的最小化。对固定 $k$,只看属于簇 $k$ 的样本集合 $C_k = {i | z^{(i)} = k}$,则:

$$ \frac{\partial J}{\partial \mu_k} = -2 \sum_{i \in C_k} (x^{(i)} - \mu_k) = 0 \quad \Rightarrow \quad \mu_k = \frac{1}{|C_k|} \sum_{i \in C_k} x^{(i)} $$

看到没?质心更新就是取簇内样本均值——这是唯一解析解,不用梯度下降。这也是 K-Means 收敛快的原因:M-step 是闭式解。

def m_step(X, z, K): """ X: (N, D) 样本 z: (N,) 簇索引 K: 簇数 返回: (K, D) 新质心矩阵 """ N, D = X.shape mu_new = np.zeros((K, D)) for k in range(K): # 找出属于簇 k 的所有样本 mask = (z == k) if np.sum(mask) > 0: # 防止空簇 mu_new[k] = np.mean(X[mask], axis=0) else: # 空簇处理:重采样一个随机样本作为新质心(常见策略) mu_new[k] = X[np.random.randint(0, N)] return mu_new # 测试 M-step z_init = e_step(X_sample, mu_init) mu_updated = m_step(X_sample, z_init, K=10) print(f"更新后质心形状: {mu_updated.shape}") # (10, 784)

关键细节:m_step中必须处理空簇(empty cluster)。如果某轮 E-step 后某个簇没分到任何样本,np.mean会报错或返回全零向量,导致后续迭代崩溃。这里采用“重采样随机样本”策略,也有用“距其他质心最远点”或“最大方差方向切分”的,但对 MNIST 来说,随机重采样最稳定。这是实操中第一个血泪经验:永远检查np.bincount(z)是否有 0。


3. 手动实现完整 K-Means 迭代:监控损失下降、可视化质心漂移、验证收敛性

现在把 E-step 和 M-step 串起来,加上收敛判断和日志。重点不是“跑出结果”,而是看见优化过程本身——这才是最优化方法课的灵魂。我们监控三个量:目标函数值 $J$、质心移动距离、簇分配变化率。

3.1 主循环:带收敛判断与日志的迭代框架

def kmeans_manual(X, K, max_iters=100, tol=1e-4, random_state=42): """ 手动实现 K-Means 完整流程 X: (N, D) 归一化后的样本(MNIST 已除以 255) K: 簇数(对 MNIST 固定为 10) max_iters: 最大迭代次数 tol: 损失变化容忍阈值 返回: mu_history (list of (K,D)), z_history (list of (N,)), losses (list of float) """ np.random.seed(random_state) N, D = X.shape # 初始化质心:用 K-means++ 策略(比随机好得多) mu = np.zeros((K, D)) # 第一个质心随机选 mu[0] = X[np.random.randint(0, N)] # 后续质心按距离平方概率选 for k in range(1, K): # 计算所有点到已选质心的最小距离平方 dist_sq_to_mu = np.full(N, np.inf) for i in range(k): dist_sq = np.sum((X - mu[i])**2, axis=1) dist_sq_to_mu = np.minimum(dist_sq_to_mu, dist_sq) # 概率正比于 dist_sq_to_mu probs = dist_sq_to_mu / np.sum(dist_sq_to_mu) mu[k] = X[np.random.choice(N, p=probs)] mu_history = [mu.copy()] z_history = [] losses = [] for it in range(max_iters): # E-step z = e_step(X, mu) z_history.append(z) # 计算当前损失 J loss = 0.0 for k in range(K): mask = (z == k) if np.sum(mask) > 0: loss += np.sum((X[mask] - mu[k])**2) losses.append(loss) # M-step mu_new = m_step(X, z, K) # 检查收敛:质心移动距离 < tol mu_shift = np.max(np.sqrt(np.sum((mu_new - mu)**2, axis=1))) mu_history.append(mu_new.copy()) mu = mu_new # 打印进度 if it % 10 == 0 or it == max_iters-1: print(f"Iter {it:3d} | Loss: {loss:.2e} | Max shift: {mu_shift:.4f}") if mu_shift < tol: print(f"Converged at iteration {it}") break return mu_history, z_history, losses # 在 MNIST 子集上运行 mu_hist, z_hist, losses = kmeans_manual(X_sample, K=10, max_iters=50, tol=1e-3)

为什么用 K-means++ 初始化?随机初始化可能导致质心全挤在数字“1”的区域,其他数字如“8”、“6”永远分不到簇。K-means++ 通过距离加权采样,强制质心分散,通常减少 30% 迭代次数。dist_sq_to_mu的计算是核心:对每个未选点,算它到所有已选质心的最小距离平方,再以此为权重抽样。这是工业级实现的标配,不是炫技。

3.2 可视化质心演化:从噪声到“数字雏形”的 30 轮旅程

K-Means 的质心不是静态图片,是动态优化的产物。我们把每轮质心 reshape 成 28x28 并画出来:

import matplotlib.pyplot as plt def plot_centroids_evolution(mu_history, n_cols=5, figsize=(12, 8)): """ 绘制质心随迭代的变化 mu_history: list of (K, 784) 质心矩阵 """ n_iters = len(mu_history) n_rows = (n_iters + n_cols - 1) // n_cols fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize) axes = axes.flatten() if n_iters > 1 else [axes] for i, mu in enumerate(mu_history): if i >= len(axes): break # 取第一个质心(索引 0)为例,展示其变化 # 实际可循环画所有 10 个,但这里简化 img = mu[0].reshape(28, 28) axes[i].imshow(img, cmap='gray') axes[i].set_title(f'Iter {i}') axes[i].axis('off') # 隐藏多余子图 for j in range(i+1, len(axes)): axes[j].remove() plt.tight_layout() plt.show() # 运行绘图 plot_centroids_evolution(mu_hist)

你会看到:第 0 轮是随机噪声块;第 5 轮开始出现模糊的竖线/横线;第 15 轮能辨认出“1”、“0”的轮廓;第 30 轮基本稳定,但和真实数字仍有差距——因为 K-Means 学的是像素均值,不是笔画结构。这就是最优化的真相:它在约束下逼近局部最优,而非生成完美图像。

3.3 监控损失曲线与收敛诊断:为什么有时损失不降反升?

画出losses曲线:

plt.figure(figsize=(10, 4)) plt.plot(losses, 'b-o', markersize=3) plt.xlabel('Iteration') plt.ylabel('Objective J (Sum of Squared Errors)') plt.title('K-Means Loss Curve on MNIST Subset') plt.grid(True) plt.show()

正常情况是单调下降。但如果出现震荡或上升,一定是代码 bug。常见原因:

  • e_step中距离计算错误(比如忘了平方,或用了曼哈顿距离)
  • m_step中空簇未处理,导致mu_new[k]为全零,下轮e_step计算距离时||x_i - 0||^2极大
  • 初始化质心超出数据范围(如用了np.random.randn未归一化)

收敛性验证口诀:K-Means 保证每轮 $J$ 不增,但不保证全局最优。若losses曲线非单调,立刻检查e_step的dist_sq公式——90% 的问题出在这里。


4. 避坑:MNIST 上 K-Means 的 4 个经典翻车现场与急救方案

K-Means 在 MNIST 上不是不能跑,而是处处是坑。这些坑不来自算法本身,而来自数据特性与实现细节的碰撞。以下是我在深大计软助教三年批改上百份实验报告总结的最高频、最隐蔽、最致命的四个问题,每条都附带现象、根因和一行修复代码。

4.1 现象:迭代 50 轮后,某个簇的样本数为 0,后续m_step报RuntimeWarning: Mean of empty slice

  • 原因:E-step 分配时,若某质心离所有样本都远,可能没分到任何点;M-step 中np.mean(X[mask])对空数组返回nan,污染后续计算。
  • 解决:在m_step中强制检查空簇,并用安全策略重置质心。不要用np.nanmean(它返回nan,不解决问题):
# 错误示范(引发连锁 nan) # mu_new[k] = np.mean(X[mask], axis=0) # mask 全 False → nan # 正确修复(在 m_step 函数内) if np.sum(mask) == 0: # 方案1:重采样随机样本(推荐,简单鲁棒) mu_new[k] = X[np.random.randint(0, N)] # 方案2:用所有样本的均值(更稳定,但可能偏移) # mu_new[k] = np.mean(X, axis=0)

4.2 现象:质心图像全是灰色块(像素值集中在 0.4~0.6),完全看不出数字形状

  • 原因:MNIST 像素是uint8(0~255),但你没归一化!KMeans对量纲敏感,未归一化的像素值(0~255)会让距离计算被高亮区域主导,质心被拉向平均灰度。
  • 解决:必须在输入前除以 255,缩放到 [0,1]:
# 错误:直接喂原始数据 # X_raw = mnist.data # shape (70000, 784), dtype uint8 # 正确:归一化是铁律 X_normalized = mnist.data.astype('float32') / 255.0 # 验证:print(X_normalized.min(), X_normalized.max()) → 应输出 0.0 1.0

4.3 现象:e_step运行极慢(>10 秒),CPU 占用 100%,dist_sq计算卡死

  • 原因:写了双重 for 循环计算距离,复杂度 O(NKD),MNIST N=70000, K=10, D=784 → 54.88 亿次运算。
  • 解决:用向量化公式X_sq - 2*X@mu.T + mu_sq.T,一行替代循环:
# 错误(慢如蜗牛) # dist_sq = np.zeros((N, K)) # for i in range(N): # for k in range(K): # dist_sq[i,k] = np.sum((X[i] - mu[k])**2) # 正确(毫秒级) X_sq = np.sum(X**2, axis=1, keepdims=True) # (N, 1) mu_sq = np.sum(mu**2, axis=1, keepdims=True) # (K, 1) dist_sq = X_sq - 2 * X @ mu.T + mu_sq.T # (N, K) 广播

4.4 现象:用匈牙利算法匹配簇标签后,准确率只有 10%~20%,远低于预期

  • 原因:你匹配的是“簇 ID”和“数字标签”,但 K-Means 的簇 ID 是任意的(0~9),而数字标签 0~9 有语义。若簇 0 匹配到数字 5,簇 1 匹配到数字 0,直接按 ID 对齐当然错。必须用二分图匹配(Hungarian Algorithm)找最优映射。
  • 解决:用scipy.optimize.linear_sum_assignment计算最佳匹配:
from scipy.optimize import linear_sum_assignment from sklearn.metrics import confusion_matrix def calculate_matching_accuracy(z_pred, y_true, K=10): """ z_pred: (N,) 预测簇标签 y_true: (N,) 真实数字标签(0~9) 返回: 匹配后的准确率 """ # 构建混淆矩阵:行=簇ID,列=真实标签 cm = confusion_matrix(y_true, z_pred, labels=range(K)) # Hungarian 算法找最大匹配 row_ind, col_ind = linear_sum_assignment(-cm) # 最大化,故取负 # 计算匹配总正确数 total_correct = cm[row_ind, col_ind].sum() return total_correct / len(y_true) acc = calculate_matching_accuracy(z_hist[-1], y_sample) print(f"Matching Accuracy: {acc:.3f}") # 正常应 0.5~0.6

提示:匹配准确率 50%~60% 是 MNIST 上 K-Means 的合理上限。别追求 95%——那属于有监督学习的领域。K-Means 的价值在于无标签下的结构发现,不是替代分类器。


5. 进阶验证:用轮廓系数、Calinski-Harabasz 指标量化聚类质量,并对比 K=5 vs K=10

K-Means 的“好坏”不能只看准确率(毕竟它没标签)。我们要用无监督评估指标来回答:当前 K=10 是最优的吗?质心真的分开了吗?这里用两个黄金指标:轮廓系数(Silhouette Score)和 Calinski-Harabasz 指数。

5.1 轮廓系数:衡量“簇内紧密度 vs 簇间分离度”

对每个样本 $i$,定义:

  • $a(i)$:$i$ 到同簇其他点的平均距离(簇内不相似度)
  • $b(i)$:$i$ 到最近其他簇所有点的平均距离(簇间不相似度)
  • 轮廓值 $s(i) = \frac{b(i)-a(i)}{\max(a(i),b(i))} \in [-1,1]$

$s(i)$ 接近 1 表示样本聚得好,接近 -1 表示分错簇。平均轮廓系数越高,聚类越优。

from sklearn.metrics import silhouette_score # 计算最终聚类的轮廓系数 silhouette_avg = silhouette_score(X_sample, z_hist[-1]) print(f"Average Silhouette Score: {silhouette_avg:.3f}") # 典型值:K=10 时约 0.10~0.15;K=5 时可能升到 0.18(因为簇更大,更易分离)

为什么 MNIST 的轮廓系数这么低?因为数字“4”和“9”、“3”和“8”在像素空间本就相似(都含圆弧+直线),K-Means 无法用线性边界分开它们。低轮廓系数恰恰证明了:数据本身的可分性有限,不是算法不行。

5.2 Calinski-Harabasz 指数:簇间离散度 / 簇内离散度

公式:$CH = \frac{Tr(B_k)}{Tr(W_k)} \times \frac{N-K}{K-1}$,其中 $B_k$ 是簇间散度矩阵,$W_k$ 是簇内散度矩阵。值越大越好。

from sklearn.metrics import calinski_harabasz_score ch_score = calinski_harabasz_score(X_sample, z_hist[-1]) print(f"Calinski-Harabasz Score: {ch_score:.0f}") # K=10 时典型值:500~800;K=5 时可能达 1200+(簇少,簇间距离更大)

5.3 系统性对比:K 从 3 到 15 的指标曲线

真正体现最优化思维的,是做超参扫描。我们画出不同 K 下的指标:

K_range = range(3, 16) sil_scores = [] ch_scores = [] for K_test in K_range: print(f"\nTesting K={K_test}...") mu_hist_t, z_hist_t, _ = kmeans_manual( X_sample, K=K_test, max_iters=30, tol=1e-3, random_state=42 ) z_final = z_hist_t[-1] sil = silhouette_score(X_sample, z_final) ch = calinski_harabasz_score(X_sample, z_final) sil_scores.append(sil) ch_scores.append(ch) print(f" Silhouette: {sil:.3f}, CH: {ch:.0f}") # 绘图 plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(K_range, sil_scores, 'bo-') plt.xlabel('Number of Clusters (K)') plt.ylabel('Silhouette Score') plt.title('Silhouette Score vs K') plt.grid(True) plt.subplot(1, 2, 2) plt.plot(K_range, ch_scores, 'ro-') plt.xlabel('Number of Clusters (K)') plt.ylabel('Calinski-Harabasz Score') plt.title('CH Score vs K') plt.grid(True) plt.tight_layout() plt.show()

你会看到:轮廓系数在 K=5~7 达到峰值(约 0.18),之后缓慢下降;CH 指数则随 K 增加持续上升(因为分更多簇,簇内更紧凑)。这揭示了最优化的本质矛盾:没有唯一“最优 K”,只有根据目标权衡的选择。若你关心簇内一致性,选 K=6;若你需精细区分(如“手写体变体分析”),可选 K=12,接受更低的轮廓系数。

我带实验课时,常让学生交两份报告:一份用 K=10(满足实验要求),一份用 K=6(指标最优)。后者往往能画出更清晰的质心图像——因为 6 个簇天然对应“直线型(1,7)、圆弧型(0,6,8,9)、三角型(3,4,5)”等粗粒度结构。这比死磕 K=10 更体现对数据的理解。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 14:54:46

锂电池仿真MATLAB代码实战:从等效电路模型到SOC估计

简介&#xff1a;面向电池研究者、高校学生及BMS开发人员&#xff0c;这份MATLAB/Simulink锂电池仿真代码包可快速搭建电池模型并模拟充放电行为&#xff0c;解决从电芯到电池组的建模与参数设置难题&#xff0c;适合课程设计、科研预研及电池管理系统算法验证。资源覆盖电化学…

作者头像 李华
网站建设 2026/10/3 14:54:05

SAP序列号管理与GMP合规深度解析

1. 为什么医药企业一上线SAP序列号管理就触发GMP审计警报&#xff1f;我第一次在华东某TOP5生物制药企业做SAP序列号模块上线支持时&#xff0c;客户质量部负责人直接把GMP附录《计算机化系统》第12条拍在桌上&#xff1a;“任何影响产品质量的电子记录必须可追溯、不可篡改、完…

作者头像 李华
网站建设 2026/10/3 14:54:03

Hadoop+Spark+Django电力能耗数据分析系统实战与排错经验

做这类“Hadoop Spark Django 电力能耗数据分析系统”的课题&#xff0c;光看标题会觉得东西不少&#xff0c;真上手做一遍才发现&#xff0c;难点其实不在某个单一技术&#xff0c;而在怎么把“数据落地—离线计算—接口服务—大屏展示”这一整条链路串起来。我前前后后完整…

作者头像 李华
网站建设 2026/10/3 14:53:59

ESTARFM时空融合模型:Python实现MODIS和Landsat高时空分辨率地表反射率

简介&#xff1a;ESTARFM&#xff08;增强时空自适应反射率融合模型&#xff09;是遥感影像融合中的经典算法&#xff0c;尤其适用于复杂异构地表区域的反射率重建。这套以Python语言实现的代码包面向地理信息科学和遥感研究人员&#xff0c;旨在通过融合多时相影像解决单一传感…

作者头像 李华
网站建设 2026/10/3 14:53:14

麻雀搜索算法SSA与SCSSA复现全解析:从原理到代码实现

时间回到某天凌晨&#xff0c;我在翻一篇新出的元启发式算法论文时&#xff0c;偶然看到麻雀搜索算法这个名字。刚开始我以为又是某个把动物行为包装成论文的“灌水套路”&#xff0c;但仔细读了两遍之后发现&#xff0c;这个算法的行为机制跟粒子群、遗传算法完全不是一个路数…

作者头像 李华
网站建设 2026/10/3 14:52:49

Express框架深入解析:中间件机制、工程化实践与避坑指南

先说结论&#xff1a;Express 是 Node.js 生态里生命力最强的 Web 框架&#xff0c;没有之一。它不 fancy&#xff0c;也不“全栈”&#xff0c;但它用极简的中间件模型&#xff0c;把 HTTP 请求处理这件事拆得明明白白&#xff0c;以至于后来一大堆框架——包括 NestJS、Fasti…

作者头像 李华