news 2026/10/3 9:12:52

手写BP神经网络逼近二元函数:MATLAB实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写BP神经网络逼近二元函数:MATLAB实现与避坑指南

简介:这份压缩包内含一份基于MATLAB手写BP神经网络逼近二元函数的完整源码,面向希望深入理解反向传播原理、不依赖任何工具箱的机器学习初学者与研究生。包内仅含1个m脚本文件,整体大小约1KB,代码精简却完整覆盖了网络结构设计、随机权重与偏置初始化、前向传播计算、误差反向传播以及基于梯度下降的参数更新等关键步骤,同时自行实现了Sigmoid等激活函数与均方误差损失函数,没有调用任何神经网络高层接口。已有189人学习下载,资源适用于课程实验、毕业设计或项目前期的算法验证。通过逐行研读这份代码,可以清晰掌握BP神经网络从数学推导到编程落地的完整流程,理解二元函数逼近过程中网络如何调整权重以降低误差;同时该脚本结构清晰、便于修改,可作为扩展网络层数、更换激活函数或开展其他函数拟合任务的基础模板,对夯实机器学习底层功底很有价值。

1. 手写BP神经网络逼近二元函数:不调工具箱,先跑通再谈优化

在MATLAB里拟合一个二元函数,第一反应是打开nftool点几下,让神经网络工具箱帮你生成网络并训练完毕。但等你需要把模型部署到没有工具箱的机器上,或者想把损失函数换成带正则项、带约束的形式时,工具箱给出的网络就成了一个动不了的黑匣子。手写BP神经网络逼近二元函数,不调用神经网络工具箱,把前向传播、反向传播和梯度更新全部拆成自己的.m代码:输入两个自变量,隐层用tanh展开曲面,输出层给出函数值,然后靠样本和梯度把权重反复推向目标。这篇文章会把网络结构、激活函数、归一化、训练循环和最容易出错的细节整理成一套可以直接照做的方案,适合课程设计、函数拟合和代理模型建模的人。

2. BP神经网络逼近二元函数的理论前提:结构、激活函数与输入归一化

手写BP之前,先确认一遍理论依据和模型配置。二元函数逼近不是靠堆代码堆出来的,而是靠「单隐层有理论保证、激活函数选对、输入范围处理好」三个前提。这三个前提若有一个不满足,后面的训练不是发散就是死区。

2.1 单隐层就能逼近二元函数:BP神经网络结构图为什么画两层就够了

万能逼近定理说的是:闭区间上的连续函数,可以用一个带足够多隐层神经元的单隐层前馈网络任意逼近。所以「二元函数逼近」这个任务,理论上不需要很深的结构,单隐层就有解。但定理只保证存在这样的网络,不保证你的训练过程能找到它。这就是理论和工程之间的缝隙:你仍然需要靠神经元数量、初始化、学习率去把那个"存在的网络"找出来。

画BP神经网络结构图时,输入层通常不算层。输入层两个节点对应x1, x2,隐层放若干个tanh节点,输出层一个线性节点,这叫做单隐层网络。很多新手把输入层算进去,文档里写三层、代码里却按两层实现,导致矩阵维度怎么都对不上。判断层数最可靠的方式是看权重矩阵:输入层到隐层的W1是H×2,隐层到输出层的W2是1×H,只有两个权重矩阵,就是单隐层。

逼近目标选什么很关键。本文统一用z = sin(x1) * cos(x2),定义域取[-π, π]²。这个函数连续光滑、输出有界,但又包含明显非线性和对称结构,单隐层网络必须有一定数量的神经元才能拟合到位,非常适合用来验证手写代码是否真的把梯度算对了。

2.2 隐层神经元、激活函数和学习率的选型逻辑

隐层激活函数选tanh,不选sigmoid。sigmoid输出恒为正,上一层梯度更新方向容易朝一个方向偏移,收敛时来回震荡;tanh是零中心输出,梯度更新更对称,实际训练收敛明显更稳。输出层用线性激活,也就是不加激活函数。理由是二元函数的输出值域没有被限制在(-1,1),线性输出才能覆盖任意范围;如果你把目标值也归一化到[-1,1],输出层用tanh也能跑,但换一个目标函数就要重新设计,不划算。

