BASICS · MATH FOR AI · 只讲用得上的数学

AI 的数学地基:三个最少必要知识块

线性代数、概率统计、微积分——每块只留模型代码里真正出现的部分。读完你能看懂 softmax、交叉熵、梯度下降这些词到底在说什么,并且每一行公式都有对应的 Python 可以亲手跑。

python3 — numpy
>>> import numpy as np
>>> v = np.array([2.0, 1.0, 0.1]); w = np.array([1.0, 3.0, 0.5])
>>> float(v @ w)
5.05
>>> x = np.array([2.0, 1.0, 0.1]); t = 0.7
>>> e = np.exp((x - x.max()) / t); p = e / e.sum()
>>> p.round(3)
array([0.766, 0.184, 0.051]) — 三个数变成一组概率,和为 1.0
本页目录

01

先画地图:AI 用的数学,核心就三块

把深度学习拆开看,每一行模型代码背后只有三类数学在干活。线性代数管「数据怎么摆」——模型里所有东西(一段文本、一张图片、一批样本)都被摆成向量或矩阵,运算基本就是矩阵乘法。概率统计管「模型怎么表达不确定」——模型最后吐出的几乎总是一个分布,训练目标也是某种概率代价。微积分管「参数怎么改」——梯度、链式法则、梯度下降,全在这一块。

好消息是:不需要把大学教材从头啃一遍。下表是每块里真正会被模型代码用到的最小集合,按这个集合学,一周就有手感;集合之外的部分(证明、测度论、凸优化),等真的需要了再回来补也完全来得及。

这一页的用法建议(照着做效率最高):

  • 按顺序读:三块是层层依赖的——不懂点积就看不懂注意力,不懂链式法则就看不懂反向传播。
  • 边读边跑:每个代码块都完整可复制,REPL 里亲手敲一遍比看十遍记得牢。
  • 速查表当词典:读别的教程卡在符号上时,回这里搜一下就走。
  • 第 06 节是毕业考:手写反向传播跑通了,这一页的任务才算完成。
线性代数要留的
向量 / 矩阵 / 矩阵乘法 / 点积 / 特征向量
概率统计要留的
条件概率 / 贝叶斯 / 分布 / 交叉熵 / softmax
微积分要留的
导数 / 链式法则 / 梯度下降 / 学习率
起点要求
高中数学 + 会一点 Python
载体语言
numpy 数组(本页)与 torch 张量(API 对应)
学完能看懂
nn.Linear、temperature、cross entropy、lr、backward
学完能做
读懂模型代码的张量运算,手推两层网络的反向传播
本页不覆盖
测度论概率、凸优化证明、信息论推导
三块怎么配合:看一个 Transformer 的单次前向——注意力打分是 query 和 key 的点积(线代),输出层是 softmax 分布、训练目标是交叉熵(概率),整个模型靠反向传播 + 梯度下降更新(微积分)。下面每一节都围绕这张地图展开,讲一块就跑一块。

02

环境准备:装 numpy 就够,torch 可选

本页所有例子只依赖 numpy;PyTorch 用来对照同一段数学在框架里长什么样,装不装都不影响阅读。建议用虚拟环境,别往系统 Python 里硬塞。

numpy 对版本没有特别要求,装新版即可;本页输出基于 Python 3.12 + NumPy 2.5 实测,不同小版本的打印格式可能有细微差别(比如 numpy 2.x 的标量会显示成 np.float64(…)),数值本身一致。

Terminal
# 1) 建一个独立环境,不污染系统 Python
python3 -m venv .venv
source .venv/bin/activate      # Windows 是 .venv\Scripts\activate

# 2) 本页主力,只有它也够跑完全部例子
pip install numpy
python -c "import numpy; print(numpy.__version__)"

# 3) 可选:想对照 PyTorch 再装(CPU 版即可入门)
pip install torch
torch 的 GPU(CUDA)版本安装命令因平台、显卡驱动而异,以 pytorch.org 首页生成的命令为准。
装完先验货:在新终端里 python -c "import numpy; print(numpy.__version__)",能出版本号才算数。torch 包有数百 MB,第一次安装慢属正常;如果 import 报 ModuleNotFoundError,多半是没进虚拟环境,先 source .venv/bin/activate。

03

十分钟热身:向量、矩阵与第一个 softmax

