“自然搞懂”深度学习系列(基于Pytorch架构)——03渐入佳境

Forester's Notebook

🤸‍♂️生活座右铭:勤而拂拭,莫染尘埃。 📚学习座右铭:一切烦恼来源于定义不清。 🙌留言:有任何问题欢迎交流学习,直接私信即可,一定会回!👌


"自然搞懂"深度学习(基于Pytorch架构)

文章目录

  • [Forester's Notebook](#Forester's Notebook)
    • "自然搞懂"深度学习(基于Pytorch架构)
      • [第Ⅰ章 初入茅庐](#第Ⅰ章 初入茅庐)
      • [第Ⅱ章 小试牛刀](#第Ⅱ章 小试牛刀)
      • [第Ⅲ章 渐入佳境](#第Ⅲ章 渐入佳境)
        • 一、初识CNN
          • [1.1 输入层(Input Layer)](#1.1 输入层(Input Layer))
          • [1.2 卷积层(Convolution Layer)](#1.2 卷积层(Convolution Layer))
          • [1.3 池化层(Pooling Layer)](#1.3 池化层(Pooling Layer))
          • [1.4 激活层(Activation Layer)](#1.4 激活层(Activation Layer))
          • [1.5 全连接层(Fully Connected Layer,FC Layer)](#1.5 全连接层(Fully Connected Layer,FC Layer))
        • 二、深入CNN
          • [2.1 Inception Block](#2.1 Inception Block)
          • [2.2 简化运算](#2.2 简化运算)
          • [2.3 梯度消失](#2.3 梯度消失)
        • 三、实战CNN
        • 四、初识RNN
          • [4.1 前向传播](#4.1 前向传播)
          • [4.2 反向传播](#4.2 反向传播)
          • [4.3 更新方式](#4.3 更新方式)
            • [4.3.1 逐个样本训练(单样本模式)](#4.3.1 逐个样本训练(单样本模式))
            • [4.3.2 mini-batch 训练(常用方式)](#4.3.2 mini-batch 训练(常用方式))
        • 五、深入RNN
          • [5.1 多分类问题](#5.1 多分类问题)
          • [5.2 双向传播](#5.2 双向传播)
          • [5.3 GRU](#5.3 GRU)
            • [5.3.1 GRU的提出](#5.3.1 GRU的提出)
            • [5.3.2 GRU思想](#5.3.2 GRU思想)
        • 六、实战RNN

第Ⅰ章 初入茅庐

第Ⅰ章 初入茅庐

第Ⅱ章 小试牛刀

第Ⅱ章 小试牛刀

第Ⅲ章 渐入佳境

一、初识CNN
1.1 输入层(Input Layer)

卷积神经网络(Convolutional Neural Networks,CNN) 是一种专门用来处理图片(或具有空间结构数据) 的神经网络。

一般来说,当今图片分为像素图和矢量图,前者由一个个像素方格组成,一个像素格代表[0, 255]的亮度值,于是,一张黑白图片便是一个数字矩阵。

为什么说是黑白图片呢?

因为,RGB 彩色图片有三个通道,分别存红、绿、蓝三种颜色分量。现在,你可以有一双"黄金瞳",将一张普通彩色图片视为三个矩阵堆叠在一起。

1.2 卷积层(Convolution Layer)

首先,介绍一个关键概念------Kernal(卷积核) :也就是小矩阵,用作窗口在大矩阵上滑行,进行计算操作。

接下来,我将操作流程定义为一个**++卷积层操作公式++ **:
卷积层操作 = 多组运算 = B ∗ 多通道运算 = B ∗ ( C ∗ 卷积运算 ) 卷积层操作 = 多组运算 = B*多通道运算 = B*(C*卷积运算) 卷积层操作=多组运算=B∗多通道运算=B∗(C∗卷积运算)

其中,B为输出通道数量,C为输入通道数量。

紧接着,我依次解释上述三种运算:

卷积运算:以Kernal为窗口遍历当前输入矩阵------将Kernal内元素与当前输入矩阵窗口内元素对应相乘,再相加得到一个数字,即为输出矩阵的一个对应位置元素,遍历完成即填满输出矩阵。

多通道运算:以C个Kernal对C个通道(即C个输入矩阵)分别进行卷积运算,得到C个输出矩阵,将C个矩阵对应元素相加得到一个新矩阵,即为最终矩阵。

多组运算:以B组Kernal(每组C个Kernal)进行相应多通道运算得到B个最终矩阵,即为B个特征图(Feature maps),也称为有B个通道。

Think-Help

操作中关键的参数主要有4个:++输出通道数B,输入通道数C,矩阵高H,矩阵宽W++。

从公式或图片亦或定义中不难看出:B决定多通道运算次数,最终想要多少个通道(多少个矩阵),就做多少次多通道运算;C决定卷积运算次数,给定输入有多少个通道,就必须做多少次卷积运算。

而矩阵的高和宽(H和W)分为卷积前后,主要取决于Kernal的大小。

设输入矩阵为A * A,Kernal为a * a,输出矩阵为X * X。(因矩阵通常为方阵,故如此假设)关系如下:
X = A − ( a / / 2 ) ∗ 2 X = A-(a//2)*2 X=A−(a//2)∗2

其中,//为整除符号。

不难理解:输出矩阵X上下左右都要减去Kernal边长的一半。

1.3 池化层(Pooling Layer)

池化层的主要目的是对特征图进行下采样(subsampling),也就是"缩小尺寸、保留重要信息"。

池化操作是对一个局部区域取代表值,有两种操作方式:

类型 计算方法 含义
最大池化 Max Pooling 取区域内最大值 提取最强特征(常用)
平均池化 Average Pooling 取区域内平均值 平滑特征(早期网络使用)

其中,最大池化操作如下图:

Think-Help

值得注意:池化仅改变矩阵尺寸(即H和W),不改变通道数。

同时补充两个十分重要的参数:padding(填充)和stride(步幅),虽然在池化层补充,但这两种操作在卷积层和池化层都可以做。

  1. padding

    在卷积层我们提到:输出矩阵会根据Kernal大小减少不同程度的边长,也就是损失了边缘信息。如果我们不想损失这些信息,如何做呢?

    自然地,在边缘补充一圈0,即输入矩阵上下左右都多了一行或一列。

    如下图,本来5 * 5的输入矩阵边长要减去2【2*(3//2)】变为3 * 3,但是将其扩充一圈变为7 * 7,这样,输出矩阵仍为5 * 5。

  2. stride

    表示卷积核或池化窗口 每次移动的步长,stride=2的情况如下图:

    注:池化的主要目的是"压缩尺寸",通常窗口之间不重叠,所以通常让步长等于窗口大小最自然。

1.4 激活层(Activation Layer)

处理图像数据,加入非线性特征也是至关重要的。

ReLU (Rectified Linear Unit)是目前 CNN 中最常用的激活函数:
f ( x ) = m a x ⁡ ( 0 , x ) f(x)=max⁡(0,x) f(x)=max⁡(0,x)

原因:计算简单、收敛更快、不容易梯度消失。

1.5 全连接层(Fully Connected Layer,FC Layer)

"卷积层是特征提取器,全连接层是分类器。"

保留特征图(Feature maps)形式,是没有办法判断最终类别滴,仍然需要"展平"(Flatten),将其变为一维向量,便于建立类别映射(如下),计算输出概率值。
y j = f ( ∑ i w i j x i + b j ) y_j = f\left( \sum_i w_{ij} x_i + b_j \right) yj=f(i∑wijxi+bj)

二、深入CNN
2.1 Inception Block

在学习完卷积神经网络常见的层次结构后,我们同样需要将其"组装"起来,以往我们学习到的神经网络设计模式都是线性,也就是将层次顺序链接起来,但实际应用中,往往需要功能更为复杂的设计模式,如下图(出自论文 《Going Deeper with Convolutions》,即 GoogLeNet ,这是 Google 在 2014 年提出的经典结构)。

其中我们勾画出一个多种层次路线混合的模块(称为Inception 模块),不同的是,这些层次路线是并行的而非串行。

Inception 模块是一种特殊的卷积块(block),它的核心思想是:

"给定多种选择让网络自己决定使用哪种卷积核尺寸来提取特征,而不是我们人为设定一个固定的卷积核。"
Think-Help

++为什么这么设计?++

传统 CNN(比如 LeNet、AlexNet)在每层卷积中,只使用固定大小的卷积核(例如全是 3×3)。

但问题是:

  • 小卷积核(1×1、3×3)→ 适合捕捉局部特征
  • 大卷积核(5×5)→ 能看见更全局的模式
  • 池化 → 帮助降维并提取主要特征

Google 团队就想:"既然不同大小的卷积核提取的信息不同,那我为什么不在一层里全部用上,然后再让网络自己学到该重视哪种特征?"

于是 Inception 结构就诞生了。

将层次并行,不同层次路线得出的结果最后如何整合呢?

按照通道(Channels)维度拼接结果,注意另外两个维度(H和W)自然必须一致。相当于把不同尺度的信息融合在一起,得到一个更强的综合特征表示。

2.2 简化运算

观察上面图片,你会发现有许多1 * 1的卷积层(Kernal尺寸为1 * 1),代入卷积层运算思考一下,这有必要吗?

似乎在提取特征方面没什么用,输出矩阵的尺寸没变,输出通道取决于你想要多少通道。那为什么还用呢?

简化运算! ++时间复杂度过高一直是CNN的痛点++,如下图使用1 * 1的Kernal会极大减少运算量(参照1.2自己算一遍哦)。

本质上就是中间进行一次通道缩小

2.3 梯度消失

设计好模式,在训练和测试时我们可能会遇到这样的情况:

可能有两个问题:过拟合【test error > train error & 56-layer > 20-layer】 和 梯度消失【56-layer > 20-layer,56-layer出现梯度消失导致模型无法得到有效训练】。

过拟合暂不多说,需要减少模型layer、增大惩罚项等操作;CNN的重点在于梯度消失,由于模型层数过多很可能导致梯度消失。

Think-Help

++梯度消失和鞍点问题的区别++

我们在第Ⅰ章提到过,神经网络的棘手问题是鞍点问题,为应对这个问题提出了SGD(随机梯度下降)和Mini-Batch(小批量梯度下降)。

而我们现在谈的是梯度消失,二者区别在于:

  • 鞍点问题指的是在反向传播过程中可能会偶然遇到**一个梯度**接近于0;
  • 而梯度消失问题指的是在反向传播过程中**多个梯度**(小于1)相乘,导致越往前传播梯度越小,最终几乎为 0,使早期层无法有效更新。

二者区别要尤其搞清,为应对梯度消失问题我们提出了几种方法(第1种前面1.4提到过,本节我们主要看第二种):

  1. 使用ReLU等非饱和激活函数(避免Sigmoid、tanh饱和区间造成梯度趋近0);
  2. 引入残差结构(Residual Connections),如ResNet,使梯度能直接跨层传播。
  3. 优化权重初始化方法(如Xavier或He初始化);
  4. 采用批量归一化(Batch Normalization) 来稳定梯度分布。

++引入残差结构ResNet++,如下图所示:

先看左侧传统网络(Plain net):输入x经过两层带权重的线性变换和非线性激活后,得到输出 H(x)。

再看右侧残差网络(Residual net,简称ResNet):输入x一方面进入普通的卷积层(两层 Weight Layer 加 ReLU),生成F(x);另一方面,被直接跳跃连接到输出端。

ResNet提出了一种新思想:让网络学习"残差"而不是直接学习映射 。残差定义为:
F ( x ) = H ( x ) − x F(x)=H(x)−x F(x)=H(x)−x
++这里的"残差"不同于损失函数中的残差++,因为它是映射函数输出减去输入而非真实y值,事实上我们也没法减去真实y值,ResNet在中间层而非输出层。

映射函数便为:
H ( x ) = F ( x ) + x H(x)=F(x)+x H(x)=F(x)+x

这样做为什么可以解决梯度消失问题?

很容易理解------ResNet结构中求梯度时绝对值永远大于等于1:
∂ H ( x ) ∂ x = ∂ F ( x ) ∂ x + 1 \frac{\partial H(x)}{\partial x}=\frac{\partial F(x)}{\partial x} +1 ∂x∂H(x)=∂x∂F(x)+1

这样,反向传播时再也不怕梯度消失啦!

三、实战CNN

待补充

四、初识RNN

循环神经网络(Recurrent Neural Network,RNN) 是一种专门处理序列数据的神经网络。

其最大的特点/优势就是**++"记忆"++**------能够结合之前的信息进行思考。

如下图所示,一组序列按照时间步依次输入RNN Cell,而每个RNN Cell都需要结合序列输入x和先前信息h进行下一步输出。

Think-Help

++RNN的特殊样本++

**在RNN中,一个样本(sequence)是一个时间序列。**序列按照时间步有多个元素,可能是一句话中的一个单词,可能是股价序列中的某一天:
x 1 , x 2 , ... , x T x_1,x_2,...,x_T x1,x2,...,xT

每个时间步输入一个 x_t,RNN 输出一个 h_t。

如:如果你在做语言模型,序列是词序列 [x1="我",x2="喜欢",x3="学习"]。这里 x1,x2,x3 是不同时刻的输入(每个可能是词向量)。

4.1 前向传播

具体的前向传播计算图及公式如下:

Think-Help

++RNN的特殊权重++

RNN 模型本身就只有一组参数(权重矩阵):
W x h : 输入 → 隐藏 W h h : 隐藏 → 隐藏 W h y : 隐藏 → 输出 W_{xh}:输入 → 隐藏\\ W_{hh}:隐藏 → 隐藏\\ W_{hy}:隐藏 → 输出 Wxh:输入→隐藏Whh:隐藏→隐藏Why:隐藏→输出

具体来说,同一样本中,不同时间步共享同一组参数(每个RNN Cell内部的参数都相同);不同样本间也共享参数。

简单来说,整个模型就一组参数。

关于每个Cell中的参数类型,由任务类型决定。

  • 多对多:对于每一步都要输出的任务,Cell中三个参数都有,如时序预测(股价每天都预测下一天)。

  • 多对一:对于只需要最后一步输出的任务,前面的Cell中只有W_xh和W_hh,如情感分类(整句话 → 一个情绪标签)。

4.2 反向传播

RNN中的反向传播被称作BPTT(Backpropagation Through Time)。

***初步理解:***相对于BP,BPTT中损失对参数的梯度求解时多考虑了"时间"因素。
∂ L ∂ W h h = ∑ t = 1 T ∂ L ∂ h t ⋅ ∂ h t ∂ W h h \frac{\partial L}{\partial W_{hh}} = \sum_{t=1}^{T} \frac{\partial L}{\partial h_t} \cdot \frac{\partial h_t}{\partial W_{hh}} ∂Whh∂L=t=1∑T∂ht∂L⋅∂Whh∂ht

这部分还是看实例更好理解(完整实例计算过程Xueyouing/Study-Naturally):

如果该序列时间步长是3呢?

那就需要再加上一个损失函数对W_hh的导数:
∂ L ∂ W h h    = ∂ L ∂ h 3 ⋅ ∂ h 3 ∂ h 2 ⋅ ∂ h 2 ∂ h 1 ⋅ ∂ h 1 ∂ W h h \frac{\partial L}{\partial W_{hh}} \; = \frac{\partial L}{\partial h_3} \cdot \frac{\partial h_3}{\partial h_2} \cdot \frac{\partial h_2}{\partial h_1} \cdot \frac{\partial h_1}{\partial W_{hh}} ∂Whh∂L=∂h3∂L⋅∂h2∂h3⋅∂h1∂h2⋅∂Whh∂h1

***直观上理解:***从1到N所有时间步长的RNN Cell都用到了W_hh,然后输出了相应的h_t,一步步传递,才得出最终输出值及损失值,最终要更新这个参数,不得求出每个Cell中的梯度再求和。

4.3 更新方式
4.3.1 逐个样本训练(单样本模式)

最原始的训练方式是:每次取一个完整序列------RNN 在时间上展开------做完前向、反向传播------更新参数------再取下一个样本。

流程伪代码:

复制代码
for sample in dataset:
    h = 0
    for x_t in sample:
        h = RNNCell(x_t, h)
    loss = compute_loss(h, target)
    loss.backward()
    optimizer.step()

该方法效率太低,无法利用 GPU 并行。

4.3.2 mini-batch 训练(常用方式)

现代训练都是 mini-batch 模式------我们把多个样本(序列)组成一个 batch,一起前向传播、一起反向传播、再一起更新参数**【每个batch更新一次参数】**。

假设 batch_size=2,每个序列长度=3:

时间步 样本1输入 样本2输入
t=1 x₁₁ x₂₁
t=2 x₁₂ x₂₂
t=3 x₁₃ x₂₃

训练时:

  • 前向:同时处理两个序列,每个时间步共享参数;
  • 反向:梯度沿时间轴回传,并在 batch 维度求平均
  • 更新:优化器对共享权重更新一次。

伪代码:

复制代码
for batch in data_loader:
    optimizer.zero_grad()
    outputs, hidden = model(batch_inputs)
    loss = criterion(outputs, targets)
    loss.backward()    # 所有样本 + 所有时间步的梯度累积
    optimizer.step()   # 更新一次参数
五、深入RNN
5.1 多分类问题

将RNN用于多分类问题:

和之前将全连接层构建的线性回归模型转为多分类模型思路一致:在其输出后加入交叉熵损失函数(CrossEntropyLoss),转为输出各类别概率值。

5.2 双向传播

对于一个序列(以一句话为例),传统做法是根据语句正向传播信息得到结果,语义的逆向是否也存在有价值的信息呢?

RNN还可以进行双向传播(序列正反方向各走一遍):

该隐藏层输出为:
h i d d e n = [ h N f , h N b ] hidden=[h_N^f,h_N^b] hidden=[hNf,hNb]

5.3 GRU
5.3.1 GRU的提出

对于RNN,BPTT时很容易导致梯度消失或爆炸,使模型难以记住长序列信息

Think-Help

++RNN的特殊问题++

我们先回顾一下梯度消失和爆炸两种情况

在所有神经网络中,反向传播的梯度更新都要用链式法则:
∂ L ∂ W = ∂ L ∂ h n ⋅ ∂ h n ∂ h n − 1 ⋅ ∂ h n − 1 ∂ h n − 2 ⋅ ... ⋅ ∂ h 1 ∂ W \frac{\partial L}{\partial W} = \frac{\partial L}{\partial h_n} \cdot \frac{\partial h_n}{\partial h_{n-1}} \cdot \frac{\partial h_{n-1}}{\partial h_{n-2}} \cdot \ldots \cdot \frac{\partial h_1}{\partial W} ∂W∂L=∂hn∂L⋅∂hn−1∂hn⋅∂hn−2∂hn−1⋅...⋅∂W∂h1

如果每一步的梯度范数 > 1 → 会越来越大(梯度爆炸);

如果每一步的梯度范数 < 1 → 会越来越小(梯度消失)。

注:先前我们并未提过梯度爆炸,因梯度消失更常见且更难解决。

而RNN相对MLP(全连接层)、CNN(卷积)神经网络更易梯度消失或爆炸,为什么?

  1. RNN 的"时间展开"导致乘法次数极多

    ++RNN的BPTT中,序列长度基本等价于层数。同一个权重 W_hh 会被在所有时间步重复使用。++

    例如一个长度为 100 的序列,RNN 展开后就是 100 层的"共享权重网络":
    h t = f ( W h h h t − 1 + W x h x t ) h_t = f(W_{hh}h_{t-1} + W_{xh}x_t) ht=f(Whhht−1+Wxhxt)

    反向传播时梯度链路是:
    ∂ L ∂ W h h ∝ ∏ t = 1 T ∂ h t ∂ h t − 1 \frac{\partial L}{\partial W_{hh}} \propto \prod_{t=1}^{T} \frac{\partial h_t}{\partial h_{t-1}} ∂Whh∂L∝t=1∏T∂ht−1∂ht

    这里的乘法次数 T(时间步长度)通常比 CNN 或 MLP 的层数大得多,比如 50、100、甚至几百。

  2. RNN 的循环结构容易形成"指数放大/衰减"

++记得4.1我们曾说过:整个RNN只有/共享一组参数。这意味着每次传播都会乘上同一个权重矩阵 W_hh。++

假设 W_hh 的最大特征值为 λ,那么梯度大致会按 ∣λ∣^T 变化。

  • 如果 ∣λ∣<1,梯度趋于 0 → 梯度消失
  • 如果 ∣λ∣>1,梯度呈指数增长 → 梯度爆炸

因为这种循环乘法在时间维度上持续发生,所以即使权重稍有不合适,也可能导致数值不稳定;而其它网络∣λ∣可能大于1可能小于1,会有所抵消。

5.3.2 GRU思想

于是人们提出了改进版 ------ GRU(门控循环单元)【Gated Recurrent Unit = 有门的循环单元】

首先给定完整公式:
z t = σ ( W z x t + U z h t − 1 ) 更新门 r t = σ ( W r x t + U r h t − 1 ) 重置门 h ~ t = tanh ⁡ ( W h x t + U h ( r t ⊙ h t − 1 ) ) 候选新状态 h t = ( 1 − z t ) ⊙ h t − 1 + z t ⊙ h ~ t 最终输出 \begin{aligned} z_t &= \sigma(W_z x_t + U_z h_{t-1}) && \text{更新门} \\ r_t &= \sigma(W_r x_t + U_r h_{t-1}) && \text{重置门} \\ \tilde{h}t &= \tanh(W_h x_t + U_h (r_t \odot h{t-1})) && \text{候选新状态} \\ h_t &= (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t && \text{最终输出} \end{aligned} ztrth~tht=σ(Wzxt+Uzht−1)=σ(Wrxt+Urht−1)=tanh(Whxt+Uh(rt⊙ht−1))=(1−zt)⊙ht−1+zt⊙h~t更新门重置门候选新状态最终输出

接下来,我们据此讲解其思路:

由前两个公式不难看出,更新门和重置门的公式格式一致:新输入x和上一步隐藏层输出(后面将其叫做"先前信息")分别乘以相应权重。

所以要从二者用途(也就是后两个公式)来区分不同:

  • 候选新状态:重置门越大,先前信息占比越大。
  • 最终输出:更新门越大,新状态占比越大。

于是,这两个"门"对应的作用可以这样说:

  1. 重置门(reset gate) --- 决定"要不要遗忘过去"
  2. 更新门(update gate) --- 决定"要不要更新记忆"
六、实战RNN

首先需要了解RNN常见参数:

参数名称 含义 作用
input_size 每个时间步输入向量的维度 例如:如果每个时间步输入一个词向量,则为词向量长度
hidden_size 隐藏层神经元数量(隐藏状态维度) 控制网络的"记忆容量"与建模能力
num_layers RNN 堆叠的层数 决定网络深度(多层RNN输出上层输入)
output_size 输出层神经元数量 取决于任务类型:分类、预测、回归等
seq_len 序列长度(时间步数量) 决定每次输入序列的时间跨度
batch_size 每次训练输入的样本数 控制并行训练规模
dropout 随机丢弃比例 防止过拟合(在层与层之间使用)
bidirectional 是否为双向RNN True表示同时考虑正向和反向序列信息

++Embedding(将元素转为向量)++

面对不等长序列,需要填充0,如下图所示:

而这些 0 是不必要进行计算的,于是引入一种更高效的方式------PackedSequence



恭喜你!到这里你洞悉了最经典的CNN、RNN神经网络,这是处理图片、文本等非结构化数据的基石。下面,我们将以实践为导向,追溯到近年来具有划时代意义的AI大模型。


  • 本文由Forester原创撰写,无偿分享,若发现侵犯版权、转卖倒卖等行为,追究其责任。

  • 为保证文体美观,各小节完整代码及PDF笔记已上传至GitHub:Xueyouing/Study-Naturally

  • 欢迎关注,未完待续!

相关推荐
Fuly10243 小时前
AI 大模型应用中的图像,视频,音频的处理
人工智能·音视频
掘金安东尼3 小时前
Cursor 2.0 转向多智能体 AI 编程,并发布 Composer 模型
人工智能
Small___ming3 小时前
【人工智能数学基础】如何理解方差与协方差?
人工智能·概率论
好家伙VCC3 小时前
**发散创新:AI绘画编程探索与实践**随着人工智能技术的飞速发展,AI绘
java·人工智能·python·ai作画
兔兔爱学习兔兔爱学习3 小时前
2025年语音识别(ASR)与语音合成(TTS)技术趋势分析对比
人工智能·语音识别
程序猿编码3 小时前
Linux 文件变动监控工具:原理、设计与实用指南(C/C++代码实现)
linux·c语言·c++·深度学习·inotify
中杯可乐多加冰3 小时前
服务编排搭建案例详解|基于smardaten实现协同办公平台复杂交互
人工智能·低代码
AndrewHZ3 小时前
【图像处理基石】图像匹配技术:从原理到实践,OpenCV实现与进阶方向
图像处理·人工智能·opencv·图像匹配·算法原理
有点笨的蛋3 小时前
从零构建你的 AIGC 后端:pnpm + dotenv + OpenAI SDK 的现代工程实践
人工智能·node.js