如果你手上有 KataGo 的权重文件,但从来没打开看过它内部长什么样,那么你和只用过 pip install 的人区别不大——工具能跑,但你说不清它为什么这样下棋。
这次我做了一件很具体的事:把 KataGo 较新的 Transformer 系网络,从“只会输出 winrate 的黑盒引擎”,变成几张人能直接看的图。我没有改一行模型源码,也没有重新训练。我用 Trae 作为编辑器,让模型在编辑器里帮我写采集和渲染脚本,从零到出第一张图,实际写代码和调试的时间是 55 分钟,不含权重文件下载。
先说结论:这件事的价值不在于“图好看”。它解决的是三个很实在的问题。第一,你能确认手上这份权重和配置是不是真的匹配,Transformer 系网络对配置项比老卷积网络敏感得多。第二,你能看出引擎给出的候选着法分布合不合理,而不是无脑相信那个胜率数字。第三,你调 maxVisits、rootPolicyTemperature 这些参数时,终于有一个能直接观察的手段,不用靠“感觉好像变强了”。
下面我把整个流程拆开写。环境怎么搭、Trae 里怎么给需求才不容易翻车、采集脚本长什么样、坐标系到底哪里最容易错、跑出来的图怎么判断是对的。如果你只是想拿几张图放进自己的复盘笔记,照着第 6 节的代码抄就能用;如果你想拿这套东西做二次开发或者参数调优,第 8、9 节的坑建议认真看一遍。
1. 为什么值得把 KataGo 的模型“打开看看”
大多数人用 KataGo 的方式是固定的:装个 GUI,加载权重,摆棋,看胜率曲线。这个用法没有问题,但它有一个隐含前提——你相信引擎输出的东西。而当你开始做稍微深入一点的事情,这个信任就会出问题。
举几个真实场景。你换了新权重,胜率曲线突然变得很“跳”,你分不清是权重本身风格不同,还是配置里 nnMaxBatchSize 和你的显存不匹配导致 batch 被悄悄降级。你在做复盘工具,想把引擎的候选着法渲染到棋盘上,结果发现有些位置的候选点看起来完全不合理,你不知道是模型先验就这样,还是你的坐标转换写反了。你在调搜索参数,把 maxVisits 从 200 提到 2000,胜率几乎没变,你想知道多出来的算力到底花在哪了。
这三个场景的共同点是:它们都发生在“引擎输出”和“模型内部”之间的那条缝里。而可视化,就是把这条缝填上的最省力手段。
需要提前说清楚的是,这里说的“可视化”不是反编译网络、不是画出 attention 权重矩阵。那种做法成本高、结论还不好解释,对绝大多数人没有实际用处。我们要做的是行为层面的可视化:把引擎在特定局面下输出的策略分布、搜索分布、领地估计,映射回 19×19 的棋盘坐标上,让数字变成颜色深浅。这件事几十行 Python 就能做完,但它带来的信息量比读十页文档都大。
哪类读者最该看这篇?如果你只是偶尔下棋用 AI 复盘,本文对你来说信息量过剩了。但如果你在做围棋相关的工具、数据分析、模型对比,或者你单纯想搞明白“Transformer 到底给 KataGo 带来了什么变化”,那这套流程能省你不少时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先理清三个概念:引擎、网络架构、以及“可视”的对象
动手之前有几个概念必须分清,否则后面脚本写出来你也不知道自己在画什么。
2.1 KataGo 不是一个模型,而是一套推理引擎
这是第一个容易混淆的地方。KataGo 的主体是一个用 C++ 写的推理引擎,它负责搜索、规则判定、GTP 协议通信、多线程调度。神经网络权重是外挂的,单独一个 .bin.gz 文件,引擎启动时加载。
这个设计带来一个很实际的便利:你可以拿同一个引擎配不同权重,跑同样的局面,直接对比。也可以拿同一份权重,换不同版本的引擎,看行为有没有变化。本文所有实验都建立在这个可替换性上。
引擎的几个常用入口值得记住:
| 子命令 | 用途 | 本文是否用到 |
|---|---|---|
gtp |
标准 GTP 协议,给 GUI 用 | 否 |
analysis |
批量接收 JSON 局面,输出结构化分析结果 | 是,核心 |
benchmark |
跑基准测试,输出吞吐和配置建议 | 是,用于模型画像 |
genconfig |
根据模型自动生成适配的配置 | 是,用于初始化 |
evalsgf |
对单局 SGF 逐手评估 | 可选 |
真正让可视化能落地的是 analysis 子命令。它用行分隔的 JSON 通信,输入一个局面,输出一个 JSON,里面有候选着法列表,每个着法带访问数、胜率、目差估计和主变。这些全是结构化数据,天然适合程序处理,不需要你去做任何协议逆向。
2.2 Transformer 在 KataGo 里改了什么
老一点的 KataGo 权重是纯卷积结构,文件名里常见 b18c384 这种,b 是 block 数,c 是通道数。较新的权重命名里会出现 nbt 这类后缀,代表引入了 bottleneck 和 attention 相关的结构。
我不打算在这里深入讲网络内部细节,原因很简单:那些细节对使用者的决策影响很小,而且我没有源码级的验证,写出来就是在编。但从使用者的角度,有两件事是真的会变:
第一,新架构对输入特征的利用方式变了,最直接的表现是策略分布的形状会不一样。老网络在某些局部战斗局面下,先验分布比较“平”,需要靠搜索来收敛;新网络在同样的局面下先验可能更集中,也就是第一感更“尖锐”。
第二,新架构对硬件和配置更敏感。batch size、后端选择、精度模式,这些参数在新旧网络上的表现不一定一致。如果你拿老配置直接套新权重,跑出来的数字和社区里别人贴的对不上,很正常。
这也正是为什么图有用:曲线只能告诉你结论变了,图能告诉你变化发生在哪个位置。
2.3 “可视化”的三个层次,我们只做第二层
把这件事分层看会更清楚:
| 层次 | 可视对象 | 实现难度 | 实用价值 |
|---|---|---|---|
| 第一层 | 模型文件本身:大小、结构、参数量 | 低 | 用于核对权重身份 |
| 第二层 | 引擎输出的策略/搜索/领地分布 | 中 | 高,直接指导调参和复盘工具开发 |
| 第三层 | 网络内部张量、attention 权重 | 高 | 对普通使用者几乎没有 |
本文走的是“第一层做基础画像 + 第二层做主要可视化”的路线。第三层不碰,原因不是做不到,而是做出来你也无法据此做出任何决策——你看到 384 个通道的激活值,然后呢?
3. 环境准备与前置条件
环境这块我尽量写细,因为后面出问题十有八九是环境没对齐。
操作系统:Linux 或 macOS 最省事。Windows 也能跑,但脚本里的路径分隔和可执行文件后缀要改,本文的 shell 脚本假设你在类 Unix 环境。Windows 用户建议用 WSL,或者把 shell 脚本改写成 Python。
Python 版本:3.8 以上即可,不涉及新版语法特性。依赖就三个:
bash复制pip install matplotlib numpy
不需要 torch、不需要 tensorflow。这一点很重要——我们不加载模型做推理,模型推理全部交给 KataGo 引擎本身。我们只处理它吐出来的 JSON。这个设计让整个方案的依赖极轻,也避免了“Python 端的模型加载和 C++ 引擎对不上”这类问题。
KataGo 引擎:从官方 GitHub Releases 下载对应平台的预编译二进制。如果你要用 GPU 加速,选带 CUDA 或对应后端的构建。放不放进系统 PATH 无所谓,本文脚本里用的是相对路径。
权重文件:同样从官方发布渠道获取。这里我必须提醒一句:不同版本权重的命名后缀含义不同,别想当然。文件名里的 b、c 是 block 数和通道数,nbt 之类的后缀代表结构特征,具体定义请以官方仓库的说明为准,我不在这里替你解读。
目录结构:提前定好,后面所有脚本都基于这个结构,不要临时改。
bash复制katago-viz/
├── bin/
│ └── katago # 引擎可执行文件
├── models/
│ └── kata1-model.bin.gz # 权重文件(文件名以实际为准)
├── config/
│ ├── gtp.cfg # genconfig 生成
│ └── analysis.cfg # 手工准备
├── scripts/
│ ├── 01_setup.sh
│ ├── 02_profile.sh
│ ├── 03_collect.py
│ ├── 04_render.py
│ └── 05_compare.py
├── data/ # 采集结果
└── figs/ # 渲染输出
关于版本号:本文不锁定具体的 KataGo 版本号,因为不同发布通道的版本节奏不一样,锁死一个数字反而容易误导。但有一条经验可以给你:引擎版本和权重版本不是一一对应的,新引擎能加载老权重,老引擎不一定能加载新权重。如果你的引擎加载新权重时报格式错误,第一个怀疑对象就是引擎太老,直接换最新版。
4. 55 分钟的时间怎么分配
标题里的 55 分钟经常被问:是不是吹的?我把实际耗时拆开,你自己判断。
| 阶段 | 实际耗时 | 主要消耗在哪 |
|---|---|---|
| 目录准备 + 引擎就位 | 8 min | 找对二进制,确认能跑起来 |
| 模型画像(genconfig + benchmark) | 6 min | benchmark 本身要跑一会儿 |
| 采集脚本编写与调试 | 16 min | 输入 JSON 格式、子进程通信 |
| 渲染脚本编写与调试 | 15 min | 棋盘坐标系,这块最费时间 |
| 对比脚本 + 结果验证 | 10 min | 确认图画出来是对的 |
有两件事必须说清楚,否则这个数字就是在误导人。
第一,这 55 分钟不含权重下载。权重文件动辄几百 MB,下载时间完全取决于你的网络,我没法把它算进去,也不想假装它不存在。
第二,这 55 分钟是一次性投入。脚本写完之后,你换一个局面、换一份权重,重跑一遍只要几分钟。后面第 6 节的代码你可以直接拿去用,那你的实际耗时可能比 55 分钟还短。
另外说一个反直觉的观察:真正占时间的不是写代码,是验证代码写得对不对。第 7 节我会专门讲怎么判断一张图是不是画错了——因为一个坐标转换写反的图,看起来和正确的图一样“像那么回事”。
5. 用 Trae 提需求:怎么问才不会拿到一堆废代码
Trae 这类 AI IDE 的价值不在于“帮你打字”,而在于它能在你还没想清楚细节的时候,先把骨架搭出来。但它给的东西质量如何,八成取决于你怎么问。
5.1 一次失败的提问长什么样
我一开始问的是:“帮我写一个脚本,可视化 KataGo 的策略分布。”
结果拿到的是伪代码。它画了一个 seaborn 热力图,但数据是 np.random.rand(19,19)。它不知道 KataGo analysis 的输出格式,也不知道 GTP 坐标系统,于是用随机数糊弄过去了。这不是模型不行,是我的需求里没有可执行的信息。
5.2 有效的提问需要包含四样东西
后来我把提问改成了结构化模板,效果立刻不一样:
text复制背景:我在用 KataGo 的 analysis 子命令做围棋局面批量分析。
输入:analysis 输出的 JSON,每行一个对象,其中 moveInfos 是候选着法数组,
每个元素包含 move(GTP 坐标,如 "Q16")、visits、winrate、prior 字段。
任务:写一个 Python 脚本,读取这些 JSON,把每个局面的候选着法先验概率
渲染成 19x19 的棋盘热力图,颜色深浅代表 prior 大小。
约束:
1. GTP 坐标列字母为 A-T 且跳过 I,行号 1-19 从下往上,即 A1 在左下角。
2. 只依赖 matplotlib 和 numpy,不要用其他绘图库。
3. 图里所有标签用英文,避免中文字体渲染问题。
4. 输出 PNG,文件名用局面 id。
关键在四个部分:背景、输入结构、任务、约束。其中“约束”里的第 1 条是最重要的——你把坐标系规则直接告诉它,它就不会写反。如果你不说,它八成会假设 A1 在左上角。
5.3 哪些活交给 AI,哪些必须自己来
我的划分标准是这样的:
| 任务类型 | 交给 AI | 自己把关 |
|---|---|---|
| 样板代码、参数解析、文件读写 | 是 | 粗看一遍 |
| 坐标转换、数值计算 | 否,自己写 | 全责 |
| 绘图样式、配色、布局 | 是 | 看效果 |
| 子进程通信、错误处理 | 半 | 必查 |
坐标转换这类逻辑,AI 写得“看起来对”的概率很高,但错一个减法符号就能让你后面所有结论翻车,而且图还是能正常出。这种活自己写十分钟,比 debug 两小时划算。
6. 完整代码实现:从 analysis 输出到棋盘热力图
下面四个脚本按顺序执行即可。文件路径都在注释里标好了。
6.1 初始化与模型画像
先确认引擎能跑,再让引擎自己告诉你这份权重的基本信息。
bash复制#!/usr/bin/env bash
# 文件路径:scripts/01_setup.sh
set -euo pipefail
mkdir -p bin models config scripts data figs logs
KATAGO_BIN="./bin/katago"
MODEL="./models/kata1-model.bin.gz"
if [ ! -x "$KATAGO_BIN" ]; then
echo "[ERROR] 引擎不存在或没有执行权限: $KATAGO_BIN"
exit 1
fi
if [ ! -f "$MODEL" ]; then
echo "[ERROR] 权重文件不存在: $MODEL"
exit 1
fi
echo "[OK] 引擎版本信息:"
"$KATAGO_BIN" version || echo "(该子命令不可用,忽略)"
echo "[OK] 目录结构已就绪"
bash复制#!/usr/bin/env bash
# 文件路径:scripts/02_profile.sh
set -euo pipefail
KATAGO_BIN="./bin/katago"
MODEL="./models/kata1-model.bin.gz"
CONFIG="./config/gtp.cfg"
# genconfig 会读取权重结构,并生成一份适配该模型的配置
"$KATAGO_BIN" genconfig -model "$MODEL" -output "$CONFIG" \
2>&1 | tee logs/genconfig.log
# benchmark 用来拿到吞吐数据,同时验证配置是否合理
"$KATAGO_BIN" benchmark -model "$MODEL" -config "$CONFIG" \
2>&1 | tee logs/benchmark.log
跑完这两个脚本,logs/ 目录下会有两份日志。genconfig.log 里的注释通常包含模型名和适配参数,benchmark.log 里有每秒访问数。这两个数字就是你后续判断“配置有没有生效”的基准线。先把基准线存好,换了参数之后再跑一次,两个数字对比才有意义。
6.2 analysis 配置
analysis 子命令需要单独的配置文件,不能直接用 GTP 的。
properties复制# 文件路径:config/analysis.cfg
# 注意:其余参数请对照官方 analysis_example.cfg 补齐,
# 下面只列本文实际依赖的几项,不同版本参数名可能略有差异。
numAnalysisThreads = 2
numSearchThreads = 8
nnMaxBatchSize = 8
maxVisits = 200
reportAnalysisWinratesAs = SIDETOMOVE
analysisPVLen = 10
logDir = ./logs
这里有一个坑必须提前说:nnMaxBatchSize 设得比你的显存能承受的大,引擎不一定会报错,它可能直接降级。降级的结果就是 benchmark 数字明显偏低,但程序照常跑。所以第 6.1 步的基准线要先跑稳。
maxVisits 在可视化实验里建议设小一点,比如 200 甚至更低。原因在下一节解释。
6.3 采集脚本
python复制# 文件路径:scripts/03_collect.py
import json
import pathlib
import subprocess
import sys
KATAGO_BIN = "./bin/katago"
ANALYSIS_CFG = "./config/analysis.cfg"
MODEL = "./models/kata1-model.bin.gz"
OUT_DIR = pathlib.Path("./data")
# 这里放你想观察的局面,moves 是 GTP 坐标序列
POSITIONS = [
{"id": "pos-01-empty", "moves": []},
{"id": "pos-02-two-stars", "moves": [["B", "Q16"], ["W", "D4"]]},
{"id": "pos-03-sanrensei", "moves": [["B", "Q16"], ["W", "D4"],
["B", "D16"], ["W", "Q4"],
["B", "R14"]]},
{"id": "pos-04-low", "moves": [["B", "Q16"], ["W", "D4"],
["B", "C3"], ["W", "R17"],
["B", "F3"]]},
]
def build_query(pos):
query = {
"id": pos["id"],
"moves": pos["moves"],
"rules": "chinese",
"komi": 7.5,
"boardXSize": 19,
"boardYSize": 19,
"maxVisits": 200,
}
return json.dumps(query, ensure_ascii=False)
def main():
OUT_DIR.mkdir(exist_ok=True)
cmd = [
KATAGO_BIN, "analysis",
"-config", ANALYSIS_CFG,
"-model", MODEL,
]
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
results = {}
for pos in POSITIONS:
proc.stdin.write(build_query(pos) + "\n")
proc.stdin.flush()
expected = len(POSITIONS)
while len(results) < expected:
line = proc.stdout.readline()
if not line:
break
line = line.strip()
if not line.startswith("{"):
# analysis 启动时会往 stdout 打一些非 JSON 提示,跳过
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
results[obj["id"]] = obj
proc.stdin.close()
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
if len(results) != expected:
print(f"[WARN] 期望 {expected} 条结果,实际拿到 {len(results)} 条",
file=sys.stderr)
for pid, obj in results.items():
out_file = OUT_DIR / f"{pid}.json"
out_file.write_text(json.dumps(obj, ensure_ascii=False, indent=2),
encoding="utf-8")
n_moves = len(obj.get("moveInfos", []))
print(f"[OK] {pid}: {n_moves} 个候选着法 -> {out_file}")
if __name__ == "__main__":
main()
这段代码有几个地方值得解释。
为什么要跳过不以 { 开头的行。analysis 模式启动时,引擎会往 stdout 打一些状态信息。如果你不做过滤,json.loads 会抛异常。我第一次跑的时候就是在这里卡了十几分钟,因为异常信息看起来像是“输出格式不对”,实际只是没过滤。
为什么要把所有请求一次性写进去。analysis 模式支持批量输入,一次性写完后一次性读,比一问一答快得多。如果你有一千个局面,逐个交互的开销会非常可观。
为什么 maxVisits 设 200。因为我们要看的是先验分布的形状,不是搜索收敛之后的最终选择。访问数越高,搜索的修正就越强,prior 和 visits 的差异就越大,图反而看不出模型的“第一感”。想观察搜索行为的话,把这个值调高再跑一次,对比两张图,差异本身也是信息。
6.4 渲染脚本
python复制# 文件路径:scripts/04_render.py
import json
import pathlib
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
DATA_DIR = pathlib.Path("./data")
FIG_DIR = pathlib.Path("./figs")
GTP_COLS = "ABCDEFGHJKLMNOPQRST" # 注意:跳过字母 I
BOARD_SIZE = 19
def gtp_to_rowcol(move):
"""GTP 坐标 -> (row, col),row=0 表示棋盘最上面一行。"""
if move is None or str(move).lower() == "pass":
return None
move = str(move).strip().upper()
col = GTP_COLS.index(move[0])
row = BOARD_SIZE - int(move[1:])
return row, col
def build_matrix(move_infos, field="prior"):
"""把候选着法聚合成 19x19 矩阵。"""
mat = np.full((BOARD_SIZE, BOARD_SIZE), np.nan, dtype=float)
for info in move_infos:
rc = gtp_to_rowcol(info.get("move"))
if rc is None:
continue
value = info.get(field)
if value is None:
continue
r, c = rc
mat[r, c] = float(value)
return mat
def render(mat, title, out_path):
fig, ax = plt.subplots(figsize=(7, 7), dpi=120)
# 用 masked array 让空格子保持背景色
masked = np.ma.masked_invalid(mat)
cmap = plt.cm.YlOrRd.copy()
cmap.set_bad("#f0d9b5") # 棋盘木色
im = ax.imshow(masked, cmap=cmap, vmin=0,
vmax=np.nanmax(mat) if np.isfinite(np.nanmax(mat)) else 1)
# 画网格线
ax.set_xticks(np.arange(-0.5, BOARD_SIZE, 1), minor=True)
ax.set_yticks(np.arange(-0.5, BOARD_SIZE, 1), minor=True)
ax.grid(which="minor", color="#5a3a1a", linewidth=0.6)
ax.tick_params(which="minor", length=0)
labels = list(GTP_COLS)
ax.set_xticks(range(BOARD_SIZE))
ax.set_xticklabels(labels, fontsize=7)
ax.set_yticks(range(BOARD_SIZE))
ax.set_yticklabels(range(BOARD_SIZE, 0, -1), fontsize=7)
ax.set_title(title, fontsize=11)
fig.colorbar(im, ax=ax, fraction=0.042, pad=0.03)
fig.tight_layout()
fig.savefig(out_path)
plt.close(fig)
def main():
FIG_DIR.mkdir(exist_ok=True)
for json_file in sorted(DATA_DIR.glob("*.json")):
data = json.loads(json_file.read_text(encoding="utf-8"))
move_infos = data.get("moveInfos", [])
if not move_infos:
print(f"[SKIP] {json_file.name} 没有 moveInfos")
continue
mat = build_matrix(move_infos, field="prior")
out_path = FIG_DIR / f"{json_file.stem}.png"
render(mat, f"{json_file.stem} / prior", out_path)
print(f"[OK] {out_path}")
if __name__ == "__main__":
main()
gtp_to_rowcol 是整个流程里最容易写错的函数,我把它单独拎出来说明:
- GTP 列字母是
A到T,但没有I。这是为了避免和数字 1 或字母 J 混淆的历史约定。如果你用chr(ord('A') + col)生成,从第 9 列开始全错。 - GTP 行号
1在棋盘最下面,而imshow的 row 索引0在最上面。所以是BOARD_SIZE - int(...)。 Q16在标准 19 路棋盘上对应(3, 15),也就是右上角的星位。你可以用这个当校验点。
这两个点任何一个写反,图还是能正常输出,颜色也还是一片热热闹闹,但所有结论都是错的。
6.5 对比脚本
有了单局面的图,下一步是把多个局面或者两份权重的结果放一起比。
python复制# 文件路径:scripts/05_compare.py
import json
import pathlib
import sys
import numpy as np
DATA_DIR = pathlib.Path("./data")
TOP_N = 8
def normalize(vec):
total = vec.sum()
if total <= 0:
return vec
return vec / total
def load_matrix(path, shape=19):
data = json.loads(path.read_text(encoding="utf-8"))
mat = np.zeros(shape * shape, dtype=float)
gtp_cols = "ABCDEFGHJKLMNOPQRST"
for info in data.get("moveInfos", []):
move = str(info.get("move", "")).upper()
if len(move) < 2 or move.lower() == "pass":
continue
try:
col = gtp_cols.index(move[0])
row = shape - int(move[1:])
except (ValueError, IndexError):
continue
value = info.get("prior")
if value is None:
continue
mat[row * shape + col] = float(value)
return normalize(mat)
def kl_divergence(p, q, eps=1e-9):
p = np.clip(p, eps, None)
q = np.clip(q, eps, None)
return float(np.sum(p * np.log(p / q)))
def main():
files = sorted(DATA_DIR.glob("*.json"))
if len(files) < 2:
print("至少需要两个局面文件才能对比")
sys.exit(1)
vectors = {f.stem: load_matrix(f) for f in files}
names = list(vectors.keys())
print(f"{'A':<18}{'B':<18}{'KL散度':>10}")
print("-" * 46)
for i in range(len(names)):
for j in range(i + 1, len(names)):
a, b = vectors[names[i]], vectors[names[j]]
d = kl_divergence(a, b)
print(f"{names[i]:<18}{names[j]:<18}{d:>10.4f}")
print("\n各局面 Top 候选着法:")
for name, vec in vectors.items():
idx = np.argsort(vec)[::-1][:TOP_N]
gtp_cols = "ABCDEFGHJKLMNOPQRST"
moves = []
for k in idx:
if vec[k] <= 0:
continue
r, c = divmod(int(k), 19)
moves.append(f"{gtp_cols[c]}{19 - r}")
print(f" {name}: {', '.join(moves)}")
if __name__ == "__main__":
main()
KL 散度这个指标用得对不对,值得说明一下。它衡量的是“用分布 Q 去编码分布 P 需要多少额外信息”。在模型对比场景里,它可以告诉你两份权重在同一个局面上的策略差异有多大。但它对零值很敏感,所以代码里做了 eps 截断。另外,如果两个分布只在少数几个点上不同,KL 会偏大,这不一定代表“模型差异大”,可能只是候选集不同。所以我没有把它当成唯一指标,而是配合 Top-N 着法列表一起看。
7. 运行结果与效果验证
跑完之后,figs/ 目录下应该出现若干 PNG。但“图出来了”不等于“图对了”,这一步必须做验证。
7.1 三个必查的校验点
| 校验项 | 怎么查 | 期望结果 |
|---|---|---|
| 坐标方向 | 看 pos-01-empty 这张图 |
热点应聚集在四个角部星位附近 |
| 颜色映射 | 看热点格子在原 JSON 里的 prior 值 |
最大的那个点颜色应该最深 |
| 棋盘朝向 | 看 pos-02-two-stars |
热点应对应右上和左下 |
第一条是我最推荐的验证方式。空棋盘是绝佳的测试用例,因为围棋的开局先验分布有很强的规律性——角部价值高、边上其次、中腹最低。如果你的空棋盘图热点跑到了中腹或者边中间,那基本可以确定坐标转换有问题。
第三条也很有用。pos-02-two-stars 里黑棋下在 Q16、白棋下在 D4,那么模型给的候选点应该围绕这两个子分布,Q16 在右上、D4 在左下。如果图里这两个区域热点对称地出现在了左上和右下,说明行号方向反了。
7.2 用数字交叉验证
图是给人看的,数字是给程序验证的。每次渲染完,我建议顺手打印一下矩阵的统计量:
python复制# 追加到 04_render.py 的 main 函数里
finite = mat[np.isfinite(mat)]
if finite.size > 0:
print(f" 非空点数={finite.size:3d} "
f"最大值={finite.max():.4f} "
f"均值={finite.mean():.4f}")
拿 pos-01-empty 的结果和你在 JSON 里看到的 prior 最大值对一遍,能对上,就说明映射没问题。这一步花不了一分钟,但能省掉后面所有基于错误图的错误结论。
7.3 如果失败了,先看哪里
按照排查成本从低到高,顺序是:
- 看
data/里的 JSON 原始内容。是不是空的?moveInfos是不是只有一两条?如果是,问题在采集阶段,检查maxVisits和analysis.cfg。 - 看坐标转换函数的单点输出。手工测一下
gtp_to_rowcol("Q16")是不是(3, 15)。 - 看渲染脚本的
vmax。如果最大prior只有 0.02,而vmax被设成了 1,整张图会淡到看不见。 - 看 matplotlib 后端。在无显示环境(比如服务器)里跑,必须用
matplotlib.use("Agg"),否则会卡在窗口创建上。
8. 常见问题与排查思路
下面这些是我实际遇到的,按出现频率排序。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 引擎启动即退出,日志提示模型格式错误 | 引擎版本过旧,不支持该权重的网络结构 | 查看 logs/ 下的引擎输出,确认加载失败发生在模型解析阶段 |
换用官方发布的最新引擎;不要试图用老引擎加载新结构权重 |
| analysis 输出为空或只有非 JSON 行 | 输入 JSON 缺少必填字段,或 analysis.cfg 路径不对 |
打印引擎的 stderr | 补全 boardXSize、boardYSize、rules、komi;确认 -config 指向的绝对路径正确 |
| benchmark 数字明显低于社区同硬件水平 | nnMaxBatchSize 与实际显存不匹配,被静默降级 |
逐步调低 batch size 重跑 benchmark,看数字是否回升 | 找到稳定值后再往上加,别一次拉满 |
| 热力图热点全在中腹 | 行列方向搞反,或 BOARD_SIZE - int(...) 写成了 int(...) - 1 |
用 pos-01-empty 校验 |
修正 gtp_to_rowcol,固定用 (row, col) 语义 |
图里出现列 I |
用 chr(ord('A')+n) 生成列名 |
打印列名数组 | 改用固定字符串 "ABCDEFGHJKLMNOPQRST" |
| 图上标签是方块 | 系统中没有中文字体 | 看 matplotlib 警告 | 图内标签统一用英文;要中文就显式配置 font.sans-serif |
脚本跑完但 figs/ 是空的 |
data/ 目录没有 JSON,或 glob 路径写错 |
ls data/ |
确认采集脚本输出目录与渲染脚本读取目录一致 |
| 多个局面采集时结果错位 | id 重复或缺失 |
检查 POSITIONS 里的 id 唯一性 |
id 必须唯一,且回传时以 id 匹配而非顺序匹配 |
表格里第二行值得单独展开。analysis 子命令对输入 JSON 的字段要求比文档写的严格,缺一个 komi 或者把 rules 写成它不认识的值,它可能既不报错也不返回结果,你就一直等在那里。调试期一定要把 stderr 打出来,我在采集脚本里保留了 stderr=subprocess.PIPE,就是为了这个。
9. 最佳实践与工程建议
这套东西从“能跑”到“能反复用”,中间还有几步。
9.1 把实验参数写进文件名
不要用 output.png 这种命名。我的习惯是 {权重短标识}-{局面id}-{maxVisits}-{参数备注}.png。原因很实际:你三周后再来看这些图,不记得哪个是哪个。文件名是你唯一的线索。
9.2 权重和配置要成对存档
前面提过,新架构对配置更敏感。所以每次跑出一组有意义的结果,把 gtp.cfg 和权重文件标识一起记下来:配置内容、引擎版本、benchmark 数字、权重文件名。做成一个 experiments.md 就够,不用上数据库。
9.3 采集阶段和渲染阶段必须解耦
现在 03_collect.py 只负责产 JSON,04_render.py 只负责读 JSON 出图。这个解耦很有价值——你想调整配色、想换一种聚合方式、想加一层平滑,全部只动渲染脚本,不用重跑采集。而采集是最耗时的部分。
9.4 把校验做成断言,而不是靠眼睛
第 7 节说的坐标校验,别只在第一次做。写成断言:
python复制def selftest():
assert gtp_to_rowcol("Q16") == (3, 15), "坐标转换错误:Q16"
assert gtp_to_rowcol("D4") == (15, 3), "坐标转换错误:D4"
assert gtp_to_rowcol("A1") == (18, 0), "坐标转换错误:A1"
assert gtp_to_rowcol("T19") == (0, 18), "坐标转换错误:T19"
print("[OK] 坐标转换自检通过")
selftest()
把这四行加进 04_render.py 开头,以后任何改动都会被立刻拦住。这四个点覆盖了四个角落,任何一个方向写反都跑不过。
9.5 生产环境的注意事项
如果你打算把这套东西做成给别人用的工具,有三条底线。
资源隔离。analysis 引擎会吃满你配的线程数。如果它和别的服务跑在同一台机器上,一定要在配置里限死 numSearchThreads 和 numAnalysisThreads,别让它抢走所有 CPU。
超时与清理。子进程如果不正常退出,会留下僵尸进程。代码里 terminate 之后一定要 wait,超时就 kill。这在长时间批处理里是必需的。
不要在共享环境里开放任意输入。如果你做的是 Web 服务,用户提交的 moves 必须先校验合法性——坐标越界、重复落子、违反规则,这些都要在进引擎之前拦住。让引擎去处理非法输入,轻则报错,重则行为不可预期。
10. 这套方法还能往哪延伸
把 KataGo 的输出变成图,只是这件事的起点。沿着同一条思路,还有几个方向我觉得更值得花时间。
换字段就有新视角。本文用的是 prior,其实同一个 JSON 里还有 visits、winrate、scoreMean、pv。把 prior 和 visits 画成两张图并排放,你能直观看到“模型的第一感”和“搜索之后的选择”差在哪里——这个差异在复杂战斗局面下会非常明显,而它恰好是评估搜索效率的一个好指标。
跨权重对比。同一批局面,用两份不同架构的权重各跑一遍,算 KL 散度,再画成对比图。这比看 benchmark 数字有意思得多,因为你能看到差异具体集中在棋盘的哪个区域。
做时间序列。把一局棋逐手跑 analysis,把每一手的策略分布按手数排列,做成一张“策略演化图”。围棋的布局、中盘、收官在策略分布上的形态差异很大,这种图能把这个差异直接呈现出来。
最后留一句实际提醒。这套流程里最脆弱的一环不是代码,是你对“图对了”的判断。所有的可视化都是把数字映射成颜色,映射错了照样好看。所以第 7 节那几个校验点,别跳。空棋盘那张图先跑通、先确认坐标无误,再往后面加东西——这个顺序省下来的时间,比你想象的要多。
