Skip to content

实战演练:用零依赖 Python 画出四张拓扑图 ​

计划里这一篇叫「30 行 NumPy 画出四张图」。真到动手的时候,环境里没有 NumPy,也没有 matplotlib:系统 Python 3.9,import numpy 直接报错,pip 能装但没必要为一个画图脚本装一堆依赖。

于是换了个方案:只用标准库,直接写 SVG。核心图元还是三十行左右,完整脚本四百多行,跑一遍输出 11 张图,Obsidian 直接引用。

这一篇就讲这个脚本怎么从零写出来。写完你会发现,画图和画网络是一回事:先把结构想清楚,再决定每个圆点、每条线放在哪。

开发过程 ​

先把需求写成一段提示词,丢给 AI,也丢给自己。提示词如下:

markdown
我要用零依赖 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. 改颜色/坐标重跑,输出跟着变

环境:

text
macOS
Python 3.9.6(系统自带)
无需 pip install

目录:

text
前置篇:一张图看懂四张网络/
├── 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。画布、图形、文字、箭头,全都是标签。最外层的壳:

python
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"/> 是白底。没有它,深色主题下透明背景会把线条吃掉。

二、四个图元:文字、圆、矩形、线 ​

后面所有图都由这四个函数拼出来。

python
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 是圆角半径。矩形节点用圆角,视觉上比直角柔和。

一个节点就是「圆 + 文字」:

python
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)

三、箭头不能插进节点 ​

如果直接从圆心画到圆心,箭头尖会藏在节点圆下面。看上去像线穿过了圆,不像连到圆。

解决方法是先把线段两端各缩回一个半径:

python
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:两层嵌套循环画出所有连线 ​

DNN 拓扑

<!-- 配图:3-4-2 全连接,蓝线高亮 w11 -->

三层坐标:

python
xs = [170, 480, 790]
iy = [190, 300, 410]              # 输入层 3 个
hy = [130, 240, 350, 460]         # 隐藏层 4 个
oy = [240, 350]                   # 输出层 2 个

全连接的边是笛卡尔积。输入层的每个 y 和隐藏层的每个 y 连一条:

python
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 个元素,不需要手动画任何一条线。

参数量的标注直接写在图底下:

text
参数量 = (3×4 + 4) + (4×2 + 2) = 26

这条标注和上一篇手算的结果对得上。画图和算账放在一起,图就不只是装饰了。

五、CNN:一个网格函数,三种用法 ​

CNN 卷积

<!-- 配图:7×7 输入、3×3 核、5×5 特征图,高亮左上 3×3 -->

CNN 图里有三块网格:输入、卷积核、特征图。写一个 grid() 复用:

python
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 的感受野;卷积核高亮整块;特征图高亮左上角一格。

三个网格的调用:

python
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。

图底下的两行字,是这一篇最想让你记住的账:

text
参数量 9 + 1 = 10;换成 224×224 的图,还是 10
全连接处理 224×224×3 的图,一层就要 1.5 亿个权重;卷积只要 10 个。

六、RNN:折叠画一次,展开画一次 ​

折叠图的重点是自环。SVG 里用 <path> 画三次贝塞尔曲线:

python
body.append(path("M 374 222 C 330 120, 450 120, 406 222", YELLOW, 2.2, "ar-gray"))

M 是起点,C 后面跟三组坐标:两个控制点、一个终点。控制点把曲线往上拉,形成一个从节点顶部出发、绕一圈再回到节点顶部的环。箭头在终点,方向自然朝下。

展开图用三列节点:

python
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:虚线回路怎么画 ​

GAN 结构

<!-- 配图:z → G → G(z),真实 x 与 G(z) 进 D,损失分两路更新 -->

GAN 图里最难画的是「损失回到两个网络」的那两条虚线。直线从损失框回到 D 是竖直的,直接 edge 就行;回到 G 的那条要绕过假样本节点,用 <path>:

python
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 顶部回去的箭头。

四个面板的坐标是手算的:

python
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 函数。

python
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),把字符串写进文件。运行:

bash
python3 draw_networks.py

输出:

text
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

不装任何库,不联网,几秒钟跑完。

验收过程 ​

先看文件:

bash
ls -lh *.svg

11 个文件,每个几 KB 到十几 KB,总共不到 100KB。SVG 是文本,体积和图形复杂度成正比,和分辨率无关;放大十倍也不糊。

再看图。DNN 的 3-4-2 结构、12 + 8 条连线、26 个参数,和理论学习篇的账对得上:

DNN

CNN 的 7×7 → 5×5、9 + 1 个参数,和案例拆解篇的公式对得上:

CNN

RNN 的折叠与展开,三列共用一组权重:

RNN 折叠

RNN 展开

GAN 的 G、D、两条损失回路:

GAN

单个神经元、矩阵乘、感受野:

神经元

矩阵乘

感受野

最后在 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。循环变量要用在坐标里,不然循环只是把同一件事做了两遍。

验收标准 ​

按开头那四条走一遍:

  1. python3 draw_networks.py 一次跑通,输出 11 个 SVG。通过。
  2. 每张图文字不重叠,箭头不插进节点。通过;上面四处返工就是为此。
  3. Obsidian 打开三篇笔记,图片全部显示。通过;路径统一用 ../assets/。
  4. 改颜色/坐标重跑,输出跟着变。通过;配色集中在文件头的常量里,改一处全图生效。

再补一条隐藏验收:图上的数字和正文里的数字必须对得上。DNN 26、CNN 10、RNN 30,730、卷积 411,这四个数在图和文里各出现一次,错一个就露馅。

小结 ​

这一篇没有训练任何网络,只做了「把结构画出来」这一件事。但画图的过程逼着你把每个坐标、每条连线、每个参数都想清楚。图上一根线画错,往往就是理解上漏了一个连接。

下一篇进入第 1 章,从最小的神经元开始,用 PyTorch 搭一个 MLP,把 MNIST 子集训到 95%。到时候你会看到,这一篇画的 DNN 图,就是那一章代码的结构图;图上 3-4-2 的每一层,在代码里都是一行 nn.Linear。

图是静态的,训练是动态的。静态的结构看懂了,动态的过程才有地方落脚。