不求全,只求把后面反复出现的三件东西先跑通:向量、矩阵乘法、softmax。复制进 REPL 或 Notebook,逐行看输出。

python3 · warmup.py
import numpy as np

# 向量:一摞有序的数,模型里几乎所有东西的形态
v = np.array([1.0, 2.0, 3.0])
print(v.shape)                 # (3,)

# 矩阵乘法:把同一个「变换」批量作用到一摞列向量上
A = np.array([[1.0, 0.0],
              [0.0, 2.0]])     # 对角阵:x 不动,y 翻倍
V = np.array([[1.0, 2.0],
              [3.0, 4.0]])     # 两列 = 两个被变换的向量
print(A @ V)
# [[1. 2.]
#  [6. 8.]]

# 第一个 softmax:任意一组数 → 和为 1 的概率分布
def softmax(x, t=1.0):
    e = np.exp((x - x.max()) / t)   # 减 max 防溢出;除以 t 是温度
    return e / e.sum()

print(softmax(np.array([2.0, 1.0, 0.1])).round(3))
# [0.659 0.242 0.099]
  • 01

    先看 shape,再谈运算

    90% 的 numpy 报错是形状对不上。任何一行写完先 print(x.shape),脑子里的形状和代码里的形状一致了,运算自然对。

  • 02

    @ 是「批量变换」

    A @ V 一次变换了 V 的两列——这就是「矩阵乘法为什么存在」的答案:它天生就是为了批量处理而发明的记号。

  • 03

    softmax 三行写完

    exp 归一化之前先减 x.max(),结果数学上完全等价但数值稳定——这是全行业通用的行规,第 07 节解释为什么。

  • 04

    用 torch 对齐验证

    torch.softmax(torch.tensor([2., 1., 0.1]), dim=-1) 应给出同样三个数。两边能对上,说明你理解的数学是对的。

这三件事就是后面一切的原料:矩阵乘法负责前向,softmax 负责输出,shape 检查负责你的血压。接下来第 04 节把三个知识块的原理一次讲透。
Terminal · 把热身代码存成 warmup.py 运行
$ python warmup.py
(3,)
[[1. 2.]
 [6. 8.]]
[0.659 0.242 0.099]
三段输出分别对应:向量形状、批量变换、概率分布。

顺手对齐一遍 PyTorch

python3 · torch 对照
import torch

logits = torch.tensor([2.0, 1.0, 0.1])
print(torch.softmax(logits, dim=-1))
# tensor([0.6590, 0.2418, 0.0992]):与 numpy 版完全一致
print(torch.softmax(logits / 0.7, dim=-1))
# tensor([0.7658, 0.1835, 0.0507]):温度 0.7,第一类更敢押注
numpy 与 torch 的 API 命名几乎一一对应,学会一个另一个看文档就会。后续小节同理,不再重复对照。

04

三个知识块:每块只留模型代码里真正出现的部分

线性代数:向量、矩阵与「批量变换」

向量就是一排有序的数:一个词的嵌入是向量,一句话(token 序列)摆成矩阵,一批样本再摞成更高维的张量——模型世界里没有「一段文字」「一张图」,只有数组。矩阵乘法 A @ V 的直观读法是:A 是一台变换机器,V 的每一列是一个输入,乘一次等于把所有输入一起变完。深度学习的「前向传播」,本质就是一长串矩阵乘法连着做,中间夹着逐元素的非线性函数。

点积是线代里出场率最高的运算:两个同长向量对应相乘再求和,结果越大方向越一致。把向量先除以自身长度(归一化)再做点积,得到的就是夹角余弦:1 表示同向、0 表示垂直。注意力机制的底座就是这一下——query 和每个 key 算相关度,分数高的词多分一点注意力。特征向量则是「矩阵作用后方向不变」的特殊方向(A v = λv),直觉上是被变换机器偏爱的方向:PCA 降维、谱方法都在找它们。

python3 · linear_algebra.py
A = np.diag([2.0, 3.0])            # 变换机器:x 放大 2 倍,y 放大 3 倍
v = np.array([1.0, 1.0])
print(A @ v)                       # [2. 3.]:一次乘法,批量变换

u = np.array([1.0, 0.0, 1.0]); w = np.array([1.0, 1.0, 0.0])
cos = u @ w / (np.linalg.norm(u) * np.linalg.norm(w))
print(cos.round(3))                # 0.5:夹角 60°,方向只算半像