隐层神经元数量基本靠试,多少带点玄学。拿本例来看,H=5时拟合明显不足,曲面被过度光滑;H=15时验证误差已经到可接受量级;H=30时训练误差更低,但验证集误差反而抬头,出现了过拟合。下表是同样的归一化数据和3000轮训练后的典型表现:

隐层神经元数验证MSE量级现象
51e-2左右欠拟合,曲面细节丢失
151e-4到1e-3整体贴合,边界略有偏差
301e-3到1e-2训练集更低,验证集反升

学习率方面,BP神经网络原理决定了每次更新步长由学习率和梯度共同决定。手写版不建议上来就用固定小学习率,先试试lr=0.1配合momentum=0.9,如果前几百轮发散就降到0.03或0.01。后文会讲怎么根据损失曲线判断。

2.3 输入归一化:为什么不做这一步模型必翻车

tanh在输入绝对值大于2时已经明显饱和,输入绝对值大于5时导数几乎为零。如果直接把[-π, π]丢进去,一部分样本会落在tanh饱和区,梯度消失,训练好几千轮loss纹丝不动。所以第一步必须归一化。通常把输入和输出都映射到[-1,1]区间,和tanh的输出范围对齐。

% 输入 X 是 2×N 矩阵,一列一个样本 X = [x1g(:)'; x2g(:)']; % 2×2500,网格全部展开 xmin = min(X, [], 2); xmax = max(X, [], 2); Xn = 2 * (X - xmin) ./ (xmax - xmin) - 1; % 输入归一化到 [-1,1] % 输出同样归一化 zmin = min(z_true(:)); zmax = max(z_true(:)); zn = 2 * (z_true(:)' - zmin) / (zmax - zmin) - 1;

这里min(X, [], 2)是按行取最小值,得到的是每个输入维度的范围,不要写成min(X),否则会把所有样本混在一起算出一个标量,两个自变量就失去了各自的尺度。输出归一化用的是训练集整体的zmin和zmax,之后反归一化也要用这两个值,不要用测试集重新统计。

注意:测试集和训练集必须共用同一套xmin/xmax/zmin/zmax。一旦用测试集自己的范围做归一化,相当于把测试集的分布信息泄给了模型,得到的验证误差会被严重低估。这是手写网络最容易踩的坑之一。

3. 不使用工具箱实现BP网络:初始化、前向传播与反向传播的MATLAB代码

这一章解决核心问题:不调用newff、train、sim这些工具箱函数,从裸矩阵开始搭网络。实现上主要用rand、tanh这些基础函数,MATLAB的矩阵计算天然适合向量化写法。下面代码都假设Xn和zn已经生成好,格式是每列一个样本。

3.1 网络初始化:权重初值范围为什么不能拍脑袋

权重初始化直接决定训练能不能起步。最忌讳的是全零初始化:所有隐层神经元输出相同,反向传播梯度也相同,整个网络退化成单神经元,永远学不出非线性曲面。正确做法是在对称区间内随机初始化,并且让方差和前后层神经元数挂钩。

H = 15; % 隐层神经元数 rng(42); % 固定随机种子,保证可复现 % Glorot 均匀初始化,限幅与前后层尺寸相关 fan_in = 2; % 输入层维度 fan_out = H; % 隐层维度 limit = sqrt(6 / (fan_in + fan_out)); W1 = rand(H, fan_in) * 2 * limit - limit; % H×2 b1 = zeros(H, 1); % H×1 limit2 = sqrt(6 / (fan_out + 1)); W2 = rand(1, H) * 2 * limit2 - limit2; % 1×H b2 = 0; % 标量

初值范围选择有讲究。rand(H, fan_in)产生[0,1)均匀随机数,乘2*limit再减limit后落在[-limit, limit]。Glorot初始化按sqrt(6/(fan_in+fan_out))限幅,匹配tanh的线性工作区,让前向传播的方差在层间尽量不膨胀也不消失。用rand(H,2)-0.5这种简易初始化也能跑通,但收敛速度和稳定性都会差一些,特别是在隐层节点数较多的时候。

偏置b1和b2初始化为零是安全的,因为零偏置不会破坏对称性——破坏对称性的是权重全零,不是偏置全零。

3.2 前向传播:从输入到预测的矩阵写法

前向传播就是把输入一层层送到输出。手写版全部用矩阵乘法,一次性算完所有样本,不要在MATLAB里写for循环逐样本前向,那个速度会慢到让你怀疑人生。

Z1 = W1 * Xn + b1; % 隐层净输入,H×m A1 = tanh(Z1); % 隐层激活值,H×m Z2 = W2 * A1 + b2; % 输出层净输入,1×m Y_pred = Z2; % 输出层线性,不做激活

逐行看维度:W1是H×2,Xn是2×m,乘出来是H×m,加b1时MATLAB会自动把H×1广播到H×m;W2是1×H,乘A1的H×m得到1×m。训练时如果报维度错误,先查这三个矩阵是不是符合这个链条。老版本MATLAB不支持隐式扩展,需要把+ b1改成bsxfun(@plus, Z1, b1),新版本直接用加号即可。

输出层不加激活函数,意味着Y_pred的取值范围不受限制。这是逼近任意幅值函数的必要选择。

3.3 反向传播:输出层和隐层的梯度推导与实现

反向传播是手写BP的核心,也是最容易算错的地方。以均方误差L = (1/2m) * sum((Y_pred - zn).^2)为例,链式法则分两步走。输出层误差delta2 = (Y_pred - zn) / m;隐层误差要把输出层误差通过W2传回来,再乘以tanh导数1 - tanh(Z1)^2,即delta1 = (W2' * delta2) .* (1 - A1.^2)。

m = size(Xn, 2); % 输出层误差 delta2 = (Y_pred - zn) / m; % 1×m % 隐层误差:误差回传 + tanh导数 delta1 = (W2' * delta2) .* (1 - A1.^2); % H×m % 梯度 grad_W2 = delta2 * A1'; % 1×H grad_b2 = sum(delta2, 2); % 1×1 grad_W1 = delta1 * Xn'; % H×2 grad_b1 = sum(delta1, 2); % H×1 % 动量更新 vW1 = 0.9 * vW1 - 0.1 * grad_W1; vW2 = 0.9 * vW2 - 0.1 * grad_W2; W1 = W1 + vW1; W2 = W2 + vW2; b1 = b1 - 0.1 * grad_b1; b2 = b2 - 0.1 * grad_b2;

delta1表达式里的(1 - A1.^2)就是tanh的导数,因为A1 = tanh(Z1),所以dA1/dZ1 = 1 - tanh(Z1)^2 = 1 - A1^2。这里必须用A1而不是Z1算导数,用错会出现整体符号或数值错误。梯度公式里delta2 * A1'是1×m乘m×H得到1×H,正好对齐W2的形状。如果写反成A1 * delta2',维度变成H×m×m×1,结果完全不对,还可能在旧版MATLAB里静默出错不报维度冲突。

动量项vW1的初值必须和W1同形状,全零初始化即可。更新公式采用的是v = momentum*v - lr*grad再叠加到权重上,这是常用的SGDM写法。没有动量项时学习率要取得更小,否则损失曲线容易抖动。

4. 训练与验证手写BP网络:网格采样、损失曲线与三维曲面检验

网络核心写完后,真正花时间的是训练策略和验证方法。训练集的采样方式影响收敛,验证方法决定你判断"逼近好了"的标准。只看训练集loss不管验证集,是手写网络最常见的自欺欺人。

4.1 网格采样:训练集怎么取才能避免偏置

边界光滑的二元函数,常规做法是在定义域内生成网格,再随机抽一部分做训练,剩下的做验证。网格间距要均匀,抽样要不重复且索引打乱,防止网络按样本顺序学出规律。

N = 50; x1v = linspace(-pi, pi, N); x2v = linspace(-pi, pi, N); [x1g, x2g] = meshgrid(x1v, x2v); z_true = sin(x1g) .* cos(x2g); % 50×50 真实曲面 idx = randperm(N * N, 2000); % 随机抽 2000 个不重复索引 X_train = [x1g(idx)'; x2g(idx)']; % 2×2000 z_train = z_true(idx)'; % 1×2000

这里randperm(N*N, 2000)从2500个网格点里随机选2000个,天然做到无放回抽样。剩下500个点可以作为验证集,但更推荐另外单独加密网格做验证,专门看网格间隙与边界。如果直接用全部2500点训练,训练集就把测试集覆盖了,得到的MSE只能反映记忆能力,不能反映逼近能力。

采样密度也要匹配网络容量。50×50网格对单隐层15个神经元来说足够,如果改成10×10,训练样本太少,网络会把噪声也学进去。样本量一般取网格点数的60%~80%做训练,其余做验证,比例再低趋势就不太稳了。

4.2 训练循环、学习率与收敛判定

手写BP的训练循环就是把前向、反向、更新三件事重复几千轮。不要每轮都打印loss,100轮或200轮打一次足够,否则终端刷屏反而看不出趋势。

max_epoch = 3000; lr = 0.1; % 学习率 momentum = 0.9; % 动量系数 vW1 = zeros(size(W1)); % 动量缓存,形状必须与权重一致 vW2 = zeros(size(W2)); for epoch = 1:max_epoch % ---- 前向 ---- Z1 = W1 * Xn + b1; A1 = tanh(Z1); Y_pred = W2 * A1 + b2; % ---- 反向 ---- delta2 = (Y_pred - zn) / 2000; delta1 = (W2' * delta2) .* (1 - A1.^2); grad_W2 = delta2 * A1'; grad_b2 = sum(delta2, 2); grad_W1 = delta1 * Xn'; grad_b1 = sum(delta1, 2); % ---- 带动量更新 ---- vW1 = momentum * vW1 - lr * grad_W1; vW2 = momentum * vW2 - lr * grad_W2; W1 = W1 + vW1; b1 = b1 - lr * grad_b1; W2 = W2 + vW2; b2 = b2 - lr * grad_b2; % ---- 周期性输出 ---- if mod(epoch, 200) == 0 train_mse = mean((Y_pred - zn).^2, 'all'); fprintf('epoch %d, train_mse %.4e\n', epoch, train_mse); end end

这里/2000写的是训练样本数,实际代码建议用size(Xn, 2)代替写死的数字,换数据集时不用改。学习率0.1配合动量0.9是手写BP比较通用的起步配置,loss下降平稳就没必要动;如果前200轮出现NaN或loss反而上升,把lr降到0.03。

收敛判定不要只看训练MSE。训练MSE会一路下降到一个极小的值,但逼近任务要的是验证集表现。实践中我会在每200轮用归一化后的完整网格数据算一次验证MSE,当验证MSE连续500轮不再下降或开始回升时,就该停了。这就是手写BP的早停,不需要工具箱自带函数。

4.3 逼近质量验证:画三维曲面而不是只看loss

训练结束后的第一件事不是看loss数值,而是把真实曲面和网络预测曲面各画一张surf图对比。损失函数只能告诉你误差的总体大小,但看不出误差集中在哪——是边界翘起来了,还是中间某个区域整体偏移。

X_all = [x1g(:)'; x2g(:)']; % 全部网格 X_all_norm = 2 * (X_all - xmin) ./ (xmax - xmin) - 1; Y_grid = W2 * tanh(W1 * X_all_norm + b1) + b2; % 1×2500 Y_grid = (Y_grid + 1) * (zmax - zmin) / 2 + zmin; % 反归一化 Y_grid = reshape(Y_grid, N, N); % 还原成 50×50 figure; surf(x1g, x2g, z_true, 'EdgeColor', 'none'); title('真实曲面'); figure; surf(x1g, x2g, Y_grid, 'EdgeColor', 'none'); title('BP逼近曲面');

反归一化公式是归一化的逆运算:z = (zn + 1) * (zmax - zmin) / 2 + zmin。训练时网络输出的是归一化后的值,必须在画图和计算指标前反归一化。两张图并排看:如果真实曲面有明显的波峰波谷,预测曲面也在同样位置有对应起伏,说明结构正确;如果预测曲面某一边被"削平"或者出现畸形隆起,优先怀疑输出层激活函数或归一化参数用错。

数值指标建议看三个:验证MSE、最大绝对误差、以及R²。最大绝对误差对边界翘边特别敏感,MSE小但最大绝对误差大,说明大部分区域拟合很好但局部有尖峰,这种情况对做代理模型的人来说不可接受。

5. 手写BP网络逼近二元函数的避坑记录:5个常见翻车现场

手写BP的报错不像工具箱那样弹出友好提示,很多问题表现为"训练跑完了但结果不对",排查全靠经验。下面5个坑是按照发生频率排的,每个都按现象、原因、解决三步说清楚。

5.1 权重全零初始化:loss卡住不动

现象:训练几千轮,训练MSE始终是同一个值,预测输出恒为零,损失曲线是条水平直线。

原因:权重全零时,所有隐层神经元输出相同,反向传播梯度大小也完全相同,更新后权重保持相等,网络退化为一个线性单元,永远无法产生非线性分割。这就是BP神经网络经典的对称性问题。

解决:按3.1节Glorot初始化重新设置权重。如果像rand(H,2)-0.5这样初始化后还是对称,检查是否在训练循环里误把权重重置为零了。多初始值训练里尤其要小心,每次重新初始化时必须重新rng。

5.2 输入没归一化,tanh全部饱和

现象:训练过程中loss下降极慢,有时看起来像卡住,把隐层激活值打印出来的话全是0.9999或-0.9999。

原因:输入范围在[-π, π]左右时,部分样本经过W1加权后落到tanh饱和区,导数接近0,反向传播回传的梯度几乎被吞掉。数值越大饱和越严重,整个网络进入死区。

解决:训练前把输入和输出归一化到[-1,1],测试集用同一套参数。如果已经归一化还是出现饱和,降低学习率并检查W1初始限幅是否过大,Glorot初始化里的sqrt(6/(fan_in+fan_out))可以防止权重初始值把净输入推得太远。

5.3 输出层用了tanh,峰值被削平

现象:曲面总体形状对,但波峰波谷处被明显截断,真实值为1.0的地方预测值只有0.8左右,边界处尤其明显。

原因:输出层用了tanh,输出被硬限制在(-1,1)区间。虽然训练时输出归一化到了[-1,1],但网络逼近的目标曲面边界梯度大,tanh输出在接近±1时压缩严重,很难精确命中边界峰值。

解决:输出层改为线性激活,即前向传播最后一步不做任何激活函数。这也是为什么前面3.2节里Y_pred = Z2而不是Y_pred = tanh(Z2)。改完之后再训练,峰值处的拟合立刻改善。

5.4 学习率太大或太小:损失曲线要么NaN要么不动

现象:学习率设为0.5或1时,loss前几十轮直接变成NaN;学习率设为0.001时,训练3000轮MSE还在1e-1量级徘徊。

原因:学习率过大,梯度更新跨过损失函数的窄谷,权重发散到数值溢出;学习率过小,每步更新幅度太小,3000轮根本走不到最优解附近。手写版没有工具箱的自适应优化器,所有步长控制都靠这个参数。

解决:从0.1起步,看前200轮loss趋势。如果震荡或变大,依次试0.03、0.01;如果loss稳定下降但速度太慢,试0.2。动量0.9可以缓解震荡,但动量加学习率同时调大容易过冲。记住一个经验:学习率翻倍训练可能发散,减半一般只是变慢,不会更差。

5.5 训练集固定网格且顺序未打乱:网络在背坐标而不是逼近曲面

现象:训练集MSE很低,但验证集MSE明显偏高,画出预测曲面后发现网格点处很准,网格间隙区域出现异常抖动。

原因:训练样本按网格顺序排列,网络学到了坐标到值的查表式映射;边界外没有样本约束,曲面就在边界处自由放飞,看起来像翘边。

解决:训练采样用randperm随机抽取并打乱,保证每个batch内样本没有顺序规律。验证集不要从训练集里留,单独加密网格生成。针对边界翘边,可以在边界区域加密采样,让网络在边界附近有更多约束。这也是4.1节强调随机抽样的原因。

6. 逼近精度的最后一块拼图:多初始值、学习率退火与Python迁移

手写BP逼近二元函数跑通之后,最后一个值得做的升级是解决"初值敏感"问题。单次训练的结果依赖rng的运气,同一个网络换个随机种子可能得到不同曲面。我的习惯是跑10次初始化,把验证集MSE最小的那组权重保存下来,相当于给训练过程留了后悔药。实践代码就是在3.1节初始化外包一层循环,记录最优权重。

best_val_loss = inf; for trial = 1:10 rng(trial); % 每次换种子,覆盖不同初值区间 % 初始化 W1,b1,W2,b2 % 执行完整训练循环 % 在验证集上计算 val_loss if val_loss < best_val_loss best_val_loss = val_loss; best.W1 = W1; best.b1 = b1; best.W2 = W2; best.b2 = b2; end end

多初始值之外,另一个容易见效的技巧是学习率退火:前1500轮用0.1快速下降,后1500轮降到0.03精细打磨。手写循环里加一个if epoch == 1500, lr = 0.03; end就行,不用额外依赖。

如果后续要把模型迁移到生产环境,这套手写结构天然可移植。bp神经网络python代码的常见做法就是把这套矩阵运算翻译成NumPy,训练循环、梯度公式完全不用改,维度检查比MATLAB更直观。原型阶段在MATLAB里把数据和量纲调对,上线阶段用Python重写,这是最稳的路径。

我现在的习惯是:任何准备上框架的项目,先用手写BP把数据和维度跑通一遍再换工具。不是不信工具箱,而是出了问题我能定位到具体是哪一行梯度算错了。希望这些踩坑记录能帮到正准备打开这个标题的你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 9:12:10

ip2region离线IP库:从接入到自建库的实战避坑指南

简介&#xff1a;ip2region 地址定位库 v2.11.2.zip 是一款开源的 IP 地址到地理位置快速映射组件&#xff0c;面向需要地域识别能力的开发者&#xff0c;可用于广告定向、内容分发、网络安全分析等场景。压缩包共 301 个文件&#xff0c;约 34.11MB&#xff0c;内含 C、Java、…

作者头像 李华
网站建设 2026/10/3 9:10:56

SpringBoot+Vue+Mysql美食推荐系统:前后端分离实战与部署全流程

很多做前后端分离项目的朋友&#xff0c;第一反应就是找个管理系统模板改改。这类系统看着热闹&#xff0c;但业务逻辑几乎是空的&#xff0c;做完除了熟悉一下Vue和SpringBoot的增删改查&#xff0c;很难沉淀出能写进简历的东西。我这次做的是一个美食信息推荐系统&#xff0c…

作者头像 李华
网站建设 2026/10/3 9:10:55

PyCharm与WSL:定位真实Python环境的终极指南

写这篇的起因&#xff0c;是我在技术社区里被连续问了好几次同一个问题&#xff1a;“我明明在 PyCharm 里选了 WSL 的 Python 环境&#xff0c;怎么跑起来之后装的东西全不见了&#xff1f;”“PyCharm 里配置的那个 python.exe 到底是 Windows 的还是 WSL 的&#xff1f;”“…

作者头像 李华
网站建设 2026/10/3 9:08:11

PyQt5五子棋AI实战:α-β剪枝、评估函数与桌面应用全解析

简介&#xff1a;基于Python与PyQt5打造的“多智能体博弈AI五子棋游戏”毕业设计项目&#xff0c;核心涵盖人机对战、深度优先搜索&#xff08;DFS&#xff09;与α-β剪枝算法&#xff0c;通过完整工程展示了博弈树搜索在棋类AI中的实际应用。资源面向计算机类毕业设计、课程设…

作者头像 李华
网站建设 2026/10/3 9:08:08

WPF动画实战:从属性机制到MVVM与3D看板全解析

做WPF开发这几年&#xff0c;我对界面的判断标准慢慢从“能不能用”变成了“好不好用、有没有质感”。早期用WinForm写上位机&#xff0c;按钮按下去没有任何反馈&#xff0c;页面切到哪一步全靠猜&#xff1b;后来转WPF&#xff0c;发现它天生自带一套动画引擎&#xff0c;不用…

作者头像 李华
网站建设 2026/10/3 9:06:11

Canvas实战:随机撒100颗五角星的几何原理与动画实现

先讲个真实经历。有阵子我想给一个活动页面做星空氛围&#xff0c;产品君丢过来一句话&#xff1a;“撒点五角星上去&#xff0c;要动的那种。”我心想&#xff0c;五角星有啥难的&#xff0c;循环五个点连起来不就成了&#xff1f;结果画出来的东西连我自己都看不下去——有的…

作者头像 李华