实战演练:用零依赖 Python 画出四张拓扑图
计划里这一篇叫「30 行 NumPy 画出四张图」。真到动手的时候,环境里没有 NumPy,也没有 matplotlib:系统 Python 3.9,import numpy 直接报错,pip 能装但没必要为一个画图脚本装一堆依赖。
于是换了个方案:只用标准库,直接写 SVG。核心图元还是三十行左右,完整脚本四百多行,跑一遍输出 11 张图,Obsidian 直接引用。
这一篇就讲这个脚本怎么从零写出来。写完你会发现,画图和画网络是一回事:先把结构想清楚,再决定每个圆点、每条线放在哪。
开发过程
先把需求写成一段提示词,丢给 AI,也丢给自己。提示词如下:
我要用零依赖 Python 画四张神经网络拓扑图,输出 SVG,供 Obsidian 直接引用。
## 功能需求
1. 画布:白色背景,标题 + 副标题,viewBox 与 width/height 一致
2. 图元:节点、连线、箭头、文字、网格;箭头不能插进节点
3. 四张图:DNN(3-4-2 全连接)、CNN(7×7 输入 + 3×3 核 + 5×5 特征图)、
RNN(折叠 + 展开)、GAN(G/D 双网络 + 损失回路)
4. 辅助图:总览 2×2、感受野 3/5/7、参数量曲线、矩阵乘、卷积逐元素
5. 所有文字用中文,字体优先 PingFang SC;代码文件 UTF-8
6. 输出 11 个 SVG 到当前目录,文件名固定
## 技术栈
- 只用 Python 标准库:os、math;不装 numpy/matplotlib
- SVG 1.1,`<defs>` 里定义箭头 marker
## 本章不做
- 不做动画、不做交互、不接前端框架
## 验收标准
1. `python3 draw_networks.py` 一次跑通,输出 11 个 SVG
2. 每张图文字不重叠,箭头不插进节点
3. Obsidian 打开笔记,图片全部能显示
4. 改颜色/坐标重跑,输出跟着变环境:
macOS
Python 3.9.6(系统自带)
无需 pip install目录:
前置篇:一张图看懂四张网络/
├── assets/
│ ├── draw_networks.py
│ ├── overview.svg
│ ├── dnn.svg
│ ├── neuron.svg
│ ├── cnn.svg
│ ├── rf.svg
│ ├── rnn_folded.svg
│ ├── rnn_unfolded.svg
│ ├── gan.svg
│ ├── params.svg
│ ├── matrix.svg
│ └── conv_calc.svg
├── 理论学习:四张结构图在讲什么/
├── 案例拆解:每个箭头对应哪行公式/
└── 实战演练:用零依赖 Python 画出四张拓扑图/完整脚本在 assets/draw_networks.py。下面按图元拆,每一段都能单独跑。
一、画布:一张 SVG 就是一个字符串
SVG 不是二进制图片,它是一段 XML。画布、图形、文字、箭头,全都是标签。最外层的壳:
def doc(w, h, body):
defs = """
<defs>
<marker id="ar-gray" markerWidth="11" markerHeight="11" refX="9" refY="3.5"
orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L0,7 L10,3.5 z" fill="#9aa0a6"/>
</marker>
</defs>"""
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
f'viewBox="0 0 {w} {h}">\n'
f'<rect width="100%" height="100%" fill="#ffffff"/>\n'
f'{defs}\n{body}\n</svg>\n')三个属性各自管一件事:
width/height:图片在页面上占多大。viewBox:坐标系的范围。这里写成0 0 w h,和宽高一致,一个用户单位就是一个像素。xmlns:命名空间。少了它,浏览器和 Obsidian 都可能不认。
<defs> 里放的是箭头。marker 定义一个小三角形,refX / refY 指定箭头的尖端对齐到线的终点,orient="auto" 让箭头跟着线的方向转。markerUnits="userSpaceOnUse" 很关键:它让箭头大小按用户单位算,不跟着线宽缩放。否则细线上的箭头会小得看不见。
<rect width="100%" height="100%" fill="#ffffff"/> 是白底。没有它,深色主题下透明背景会把线条吃掉。
二、四个图元:文字、圆、矩形、线
后面所有图都由这四个函数拼出来。
def t(x, y, s, size=15, fill=INK, anchor="middle", weight="normal"):
return (f'<text x="{x}" y="{y}" font-family="{FONT}" font-size="{size}" '
f'fill="{fill}" text-anchor="{anchor}" font-weight="{weight}">{esc(s)}</text>')
def circle(cx, cy, r, fill=BLUE_FILL, stroke=BLUE, sw=2):
return f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>'
def rect(x, y, w, h, fill="#ffffff", stroke=LINE, sw=2, rx=10):
return (f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" '
f'fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>')
def line(x1, y1, x2, y2, stroke=LINE, sw=1.5, marker=None, dash=None):
extra = f' stroke-dasharray="{dash}"' if dash else ""
mk = f' marker-end="url(#{marker})"' if marker else ""
return (f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" '
f'stroke-width="{sw}"{mk}{extra}/>')几个容易踩的点:
<text>的y是基线,不是文字顶部。视觉上要居中,得在cy + size/3附近,而不是cy。text-anchor="middle"让文字以 x 为中心;默认是start,左对齐。- SVG 里
&、<、>是特殊字符,文字里出现要转义。esc()就是干这个的。 rx是圆角半径。矩形节点用圆角,视觉上比直角柔和。
一个节点就是「圆 + 文字」:
def node(cx, cy, label, r=24, fill=BLUE_FILL, stroke=BLUE, fs=14):
return circle(cx, cy, r, fill, stroke) + t(cx, cy + 5, label, fs)三、箭头不能插进节点
如果直接从圆心画到圆心,箭头尖会藏在节点圆下面。看上去像线穿过了圆,不像连到圆。
解决方法是先把线段两端各缩回一个半径:
def edge(x1, y1, x2, y2, r1=0, r2=0, stroke=LINE, sw=1.4, marker=None, dash=None):
dx, dy = x2 - x1, y2 - y1
dist = max((dx * dx + dy * dy) ** 0.5, 1e-6)
ux, uy = dx / dist, dy / dist
sx, sy = x1 + ux * r1, y1 + uy * r1
ex, ey = x2 - ux * r2, y2 - uy * r2
return line(sx, sy, ex, ey, stroke, sw, marker, dash)先算单位向量 (ux, uy),起点沿方向前进 r1,终点沿反方向退回 r2。DNN 的节点半径是 24,所以每条边都传 r1=24, r2=24。
如果是矩形节点,不能直接用半径,要按方向算到矩形边界的距离。这一篇只用圆节点,留个口子,后面画方框图时再补。
四、DNN:两层嵌套循环画出所有连线
<!-- 配图:3-4-2 全连接,蓝线高亮 w11 -->
三层坐标:
xs = [170, 480, 790]
iy = [190, 300, 410] # 输入层 3 个
hy = [130, 240, 350, 460] # 隐藏层 4 个
oy = [240, 350] # 输出层 2 个全连接的边是笛卡尔积。输入层的每个 y 和隐藏层的每个 y 连一条:
for y in iy:
for yy in hy:
body.append(edge(xs[0], y, xs[1], yy, 24, 24))3×4 出 12 条线。隐藏到输出同理,4×2 出 8 条。加上 3 + 4 + 2 个节点,整张图 27 个元素,不需要手动画任何一条线。
参数量的标注直接写在图底下:
参数量 = (3×4 + 4) + (4×2 + 2) = 26这条标注和上一篇手算的结果对得上。画图和算账放在一起,图就不只是装饰了。
五、CNN:一个网格函数,三种用法
<!-- 配图:7×7 输入、3×3 核、5×5 特征图,高亮左上 3×3 -->
CNN 图里有三块网格:输入、卷积核、特征图。写一个 grid() 复用:
def grid(x, y, rows, cols, cell, values=None, highlight=None, fs=13,
fill="#ffffff", stroke="#dadce0"):
out = []
for r in range(rows):
for c in range(cols):
hl = highlight and highlight[0] <= r < highlight[1] and highlight[2] <= c < highlight[3]
f = BLUE_FILL if hl else fill
s = BLUE if hl else stroke
out.append(f'<rect x="{x + c * cell}" y="{y + r * cell}" '
f'width="{cell}" height="{cell}" fill="{f}" stroke="{s}" stroke-width="1.4"/>')
if values:
out.append(t(x + c * cell + cell / 2, y + r * cell + cell / 2 + fs / 3,
str(values[r][c]), fs, INK))
return "".join(out)highlight 是一个四元组 (行起, 行止, 列起, 列止),左闭右开。输入图高亮 (2, 5, 2, 5),就是中间 3×3 的感受野;卷积核高亮整块;特征图高亮左上角一格。
三个网格的调用:
body.append(grid(70, 140, 7, 7, 36, highlight=(2, 5, 2, 5)))
body.append(grid(430, 150, 3, 3, 56,
values=[["w11", "w12", "w13"],
["w21", "w22", "w23"],
["w31", "w32", "w33"]]))
body.append(grid(700, 150, 5, 5, 36, highlight=(0, 1, 0, 1)))输入 7×7、核 3×3、输出 5×5,尺寸对得上:(7-3)/1 + 1 = 5。
图底下的两行字,是这一篇最想让你记住的账:
参数量 9 + 1 = 10;换成 224×224 的图,还是 10
全连接处理 224×224×3 的图,一层就要 1.5 亿个权重;卷积只要 10 个。六、RNN:折叠画一次,展开画一次
折叠图的重点是自环。SVG 里用 <path> 画三次贝塞尔曲线:
body.append(path("M 374 222 C 330 120, 450 120, 406 222", YELLOW, 2.2, "ar-gray"))M 是起点,C 后面跟三组坐标:两个控制点、一个终点。控制点把曲线往上拉,形成一个从节点顶部出发、绕一圈再回到节点顶部的环。箭头在终点,方向自然朝下。
展开图用三列节点:
xs = [180, 560, 940]
for i, x in enumerate(xs):
body.append(node(x, 170, f"x{i}", 28, BLUE_FILL, BLUE))
body.append(node(x, 310, f"h{i}", 30, YELLOW_FILL, YELLOW))
body.append(node(x, 450, f"y{i}", 28, GREEN_FILL, GREEN))
body.append(edge(x, 198, x, 280, 28, 30, LINE, 1.7, "ar-gray"))
body.append(edge(x, 340, x, 422, 30, 28, LINE, 1.7, "ar-gray"))
for i in range(2):
body.append(edge(xs[i] + 30, 310, xs[i+1] - 30, 310, 0, 0, YELLOW, 2.4, "ar-gray"))三列画的是同一个计算,只是时间步不同。参数标签只标一次,就是在提醒你:W_xh、W_hh、W_hy 各只有一份。
七、GAN:虚线回路怎么画
<!-- 配图:z → G → G(z),真实 x 与 G(z) 进 D,损失分两路更新 -->
GAN 图里最难画的是「损失回到两个网络」的那两条虚线。直线从损失框回到 D 是竖直的,直接 edge 就行;回到 G 的那条要绕过假样本节点,用 <path>:
body.append(path("M 610 485 C 500 520, 360 440, 300 330",
RED, 1.8, "ar-red", "7 5"))stroke-dasharray="7 5" 表示实线 7 个单位、空白 5 个单位,循环下去。ar-red 是红色箭头 marker。
这条线的语义比形状重要:更新 D 时 G 冻结,更新 G 时 D 冻结。图上两条虚线各回各家,就是这句话的图形版。
八、总览:把四张图塞进 2×2
<!-- 配图:2×2 面板,DNN/CNN/RNN/GAN 各一个简化拓扑 -->
总览不是把四张大图缩小,而是重新画四个简化版。每个面板只保留最能代表该网络的形状:
- DNN:两列输入、三列隐藏、两列输出,全连接。
- CNN:4×4 网格、2×2 高亮、箭头指向 3×3 特征图。
- RNN:三个节点连成链,中间一个自环。
- GAN:z → G → D,外加一条从 D 顶部回去的箭头。
四个面板的坐标是手算的:
panels = [(30, 110, 500, 310), (550, 110, 500, 310),
(30, 440, 500, 310), (550, 440, 500, 310)]每个面板左上角 (x, y),宽 500、高 310。面板里的图形坐标都写成 x + 偏移、y + 偏移,这样换布局只改 panels 一行,里面的图形跟着走。
九、一次生成 11 张图
main() 只做一件事:切到脚本所在目录,按顺序调用 11 个 draw 函数。
def main():
here = os.path.dirname(os.path.abspath(__file__))
os.chdir(here)
draw_overview()
draw_dnn()
draw_neuron()
draw_cnn()
draw_rf()
draw_rnn_folded()
draw_rnn_unfolded()
draw_gan()
draw_params()
draw_matrix()
draw_conv_calc()每个 draw 函数最后调 save(name, w, h, body),把字符串写进文件。运行:
python3 draw_networks.py输出:
wrote overview.svg
wrote dnn.svg
wrote neuron.svg
wrote cnn.svg
wrote rf.svg
wrote rnn_folded.svg
wrote rnn_unfolded.svg
wrote gan.svg
wrote params.svg
wrote matrix.svg
wrote conv_calc.svg不装任何库,不联网,几秒钟跑完。
验收过程
先看文件:
ls -lh *.svg11 个文件,每个几 KB 到十几 KB,总共不到 100KB。SVG 是文本,体积和图形复杂度成正比,和分辨率无关;放大十倍也不糊。
再看图。DNN 的 3-4-2 结构、12 + 8 条连线、26 个参数,和理论学习篇的账对得上:
CNN 的 7×7 → 5×5、9 + 1 个参数,和案例拆解篇的公式对得上:
RNN 的折叠与展开,三列共用一组权重:
GAN 的 G、D、两条损失回路:
单个神经元、矩阵乘、感受野:
最后在 Obsidian 里打开三篇笔记,11 张图全部显示,没有裂图。理论篇用 6 张,案例篇用 6 张,实战篇用 11 张,有重复引用,路径都指向 ../assets/。
踩坑与返工
这一版不是一次画对的。下面四处是实际返工过的,记下来省得你再踩。
1. 神经元公式挤成一行
第一版把 z = ... + b 和 a = ReLU(z) 写在一个 <text> 里,用空格隔开。SVG 会压缩连续空格,渲染出来是 ... + b a = ReLU(z),两段公式粘在一起。
改法:拆成两个 <text>,分别放在 y=418 和 y=448。SVG 的 <text> 不会自动换行,也不会保留多个空格;要分行就多写一个标签,要对齐就用 text-anchor。
2. RNN 的 W_xh 标签压在箭头上
第一版把 W_xh 居中放在竖线旁边,x 和线的 x 一样。渲染出来,文字被箭头穿过,读不清。
改法:标签往右挪 18 像素,text-anchor="start"。箭头在 x=180,标签从 x=198 开始,两者不重叠。W_hy 同理。
3. 卷积算式和底部说明重叠
第一版把算式放在 y=448,底部说明放在 y=440,两行撞在一起,字叠字。
改法:算式保留在右列,底部说明下移到 y=518。画图前先在纸上列一下每块文字的 y 区间,比事后挪三次快。
4. 总览图里 RNN 的第三个节点没连上
第一版写了一个 for i in range(2),循环体里却用了固定的 x+106 和 x+214,两次画的是同一条边。结果第一个和第二个节点连上了,第三个节点孤零零挂在右边。
改法:两条边分开写,x+106 → x+214 和 x+246 → x+354。循环变量要用在坐标里,不然循环只是把同一件事做了两遍。
验收标准
按开头那四条走一遍:
python3 draw_networks.py一次跑通,输出 11 个 SVG。通过。- 每张图文字不重叠,箭头不插进节点。通过;上面四处返工就是为此。
- Obsidian 打开三篇笔记,图片全部显示。通过;路径统一用
../assets/。 - 改颜色/坐标重跑,输出跟着变。通过;配色集中在文件头的常量里,改一处全图生效。
再补一条隐藏验收:图上的数字和正文里的数字必须对得上。DNN 26、CNN 10、RNN 30,730、卷积 411,这四个数在图和文里各出现一次,错一个就露馅。
小结
这一篇没有训练任何网络,只做了「把结构画出来」这一件事。但画图的过程逼着你把每个坐标、每条连线、每个参数都想清楚。图上一根线画错,往往就是理解上漏了一个连接。
下一篇进入第 1 章,从最小的神经元开始,用 PyTorch 搭一个 MLP,把 MNIST 子集训到 95%。到时候你会看到,这一篇画的 DNN 图,就是那一章代码的结构图;图上 3-4-2 的每一层,在代码里都是一行 nn.Linear。
图是静态的,训练是动态的。静态的结构看懂了,动态的过程才有地方落脚。