vals, vecs = np.linalg.eig(A)      # 特征向量:变换后方向不变的方向
print(vals)                        # [2. 3.]:坐标轴本身就是它的特征向量

把「矩阵的一行 = 一个神经元的权重」这个视角接回模型代码:神经网络的全连接层(nn.Linear(4, 2))本质就是一次矩阵乘法加一个偏置,x @ W + b,没有任何多余的东西:

python3 · linear_layer.py
x = np.array([[1.0, 2.0, 3.0, 4.0]])               # 一个样本,4 维
W = np.arange(8, dtype=float).reshape(4, 2) / 10   # 4 个输入 × 2 个神经元
b = np.array([0.1, -0.1])
print(x @ W + b)
# [[4.1 4.9]]:这就是 nn.Linear 的全部数学

更高维同理:卷积是「局部小矩阵乘」,注意力是「带权重的矩阵乘」——记住 x @ W + b,模型里的每一层都是它的变奏。

特征向量暂时用不上别慌:它在本页的角色是「看懂 PCA 降维和谱方法的直觉」。做 AI 前两年你可能一次都不手算它,但读论文遇到「主成分」「谱聚类」时,想起「找矩阵偏爱的方向」就够了。

概率统计:模型输出的语言

条件概率 P(A|B) 回答「已知 B 时,A 多大概率」;把它倒过来推就是贝叶斯公式 P(B|A) = P(A|B)·P(B) / P(A)——知道「有病的人里多少比例验出阳性」,可以反推「验出阳性的人里多少真有病」。垃圾邮件过滤、医学检测、贝叶斯优化,骨架都是这一行。常见分布记两个就够:伯努利管「是 / 否」(分类标签),高斯管「连续测量」(权重初始化 rng.normal 的默认来源)。

python3 · bayes.py
p_dis = 0.01                 # 患病率 P(D):基础率
p_pos_dis = 0.99             # 敏感度 P(+|D):有病的人 99% 验出阳性
p_pos_ok = 0.05              # 假阳性 P(+|健康):健康人也有 5% 阳性
p_pos = p_dis * p_pos_dis + (1 - p_dis) * p_pos_ok
print(p_dis * p_pos_dis / p_pos)     # 贝叶斯公式逐项代入
# 0.1666…:阳性结果里只有约 1/6 真有病

「检测很准」和「你有病」是两回事——基础率越低,假阳性占的比重越大。垃圾邮件过滤、异常检测都要过这道坎。

交叉熵就是「猜错有多大代价」:−Σ yᵢ·log pᵢ,真实类别上模型给的概率越低,代价越大。押对 70% 代价 0.357,只剩 10% 代价 2.303——对数把「错得离谱」惩罚得极重,这就是它当损失函数的原因。它的搭档是 softmax + 温度:softmax 把一组打分变成概率分布,除以温度 t 控制分布的尖平;大模型生成文本时的 temperature 参数就是它——t 调小输出保守确定,t 调大天马行空。

python3 · probability.py
p = np.array([0.7, 0.2, 0.1])          # 模型给出的预测分布
y1 = np.array([1, 0, 0]); y3 = np.array([0, 0, 1])
print((-np.sum(y1 * np.log(p))).round(3))   # 0.357:押对了,代价小
print((-np.sum(y3 * np.log(p))).round(3))   # 2.303:押错到只剩 10%,代价大

x = np.array([3.0, 2.0, 0.5])
for t in (0.5, 1.0, 2.0):
    e = np.exp((x - x.max()) / t)
    print(t, (e / e.sum()).round(2))
# 0.5 [0.88 0.12 0.01]   温度越低分布越尖:0.88 → 0.69 → 0.53
# 1.0 [0.69 0.25 0.06]
# 2.0 [0.53 0.32 0.15]
选哪个损失:分类任务(输出是概率分布)用交叉熵,回归任务(输出是连续值)用 MSE。硬背没意思,本质是「输出服从什么分布,就用对应的极大似然损失」——这条原则后面学任何新损失都套得上。

顺带认识熵 H(p)——分布自身的不确定性,也是交叉熵的前半截:

