🏆 第六届计图 AI 挑战赛 · 赛道一 · 参赛团队出品
让每一次本地实验都有意义。 精确离线估算线上 MRR,不打盲猜的提交。
本工具与 Jittor 框架完全兼容。三个核心脚本都只依赖 NumPy/Pandas—— 你的 Jittor 模型只需输出一个
[Q, 100]的 numpy 数组,就能无缝接入评分。
目标读者:从未接触过时序图竞赛的初学者。 读完这一部分,你应该能独立完成从数据到 MRR 评分的完整流程。
假设你参加了一个 AI 竞赛——就像 Kaggle 或计图挑战赛。任务是时序图链接预测:
给定一个用户 ID (src) 和一个时间点 (time),评委给你 100 个候选商品 ID (dst), 你需要从中找出真正会发生交互的那一个。
你训练了一个深度学习模型(CRAFT、GCN、Transformer,随你喜欢),每天提交到平台。平台给你返回一个数字——你的 MRR 总分。这个数字在 0.93 到 1.52 之间波动。
问题来了:你花了两天改进模型,提交上去,分数反而降了 0.003。你不知道是改进方向不对,还是随机波动。你一天只有 10 次提交机会。你的 deadline 还有三周。
你需要一个办法,在本地就能知道模型的真实水平。
这就是 OpenJittor 做的事情。
不要被名字吓到。它就是一个简单的"猜对了加多少分":
对每一个查询(总共 Q 个):
├─ 你的模型给 100 个候选 dst 打分(分数越高 = 越可能是真值)
├─ 按分数从高到低排序
├─ 看看真值 dst 排在第几名
└─ 这个查询的得分 = 1 / 排名
MRR = 所有查询得分的平均值
用表格理解:
| 查询编号 | 候选数 | 真值 dst 排名 | 该查询得分 (1/rank) |
|---|---|---|---|
| 1 | 100 | 第 1 名 | 1/1 = 1.000 |
| 2 | 100 | 第 3 名 | 1/3 = 0.333 |
| 3 | 100 | 第 10 名 | 1/10 = 0.100 |
| 4 | 100 | 第 50 名 | 1/50 = 0.020 |
| 5 | 100 | 第 100 名 | 1/100 = 0.010 |
| 平均 | MRR = 0.293 |
几个关键数字帮你建立直觉:
| 场景 | MRR | 含义 |
|---|---|---|
| 完美模型 | 1.000 | 所有查询真值排第 1 |
| 强模型 | 0.45-0.60 | 真值通常在前 3 |
| 中等模型 | 0.20-0.40 | 真值通常在前 10 |
| 纯随机猜测 | ≈0.052 | 100 候选,期望排名≈50.5 |
| 比随机还差 | <0.052 | 模型学到了反方向 |
大多数时序图竞赛会给你两个数据集:
- D1 (dataset1):一般图,重复边多(~73%),偏"记忆"
- D2 (dataset2):二分图,冷启动多(~54%),偏"推理"
总分 = D1_MRR + D2_MRR。本工具主要面向 D2 设计,D1 同理可类推。
- 热 dst:在训练集中出现过的商品 → 模型见过它,有 embedding
- 冷 dst:从未在训练集中出现的商品 → 模型从未见过,embedding 是随机初始化的
冷 dst 是时序图竞赛的核心难度。D2 中约 54% 的候选 dst 是冷的——你的模型对它们一无所知。
你拿到了 train.csv:
src,dst,time
1,100,1620000000
2,200,1620000001
1,300,1620000002
3,100,1620000003
...你想:"取最后 15% 的数据做验证,随机采样 99 个没见过的 dst 做负样本。"
于是你写了这样的代码:
# ❌ 错误做法 —— 会让你被骗得很惨
# 1. 简单切分
n_val = int(len(df) * 0.15)
train = df.iloc[:-n_val]
val = df.iloc[-n_val:]
# 2. 从训练集 dst 中随机采负样本
unique_dst = train['dst'].unique() # ≈ 50,640 个
for each val query:
negatives = np.random.choice(unique_dst, 99)
# 3. 评估
mrr = compute_mrr(predictions, ground_truth)
print(f"本地 MRR = {mrr:.4f}") # → 0.99!太好了!提交后:
线上 MRR = 0.50 ← 怎么回事?!
问题出在三个地方:
你的本地验证中,99 个负样本从 unique(train_dst) 抽取(约 50K 个热 dst)。
但线上测试的候选池有 110K 个唯一 dst,其中 54% 是冷 dst(训练集从未出现)。
冷 dst 在你的本地验证中完全不存在 → 你的模型不需要区分它们 → 本地 MRR 虚高。
PopTrack(一个只查"历史频次表"的零参数方法)在这个错误验证集上 MRR = 0.99。 提交到线上:0.50。
假设训练数据从 2020 年到 2024 年。你取最后 15% 做验证。但如果你在"热度"特征中使用了 2024 年的数据——你就在用验证期的信息"预测"验证期。
我们第一版 markov_val.py 犯了这个错误:
- 泄漏版 MRR = 0.6254 ✨
- 修复后 MRR = 0.4467 💀
凭空蒸发了 0.18。
纯随机负采样让模型可以靠"猜热门商品"拿分。线上的负样本是精心构造的,包含大量冷 dst——难度远高于你的本地验证。
💡 核心教训:如果你的零参数 baseline(如 PopTrack)在验证集上接近满分,你的验证集有问题。
我们设计的 val-hot 基准精确解决了上述三个问题。核心思想非常简单:用 test.csv 中已有的信息来模拟评测机制。
时间轴
│········ train' (信息截止前) ········│·· val (验证期) ··│
↑ ↑ ↑
训练集起始 cut (信息截止点) 训练集结束
↑
测试集时间跨度 = val 时间跨度
三个关键操作:
| # | 操作 | 为什么这样做 |
|---|---|---|
| ① | 候选池 = test.csv 的所有候选列的唯一 dst | 精确复现评测分布,包括 54% 冷 dst |
| ② | val 期长度 = test.csv 的时间跨度 | 模拟评测的时间尺度 |
| ③ | 99 负样本 = 均匀随机采样 (有碰撞检测) | 与评测的负采样机制保持一致 |
数据流图:
train.csv ──→ [时间切分] ──→ train' (前 85%) ──→ 检查 cold-at-train' ──→ val_coldmask.npy
│ val (后 348 天) ──→ 构造候选 ──→ val_hot.csv
│ ↑
test.csv ──→ [提取候选池] ──→ 110,368 唯一 dst ──────────┘
一个具体的查询长什么样:
val_hot.csv 第 42 行:
src=1523, time=1719000000, dst=8842(真值), n0=5001, n1=99999, ..., n98=12345
↑
这 99 个是从 test.csv 候选池
均匀采样来的负样本
重要边界:val-hot 只能衡量热 dst 排序。 所有 val 真值 dst 都在 train' 中出现过(是热的)。冷 dst 的表现需要线上提交验证——这不是工具 bug,而是"不能在本地获取测试真值"的数学限制。
你需要竞赛方提供的两个文件:
data/dataset2/
├── train.csv ← 训练数据: src, dst, time
└── test.csv ← 测试数据: src, time, dst, n0, n1, ..., n98
test.csv 有 102 列:
src,time,dst:查询的源节点、时间、真值 dst(这一列是你最终要预测的)n0到n98:99 个候选 dst(你需要对它们排序)
python build_val.py \
--train data/dataset2/train.csv \
--test data/dataset2/test.csv \
--out data/dataset2/train' (信息截止前): 2,038,500 条边
val (验证期) : 222,783 条边
测试集时间跨度 : 348 天
cold-at-train' 真值比例: 0.300
测试候选池大小: 110,368 个唯一 dst
✓ 验证基准已保存:
data/dataset2/val_hot.csv (222,783 查询 × 101 列)
data/dataset2/val_coldmask.npy (cold-at-train' 掩码)
耗时: 12.3s
| 输出文件 | 格式 | 内容 |
|---|---|---|
val_hot.csv |
CSV, Q行×101列 | src, time, dst(真值), n0..n98 |
val_coldmask.npy |
bool数组 [Q] | True = 该查询的真值 dst 在 train' 中从未出现 |
- cold-at-train' 比例 ≈ 30% 是正常的。这些查询的真值 dst 虽然在 train' 中没出现,但在 val 期中出现了——它们模拟的是"训练期结束后新出现的交互模式"。
- 如果比例为 0%,说明测试集时间跨度可能算错了。如果比例 > 50%,说明你的训练数据覆盖度很低。
- 构造耗时约 10-15 秒(22 万查询),内存占用约 500MB。
你的模型需要读取 val_hot.csv,对每一行的 100 个候选 dst 打分,输出一个 [Q, 100] 的分数矩阵。
唯一的要求:列 0 必须是真值 dst 的分数。
import jittor as jt
import numpy as np
import pandas as pd
# 加载验证数据
vd = pd.read_csv('data/dataset2/val_hot.csv')
candidates = vd.iloc[:, 2:].values.astype(np.int64) # [Q, 100], 列0=真值
# 加载你的模型
model = YourJittorModel()
model.load_state_dict(jt.load('your_checkpoint.pt'))
model.eval()
# 推理
scores = np.zeros((len(vd), 100), dtype=np.float32)
batch_size = 500
for st in range(0, len(vd), batch_size):
en = min(st + batch_size, len(vd))
batch_cand = candidates[st:en]
batch_src = vd.iloc[st:en, 0].values.astype(np.int64)
# ★ Jittor 前向
with jt.no_grad():
output = model(
jt.array(batch_src),
jt.array(batch_cand)
)
# ★ 关键: Jittor.Var → NumPy
scores[st:en] = output.numpy()
# 保存
np.save('data/dataset2/my_model_scores.npy', scores)Jittor → NumPy 的桥梁只有一行:output.numpy()。 除此之外没有框架依赖。
# 完全一样的工作流,只是把 jt.array 换成 torch.from_numpy
import torch
# ...
with torch.no_grad():
output = model(torch.from_numpy(batch_src), torch.from_numpy(batch_cand))
scores[st:en] = output.cpu().numpy()sc = np.load('data/dataset2/my_model_scores.npy')
print(sc.shape) # 应该是 (222783, 100)
print(sc.dtype) # float32 或 float64
print(sc[0, 0]) # 第一个查询的真值分数
print(sc[0, 1:5]) # 前 4 个负样本的分数python score_val.py \
--val data/dataset2/ \
--scores data/dataset2/my_model_scores.npy验证基准: data/dataset2/val_hot.csv
查询数: 222,783
Cold-at-train' 真值: 30.0% (66,835/222,783)
已加载: my_model_scores shape=(222783, 100)
===============================================================
单模型评分
===============================================================
[my_model_scores]
──────────────────────────────────────────────
查询数: 222,783 (hot-true: 155,948, cold-true: 66,835)
──────────────────────────────────────────────
MRR (all) = 0.4598 ← 线上 D2 热分量估算器
MRR (hot-true) = 0.6123 (仅热真值查询)
MRR (cold-true) = 0.1043 (冷真值查询, 随机基线≈0.052)
──────────────────────────────────────────────
真值排名分位数:
P10=1 P25=1 P50=3 P75=12 P90=42
Top-1 Acc: 0.2750
Top-3 Acc: 0.5550
Top-10 Acc: 0.8660
| 指标 | 含义 | 判断标准 |
|---|---|---|
| MRR(all) | 全部 val 查询的 MRR | 最重要的数字。 比线上 D2 热分量略高 |
| MRR(hot-true) | 仅热真值查询 | 模型的热排序纯能力。> 0.55 算好 |
| MRR(cold-true) | 仅冷真值查询 | ≈ 0.05 正常(随机),> 0.10 说明模型有冷启动能力 |
| P50 Rank | 真值排名中位数 | < 3 优秀,3-10 一般,> 10 差 |
| Top-1 Acc | 真值排第 1 的比例 | 与 MRR 强相关 |
| Top-10 Acc | 真值在前 10 的比例 | 宽松指标。> 0.85 说明"至少不会完全跑偏" |
如果你有多个模型的分数,想知道平均融合能不能涨分:
python score_val.py \
--val data/dataset2/ \
--scores model_a.npy model_b.npy model_c.npy \
--blend输出会多一段融合评分,告诉你简单平均后 MRR 是多少。
python score_val.py \
--val data/dataset2/ \
--score-dir data/dataset2/scores/Val-hot 的 MRR 变化能可靠预测线上 D2 的方向和大致幅度。我们用一个经验公式:
Δ线上D2 ≈ 0.70 × ΔMRR(all)
具体例子(来自我们的实际提交记录):
| 改动 | ΔMRR(all) val | 预测 Δ线上 | 实际 Δ线上 |
|---|---|---|---|
| +cand_freq 特征 | +0.0187 | +0.013 | +0.0184 ✓ |
| +pop 热度特征 | +0.0384 | +0.027 | +0.0467 ✓ (低估) |
| +GNN 预训练 | +0.0112 | +0.008 | +0.0071 ✓ |
| SM 模型入融合 | +0.0045 | +0.003 | +0.0032 ✓ |
保守估计:val ΔMRR > 0.003 才值得提交。 低于这个阈值可能是噪声。
如果竞赛平台暂时没有公开 test.csv:
- 方案 A(推荐):向平台申请测试集文件。大部分竞赛都会在后期公开。
- 方案 B(近似):用
np.unique(train['dst'])作为候选池。但这会低估冷 dst 比例,导致 MRR 虚高。一定要在 README 中注明这个近似,并期待 test.csv 到手后重新构造。 - 方案 C(模拟):假设冷 dst 比例为 50%,从全节点空间随机采样 50% 虚构冷节点作为候选池补充。但这只是模拟——精度取决于你的假设。
确保列 0 始终是真值 dst。如果你用的是 val_hot.csv,列顺序就是:
col 0 = src
col 1 = time
col 2 = dst (真值)
col 3-101 = n0...n98 (99 个负样本)
所以 vd.iloc[:, 2:].values 就是你要的 [Q, 100],且第 0 列 = 真值 dst。
不一定。 如果 val_hot 中的冷真值查询的真值 dst 在 train' 中完全没出现过,任何基于训练 embedding 的模型都对它无法区分。0.05 ≈ 随机。这是竞赛设计的固有限制。
如果 MRR(cold-true) > 0.08,你的模型可能有一些归纳能力(比如通过 src 的邻居模式推断新 dst)。这是好事。
可能原因:
- 你的模型对冷 dst 的排序比随机差(线上受冷分量拖累更严重)
- 训练集/测试集的构造方式与 val-hot 的假设不同(比如组织者用了非均匀负采样)
- 时间跨度算错了(检查 test.csv 的 time 范围)
- 候选池不完整(检查 test.csv 的候选列是否全部读入)
调试方法:做一次"探针提交"——把冷候选分数全部置零,线上 D2 ≈ f_hot × MRR_hot。用这个反推 f_hot 和 cold MRR。
检查你的 test.csv 列名。标准格式是 src, time, dst, n0, n1, ..., n98。如果列名不同(比如 candidate_0 而不是 n0),修改 build_val.py 中 cand_cols 的提取逻辑:
# 如果你不想改源码,直接改列名即可
td.columns = ['src', 'time', 'dst'] + [f'n{i}' for i in range(99)]修改时间跨度。D1 的 test 期通常和 D2 不同。查看 test.csv:
span = td['time'].max() - td['time'].min()然后在 build_val.py 中使用这个 span 做切分。
因为时序图数据有时间依赖——未来的边依赖于过去的边。随机切分(shuffle split)会让模型在训练时"看到未来"——这是最严重的数据泄漏。必须用时间切分。
从 0.9357 到 1.24434,54 个 D2 实验,44 次提交,无数死胡同。 这些是我们希望前人告诉过我们的话。
# 当时我们觉得这很合理
negatives = np.random.choice(np.unique(train['dst']), 99) # ❌推翻它的瞬间:PopTrack(零参数热度查询)在这个验证集上 MRR = 0.99,比我们训练了 40 个 epoch 的深度学习模型还高。提交线上:PopTrack = 0.50。差了一倍。
💡 如果你的零参数 baseline 在验证集上接近满分,你的验证集有 bug。
用 src 历史邻居 + 热门加权采样构造"更难"的负样本,然后线性回归到线上分数。两个致命缺陷:(1) hard negative 分布 ≠ 评测真实分布;(2) 校准需要消耗宝贵的提交次数。
核心洞察来自一次凌晨 2 点的数据探索:test.csv 里已经包含了评测的所有信息。 候选池、时间跨度、查询格式——全在 test.csv 里。我们只需要"模仿"它。
99-neg InfoNCE 训练时用了 unique(train_dst) 做负样本池(~50K 全是热 dst)。val_hot MRR 崩到 0.1758。原因:模型从未在训练中见过冷 dst(占评测 54%),推理时冷负样本分数完全失控。
解法:训练负样本池 = test.csv 候选池(含冷 dst)。一行代码的改动,MRR 恢复正常。
Massive 模型(384 维 hidden)Val AP = 0.9622(最高),但线上 MRR 比 Val AP = 0.9594 的小模型低了 0.0123。AP 和 MRR 在顶端脱钩——更大的模型在"容易样本"上更自信,但在"难样本"上反而退步。
两个优化器各调用一次 optimizer.step(loss) → Jittor 的 backward 图在第一次就被消费了,第二个优化器拿到空梯度。修了一周才发现。教训:永远只用一个优化器。
# 源码第 308-309 行
output = self.output_layer(output.view(-1, output.shape[-1]))
output = output.view(output.shape[0], output.shape[1], -1) # ← 当 test_dst≠None 时维度错误
# 返回 [B*C, 1, 1] 而非 [B, C, 1]3 天定位,1 行 monkey-patch 修复。开源代码也可能有 bug——怀疑一切。
知识蒸馏、归纳嵌入、分数传播、分位数校准、伪正样本、冷负采样、冷正则化——没有一种方法能把冷 DST 排序提升到随机以上。 第三次独立验证(leak_detection.py)后永久关闭此方向。冷 DST 的候选是均匀噪声,信息论上不可区分。
这 8 次提交烧掉了我们几乎一整天的配额。
- D1 (72.6% 重复边) = 记忆型。EdgeBank(查表)单模型 0.737。
- D2 (2.2% 重复边, 54% 冷启动) = 推理型。EdgeBank 只有 0.18。
一套策略打两个数据集 → 全部失败。必须分叉处理。
src-tower embedding · cold-dst embedding = src · random_vector ≈ 0。冷 dst 的 embedding 是随机初始化的,点积天然为零。换 InfoNCE loss、换 GNN 预训练——都没用。这是结构问题,不是训练问题。
90 天静态热度(一行 bincount + exp 衰减)val MRR = 0.4467。我们训练了 40+ 个实验的 Cold-CRAFT = 0.4598。差距 0.013——CRAFT 的交叉注意力看不到"全局热度"这个最简单的信号。这个发现后来贡献了 +0.047 的线上提升。
💡 在你调第 50 个超参之前,先检查一个零参数统计量能不能打败你的模型。
Jittor 1.3.9 的 JIT 编译器在处理含中文字符的 Windows 路径(C:\Users\穆再排尔\...)时崩溃。解决方案:所有项目路径纯 ASCII。
Dual Path CRAFT 在本地 val 上 MRR = 0.90,提交线上 D2 = 0.28。本地的"好"不一定是线上的好。当本地和线上冲突时,相信线上,然后修本地验证。
| # | 法则 | 违反的典型代价 |
|---|---|---|
| 1 | 候选池必须与评测分布一致 | 虚高 0.30-0.50 MRR |
| 2 | 时间切分零泄漏(特征不能使用 val 期信息) | 虚高 0.15-0.20 MRR |
| 3 | 负采样与评测同分布(不要自己发明 harder negatives) | 排行不可信 |
| 4 | 冷热分解评估(MRR_hot 和 MRR_cold 分别看) | 不知道改进来自哪里 |
| 5 | 固定随机种子(保证可复现对比) | 无法确认 A > B |
| 6 | 至少跑一个零参数 baseline(PopTrack / EdgeBank) | 不知道验证集本身有没有 bug |
0.9357 起点 — Jittor 官方 baseline (含 bug)
1.0848 CRAFT-bip 架构 (二部图类型感知 + struct gate, +0.15)
1.1098 12d 结构特征 (+0.025)
1.1456 3 路集成 (CRAFT+Massive+DyGFormer, +0.036)
1.1748 D1 EdgeBank — 天花板 (4 次独立命中同一分数)
↓ 发现冷 DST 信息论不可解 ↓
1.2187 pop 热度特征 (零训练!+0.047) ← 最大的单次突破
1.2370 cand_freq (候选频次编码测试期热度, +0.018)
1.2443 cfτ60 时间分辨版 (+0.007)
→ 目标 1.40,在路上
最重要的是:两个最大突破(pop +0.047, cand_freq +0.018)都是在"放下模型、去看数据"时发现的。 不是新架构,不是更长的训练,而是"我们到底在评测什么"的理解深入了一层。
—— 一名光荣的共青团员写给开源社区的话
互联网从诞生那天起,就带着一种朴素而伟大的精神:信息的自由流动、知识的无偿共享、协作的去中心化。
Tim Berners-Lee 放弃 WWW 专利,让万维网成为全人类的公共财产。Richard Stallman 发起 GNU 计划,用 Copyleft 对抗 Copyright。Linus Torvalds 把 Linux 内核扔到邮件列表里说"只是个爱好,不会太大"——然后全世界的工程师一起把它建成了互联网的基石。GitHub 上数亿个仓库,每一行 import numpy as np 的背后,都是无数人无偿劳动、开放共享的成果。
这就是互联网精神:因为开放,所以强大;因为共享,所以繁荣。
而当我以一名共青团员的身份思考这一切时,我发现:互联网精神与共产主义精神在底层逻辑上是辩证统一的。
共产主义的核心追求是什么?各尽所能,按需分配——每个人贡献自己的能力和智慧,每个人都能获得自己需要的东西。开源社区难道不是这样吗?全世界的开发者"各尽所能"——有人修 bug,有人写文档,有人做 code review,有人搭建 CI/CD——而所有人"按需分配"——任何人都可以免费使用 Linux、Python、PyTorch、Jittor 这些基础设施。
共产主义反对知识垄断和阶级固化。 开源运动反对专利壁垒和技术霸权。当一家公司把关键算法锁进专利保险柜时,它在做的事情是:让知识成为少数人的私有财产,拒绝全人类共享进步的果实。而当一名工程师把自己的代码以 MIT License 开源时,ta 在说:"这个解法我找到了,你可以拿去用,不需要付费,不需要署名,只需要让这个世界变得更好一点。"
这不正是"全心全意为人民服务"的技术表达吗?
习近平总书记说过:"青年兴则国家兴,青年强则国家强。"作为新时代的共青团员,我认为技术开源就是青年一代践行共产主义理想的具体行动。我们不只是在写代码——我们在用 pull request 的方式建设一个更平等的世界。
有人可能会问:竞赛代码开源了,别人不就能超过你了吗?
是的。这正是我们希望的。
如果我们的 val-hot 基准设计、我们的经验教训、我们的验证工具能帮助下一个参赛团队少踩几个坑,更快地做出更好的模型——那是我们的荣幸。竞争不是零和博弈。一个人的胜利是胜利,但所有人一起进步的胜利是更大的胜利。
水涨船高。 当中国 AI 竞赛的基线被开源工具抬高时,所有人都会受益——包括我们自己。因为我们会看到更强的对手、更聪明的解法、更深刻的分析。而这些反过来又会推动我们向前。
互联网精神说:信息想要自由。 共产主义精神说:各尽所能,按需分配。 共青团员说:把这两件事都做了。
这就是我们开源 OpenJittor 的全部理由。
⚡ 让每一次提交都有意义。让每一个后来者少走弯路。 ⚡
穆再排尔·穆合塔尔
机电学院 机械工程
光荣的共青团员
2026 年 7 月
openjittor/
├── README.md ← 你正在读的文件
├── build_val.py ← 构造 val-hot 验证基准
├── score_val.py ← MRR 评分与融合分析
├── example_workflow.py ← 完整工作流示例 (Jittor 兼容)
└── requirements.txt ← Python 依赖 (numpy, pandas, scipy)
依赖: 只需 NumPy + Pandas + SciPy。Jittor 框架仅在你的模型推理时需要——评分工具本身不依赖任何深度学习框架。
@misc{openjittor2026,
title = {OpenJittor: Competition-Grade Local MRR Validation Toolkit},
author = {Muzaipaer·Muhetaer and Jittor Track-1 Team},
year = {2026},
url = {https://github.com/[username]/openjittor},
note = {第六届计图AI挑战赛赛道一参赛团队出品}
}- 清华大学 Jittor 团队 —— 维护了优秀的国产深度学习框架
- CRAFT 论文作者 —— 提供了强大的时序图学习基线
- 所有在 arXiv 上公开发表论文的研究者 —— 你们的成果是我们站立的肩膀
- 每一个给开源项目提 issue、修 bug、写文档的陌生人 —— 你们让这个世界变得更好
Licensed under MIT. 拿去用吧。