python3 · entropy.py
p = np.array([0.5, 0.5])
print(-(p * np.log(p)).sum())     # 0.6931:五五开,最拿不准
q = np.array([0.99, 0.01])
print(-(q * np.log(q)).sum())     # 0.0560:快确定了,熵很小

微积分:导数、链式法则与梯度下降

导数回答「x 动一点,y 动多少」,几何上就是斜率。多个参数时对每个参数各求一次偏导,排成向量就是梯度 ∇f,指向上升最快的方向;训练要下降,所以每步沿负梯度走:w ← w − lr·∇f,这就是梯度下降。学习率 lr 是唯一的驾驶参数:太大会在碗壁之间来回震荡甚至飞成 NaN,太小则半天挪不动。

为什么需要链式法则?因为网络是嵌套函数 y = f(g(h(x))):损失在最外层,参数在最内层,梯度只能一层层乘回来(f′·g′·h′)。「逐层乘回来」这套算法就是反向传播——第 06 节的手写实现里,你会看到它每一层长什么样。tanh 的导数是 1 − tanh²,sigmoid 的是 σ(1−σ),这两个式子手推网络时天天用。

torch 的 autograd 把这套「逐层乘回来」自动化:你只需声明哪些张量需要梯度,backward() 会沿着计算图把整条链算完:

python3 · autograd.py
import torch

x = torch.tensor(2.0, requires_grad=True)
y = (x - 3) ** 2            # 同一只碗
y.backward()                # 自动求导:链式法则全自动
print(x.grad)               # tensor(-2.):和手推的 2*(2-3) 一致

以后写完任何手推梯度,都可以用第 03 节的中心差分对拍一遍——数值梯度是解析梯度的廉价验算器,这一招叫梯度检查(gradient check),是写自定义层的标准保险。

python3 · calculus.py
f  = lambda x: (x - 3) ** 2          # 一只碗
df = lambda x: 2 * (x - 3)          # 导数 = 斜率

h = 1e-6                            # 数值导数验算:中心差分
print((f(2.0 + h) - f(2.0 - h)) / (2 * h))
# -2.0000000001…,真实导数正是 -2

x = 0.0                             # 梯度下降:沿负梯度滚到碗底
for _ in range(60):
    x -= 0.1 * df(x)                # lr = 0.1
print(x)                            # 2.9999954…:基本到底了

三块在一次训练步里如何分工

把三块拼回一次真实的训练迭代,分工一目了然——第 06 节会把这三行扩成 30 行完整代码:

前向传播
线性代数:一摞矩阵乘法把输入算成预测(X @ W1 + b1 …)
计算损失
概率统计:softmax 把输出变成分布,交叉熵量化「错了多少」
反向传播
微积分:链式法则把误差逐层乘回每个参数
参数更新
还是微积分:梯度下降沿负梯度走一步,学习率决定步幅

同一个循环在训练里有一堆名字:SGD、optimizer.step()、backward() + step()——名字在变,骨架永远是「前向 → 损失 → 反向 → 更新」这四拍。第 06 节会把这四拍一次写全。

05

速查表:符号、API、损失、求导

这一节当词典用。忘了符号含义、numpy 函数名、温度采样怎么写,直接在下面搜——中英文关键词都能搜到。

符号含义
x ∈ ℝⁿ一个 n 维向量,比如 3 个数排成一排
xᵢ向量的第 i 个分量(公式里从 1 数,代码里从 0 数)
‖x‖向量长度(L2 范数):各分量平方和开根号
x·y 或 ⟨x,y⟩点积:对应位置相乘再求和,值大 = 方向像
A ∈ ℝ^(m×n)m 行 n 列的矩阵
Aᵢⱼ矩阵第 i 行第 j 列的元素
Aᵀ转置:行列互换,形状 (m,n) 变 (n,m)
Iₙn 阶单位矩阵:乘谁谁不变
∇f梯度:f 对每个参数的偏导排成的向量,指向最陡上升方向
∂L/∂w损失 L 对参数 w 的偏导数(梯度里的一个分量)
P(A|B)条件概率:B 已发生时 A 的概率
E[X]期望:随机变量取值的长期加权平均
H(p)熵:分布的不确定性,越平越大
argmax让表达式取最大值的那个自变量
∑求和:∑ᵢ xᵢ 就是把所有分量加起来
𝒩(μ, σ²)正态(高斯)分布:均值 μ、方差 σ²,钟形曲线

06

实战:纯 numpy 手写一个能学习的两层网络

目标任务:给 64 个标量样本,让网络自己学出 y = 3x + 1。结构 1 → 16 → 1,写成一行就是 y = tanh(x·W1 + b1)·W2 + b2——只有两个参数矩阵;隐藏层用 tanh,损失是 MSE,优化器就是第 04 节的梯度下降。没有任何黑盒,每一行都对应前面讲过的知识块。

第 1 步:造数据、初始化参数

python3 · 数据与参数
import numpy as np

rng = np.random.default_rng(0)          # 固定种子,结果可复现
X = rng.uniform(-1, 1, (64, 1))         # 64 个标量样本,形状 (64, 1)
Y = 3 * X + 1                           # 要学的目标:一条直线

W1 = rng.normal(0, 1, (1, 16)); b1 = np.zeros(16)   # 隐藏层参数
W2 = rng.normal(0, 1, (16, 1)); b2 = np.zeros(1)    # 输出层参数
lr = 0.05                               # 学习率:每步走多远

种子固定为 0,意味着你在自己机器上跑出的 loss 数字会和本页输出一模一样——调参实验第一课:可复现优先。

第 2 步:前向传播=两连矩阵乘法(线性代数)

python3 · forward
h = np.tanh(X @ W1 + b1)        # (64,1)@(1,16) → (64,16),非线性压到 -1~1
y = h @ W2 + b2                 # (64,16)@(16,1) → (64,1),网络输出
loss = np.mean((y - Y) ** 2)    # MSE:平均平方误差,越小学得越像

第 3 步:反向传播=链式法则逐层乘回来(微积分)

python3 · backward
dy = 2 * (y - Y) / y.size       # dL/dy:MSE 对输出的导数
dW2, db2 = h.T @ dy, dy.sum(0)  # 输出层梯度:上游差 × 本层输入
dh = dy @ W2.T * (1 - h ** 2)   # 穿过 tanh:它的导数是 1 − tanh²
dW1, db1 = X.T @ dh, dh.sum(0)  # 隐藏层梯度:同一套乘法再来一遍

对照看:每一层的梯度都是「上游传来的误差 × 本层输入的转置 × 本层激活函数的导数」——这就是链式法则在矩阵世界里的长相。torch 的 loss.backward() 做的就是这两步的自动化。

第 4 步:完整程序(直接复制运行)

python3 · mini_net.py 全文
import numpy as np

rng = np.random.default_rng(0)
X = rng.uniform(-1, 1, (64, 1))     # 64 个标量样本
Y = 3 * X + 1                       # 目标:学一条直线

W1 = rng.normal(0, 1, (1, 16)); b1 = np.zeros(16)
W2 = rng.normal(0, 1, (16, 1)); b2 = np.zeros(1)
lr = 0.05

for step in range(2001):
    # ---- 前向:线性代数 ----
    h = np.tanh(X @ W1 + b1)
    y = h @ W2 + b2
    loss = np.mean((y - Y) ** 2)

    # ---- 反向:链式法则 ----
    dy = 2 * (y - Y) / y.size
    dW2, db2 = h.T @ dy, dy.sum(0)
    dh = dy @ W2.T * (1 - h ** 2)
    dW1, db1 = X.T @ dh, dh.sum(0)

    # ---- 更新:梯度下降 ----
    W1 -= lr * dW1; b1 -= lr * db1
    W2 -= lr * dW2; b2 -= lr * db2
    if step % 500 == 0:
        print(f"step {step:4d}   loss {loss:.4f}")

x0 = np.array([[0.5]])
pred = np.tanh(x0 @ W1 + b1) @ W2 + b2
print("predict x=0.5 ->", pred.round(2), "(target 2.50)")
实际运行输出
step    0   loss 20.8016
step  500   loss 0.0008
step 1000   loss 0.0004
step 1500   loss 0.0004
step 2000   loss 0.0003
predict x=0.5 -> [[2.52]] (target 2.50)

loss 从 20.8 掉到 0.0003,预测 2.52 贴着目标 2.50——网络真的从数据里「学」出了那条直线,而不是被我们写死。

收敛形态也有信息量:绝大多数改善发生在前几百步,之后进入漫长的平台期。真实训练里通常配一个验证集,loss 不再下降就早停,省下的时间比调参多。

第 5 步:跑起来之后,改这三个数

python3 · 三个实验
lr = 0.9               # 改大:loss 可能不降反升,甚至 NaN
# lr = 0.005           # 改小:收敛明显变慢
# Y = np.sin(3 * X)    # 换个更难的目标:直线 → 曲线
亲手看到「学习率大了会炸、小了太慢」这一刻,你对 lr 的理解就从名词变成了手感。顺手把隐藏层从 16 改成 4,观察直线任务照样能学——容量够用就行也是一条直觉。

想再进一步:把回归改成分类,只需三处替换——损失从 MSE 换成交叉熵,输出层后面接 softmax,目标 Y 换成 one-hot 标签;网络结构、前向、反向的骨架一字不用动。这正是第 04 节「三块分工」图的意义:换任务换的是概率那一块,其余两块照旧。

07

避坑:新手最先撞上的四个坑

坑一:softmax 里 exp 溢出成 NaN

logits 一大(比如 1000),np.exp 直接上溢成 inf,inf/inf 得 nan。解法是行业通用行规:减去最大值再 exp,数学上完全等价(分子分母同乘同一个常数 e^(−max),分布不变),数值上永远安全。大模型生成里 logits 同样可能上千,所有框架的 softmax 内部都做了这一步。

python3 · 数值稳定 softmax
x = np.array([1000.0, 999.0])
# np.exp(x)              # overflow → inf/inf = nan
e = np.exp(x - x.max())    # 减 max:等价且稳定
print((e / e.sum()).round(3))   # [0.731 0.269]

坑二:* 是逐元素乘,@ 才是矩阵乘

写损失、写梯度时混用两者,不报错但结果悄悄错——这是最阴险的一类 bug。手推梯度时想「这步是点积还是逐元素」,落到代码里就对应 @ 还是 *,一一对应就不会错。

python3
a = np.array([1.0, 2.0]); b = np.array([3.0, 4.0])
print(a * b)     # [3. 8.]:逐元素相乘,shape 不变
print(a @ b)     # 11.0:点积,出来的是标量

坑三:(n,) 和 (n,1) 不是一回事

一维数组和列向量在广播时行为完全不同。转置一维数组什么都不会发生(.T 对 (n,) 无效),误以为转了方向,后面的乘法就全错。

python3
a = np.array([1.0, 2.0, 3.0])        # shape (3,)
b = np.array([[1.0], [2.0], [3.0]])  # shape (3,1)
print((a * b).shape)                 # (3, 3):广播悄悄扩张了

col = a[:, None]                     # 想要列向量,显式造:(3,1)
print(col.shape)                     # (3, 1)

经验法则:凡是「一行是一个样本」的场合,坚持用二维形状 (batch, features);(n,) 只留给纯一维运算。形状约定一旦固定,广播 bug 就少一大半。

坑四:loss 变 NaN,按顺序查三处

python3 · NaN 排查清单
# 1) 学习率太大:步子迈飞了 → lr 除以 10 再试
# 2) log(0):概率下溢为 0 → 写成 np.log(p + 1e-9)
# 3) 除零:分母用 y.size 这类真实计数,别写死常数
# 4) 梯度爆炸:连乘后巨大 → clip_grad_norm_ 裁剪一下
心法:NaN 几乎从不来自「模型不够好」,而来自这三类数值事故。遇到时把 lr 减半、给 log 加个极小值、打印中间张量的 min/max,一两分钟就能定位。

08

进阶:把三块数学接回模型

下面这些资源按「先直觉、后严谨」排序,前两个用一周,后两个用一两个月:

别按顺序全啃:先用 3Blue1Brown 建直觉,再拿 Karpathy 的视频配本页第 06 节动手,卡住了再回花书查严谨定义。数学书的正确读法是按需查阅,不是从头到尾。

下一步去哪:推荐顺着 自然语言处理 NLP 往前走——词向量、注意力、softmax 采样这些概念全是本页三块数学的直接应用,学以致用比再刷一本数学书有效得多。

09

收尾:合上页面前,勾一遍

五条全勾上,你手里就有一张能读懂大部分模型代码的数学地图。哪条没把握,就回对应小节再跑一遍代码——数学是跑会的,不是看会的。

0 / 5

已复制