ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TCN-Transformer-LSTM组合模型回归+SHAP分析+多输出预测MATLAB实现

TCN-Transformer-LSTM组合模型回归+SHAP分析+多输出预测MATLAB实现 简介本资源是一套面向时间序列多输出回归任务的MATLAB智能建模与可解释性分析完整方案适用于高校科研人员、工程技术人员及高年级本科生开展时序预测、模型融合与特征归因研究。资源融合TCN的局部时序感知能力、Transformer的长程依赖建模优势与LSTM的动态演化特性构建端到端混合神经网络并集成SHAP值量化各输入特征对多个输出变量的贡献度兼顾预测精度与决策透明性。压缩包共7个文件56KB含3个Excel数据文件原始数据、新输入、预测结果、3个核心MATLAB脚本主训练main.m、新数据预测newpre.m、SHAP计算shapley_function.m及1份运行说明txt结构紧凑、即装即用。目前已有60人学习下载提供从数据读取、归一化、训练验证、多指标评估R²/MAE/RMSE、全维度可视化结构图、训练曲线、预测对比、误差分布、散点拟合到新样本批量预测的一站式实现代码注释清晰适合作为时序建模进阶实践范例。 TCN-Transformer-LSTM组合模型回归SHAP分析新数据预测多输出MATLAB完整代码和数据最近一直在折腾时序回归预测尤其是那种输入特征多、输出指标也多、数据量还不小的场景。单靠LSTM吧长序列容易遗忘纯Transformer吧位置编码和注意力堆叠在中小样本上容易过拟合TCN虽然卷积结构稳定、训练快但它的感受野扩展也有天花板。后来我把这三者揉在一起做成了TCN-Transformer-LSTM的串行组合结构配合SHAP做特征归因分析还顺手解决了多输出回归和新数据批量预测的问题整套代码和数据都是在MATLAB里跑通的。这篇文章就把我的完整思路、踩过的坑、以及可以直接抄的MATLAB实现细节全部摊开讲一遍。先说一下这套东西适合谁手里有一批多维时序特征要预测多个连续目标的做风控、设备健康度、能耗预测、金融回归、负荷预测这类任务的人想把深度学习模型的“黑盒”打开用SHAP解释哪些特征真正在影响输出的以及在MATLAB里做研究、写论文、跑实验的同学。这套方案从数据预处理到模型训练再到可解释性分析和新数据预测是一条完整的链路。1. 项目整体设计思路为什么要做三大模型组合很多做时序预测的人第一步都会陷入一个选择题LSTM和Transformer到底选谁再加上一个稍微冷门一点的TCN三选一往往选得纠结。我的做法是干脆不选——让它们各司其职按顺序串联成一条特征提取流水线。这个思路的核心逻辑很简单不同模型擅长捕捉不同粒度的信息与其互相替代不如前后接力。1.1 三种模型能力拆解TCN时间卷积网络的本质是一堆膨胀因果卷积层。它有两点优势一是卷积的并行度高训练速度往往比循环结构快不少二是膨胀卷积可以指数级扩大感受野理论上能覆盖很长的历史区间。但TCN有个天然短板——它的卷积核是固定尺寸的对序列中“远处依赖但位置不确定”的信息捕捉能力有限而且它对全局上下文的建模能力不如注意力机制。Transformer的价值在于自注意力机制。它能把序列里任意两个位置直接建立联系距离不再是障碍。多头注意力又允许模型从多个子空间同时提取不同维度的关系。但Transformer的问题也很现实它缺少时序的天然顺序感需要位置编码硬塞进去在小数据集上非常容易过拟合训练还特别吃显存和算力。LSTM则是经典的循环结构擅长按时间顺序逐步建模记忆单元能保留长期状态对趋势和周期性有天然敏感度。但LSTM慢长序列上梯度信息经过多步传递后容易衰减而且它更偏重“顺序依赖”而非“全局关联”。把三者串起来之后各自的分工就很明确了TCN在前面做第一轮特征抽取把原始输入中的局部时间模式先过滤一遍同时降低序列长度带来的计算压力Transformer在中间接棒利用自注意力捕捉TCN漏掉的跨时间步长依赖建立全局上下文关系LSTM在最后按时间顺序消化Transformer输出的语义向量强化序列的时序表达再交给全连接层回归到目标值。这种结构在多个项目里实测下来效果普遍比任何一个单模型高出5%-15%的R²尤其是在特征维度多、时间窗口长的数据上提升非常明显。1.2 为什么选择串行而不是并行融合我的项目一开始也试过并行结构TCN、Transformer、LSTM三个模型各自分支提取特征然后在后面拼接。但实验下来发现两个问题一是三个并行分支的梯度回传路径不均衡训练时收敛速度差异很大容易出现某个分支学不动的情况二是参数量翻倍中间特征拼接之后维度爆炸小数据集上过拟合风险飙升。串行结构虽然形式更朴素但梯度路径单一、训练稳定而且推理速度也比并行结构快不少。1.3 多输出场景下的网络设计考量多输出回归在MATLAB里实现起来很直接——就是最后一层全连接层的神经元数量等于输出维度。但有个细节很多人第一版会踩坑多个输出之间的数值量纲如果差别很大比如一个指标范围是0到1另一个是1000到5000直接训练会让loss被大数值的那一路主导。我会在数据预处理阶段对每个输出通道分别做归一化让它们都落在同一个量级上训练稳定之后反归一化回来就是原始量纲了。2. MATLAB环境准备与数据预处理这个项目是在MATLAB里实现的版本我用的R2023a理论上R2021a以上都能跑主要依赖Deep Learning Toolbox和Statistics and Machine Learning Toolbox。如果要做SHAP分析还会用到一些自定义函数后面详细说。2.1 数据格式与文件组织我建议在项目目录下建一个干净的文件夹结构别把代码和数据全部堆在一起your_project/ |-- data/ |-- raw_data.csv |-- scripts/ |-- 1_prepare_data.m |-- 2_build_model.m |-- 3_train_model.m |-- 4_shap_analysis.m |-- 5_predict_new.m |-- results/ |-- model_weights.mat |-- shap_results.mat原始数据我用的是一张CSV表每行是一个时间点前面若干列是输入特征X后面若干列是要预测的目标Y。比如我项目里输入是12个维度的传感器特征输出是3个健康指标那么数据就是[样本数, 15]的矩阵。2.2 滑动窗口构建时序回归和普通的表格回归最大的区别就是单个时刻的X不能直接当输入因为模型需要看它前N个时刻的状态才能预测当前甚至未来时刻的Y。所以第一步是构造滑动窗口样本集。窗口长度我一般取50到100视你的数据时间跨度和采样频率而定。假设窗口长度为T那么每个训练样本就是过去T个时刻的输入特征序列形状是[T, numFeatures]标签是当前时刻或未来某个时刻的Y值。function [XTrain, YTrain] createSlidingWindows(data, inputCols, outputCols, windowSize, stepSize) numSamples size(data, 1); windowCount floor((numSamples - windowSize) / stepSize) 1; numFeatures length(inputCols); numOutputs length(outputCols); XTrain zeros(windowSize, numFeatures, 1, windowCount); % T x C x 1 x N YTrain zeros(numOutputs, windowCount); idx 1; for i 1:stepSize:(numSamples - windowSize 1) windowData data(i:iwindowSize-1, inputCols); targetData data(iwindowSize, outputCols); % 预测窗口结束后的下一时刻 XTrain(:, :, 1, idx) windowData; YTrain(:, idx) targetData; idx idx 1; end % 去掉多余的预分配空间 XTrain(:, :, 1, idx:end) []; YTrain(:, idx:end) []; end注意上面代码里的维度设计。TCN和Transformer在MATLAB里接受序列输入标准格式是[H, W, C, N]其中H是序列长度即时间步W这里设成1表示单步特征向量宽度C是通道数我直接用特征维度作为通道N是样本数。如果你对图像处理熟悉的话可以理解成把每个时间点的特征向量当作一个“图像的像素行”。2.3 归一化与多输出量纲统一归一化我选择了z-score标准化也就是减去均值除以标准差。相比MinMax归一化z-score对异常值的容忍度更高不会因为个别极端点把所有数据都挤到一个很窄的区间里。% 对输入特征和输出目标分别计算均值和标准差 [Xmu, Xsigma] deal(mean(Xall), std(Xall)); [Ymu, Ysigma] deal(mean(Yall), std(Yall)); % 直接在原始矩阵上标准化 Xall_norm (Xall - Xmu) ./ Xsigma; Yall_norm (Yall - Ymu) ./ Ysigma; % 拆滑动窗口时用归一化后的矩阵 [XTrain, YTrain] createSlidingWindows([Xall_norm, Yall_norm], 1:numFeatures, numFeatures1:numFeaturesnumOutputs, windowSize, 1);多输出这里特别重要Ymu和Ysigma要按列分别存储这样预测完了才能逐列反归一化找回原始量纲。我见过太多人把整个矩阵压成一个标量来归一化结果输出维度之间比例关系全乱了。3. 核心代码实现TCN-Transformer-LSTM组合网络搭建这一节是整个项目的重头戏。我会把网络搭建的代码一步步拆开讲包括每一层的维度变化、为什么这么连、以及实际跑起来会遇到的坑。3.1 TCN模块膨胀因果卷积层实现MATLAB的Deep Learning Toolbox没有直接封装TCN模块所以需要用自定义层或者借助sequenceFolding和convolution2dLayer来模拟。我采用的是自定义层方案通过膨胀卷积和因果padding来保证输出序列长度与输入一致。classdef tcnBlock nnet.layer.Layer properties NumFilters FilterSize DilationFactors end properties (Learnable) ConvWeights ConvBias end methods function layer tcnBlock(numFilters, filterSize, dilations, NameValueArgs) arguments numFilters filterSize dilations NameValueArgs.Name tcn end layer.Name NameValueArgs.Name; layer.NumFilters numFilters; layer.FilterSize filterSize; layer.DilationFactors dilations; % 初始化卷积权重 f filterSize * numFilters * numFilters; layer.ConvWeights dlarray(randn(filterSize, numFilters, numFilters) * sqrt(2 / f)); layer.ConvBias dlarray(zeros(numFilters, 1)); end function Z predict(layer, X) % X shape: [sequenceLength, 1, numFeatures, batchSize] seqLen size(X, 1); numChannels size(X, 3); Y X; for dIdx 1:length(layer.DilationFactors) dilation layer.DilationFactors(dIdx); causalPad (layer.FilterSize - 1) * dilation; % 在序列开始端补零保证因果性 padded cat(1, zeros(causalPad, 1, numChannels, size(X, 4)), Y); % 使用convolution2dLayer的等效卷积操作 % 这里简化说明实际用dlconv Z dlconv(padded, layer.ConvWeights, layer.ConvBias, ... Stride, 1, DilationFactor, [1, dilation]); % 注意MATLAB实际维度和参数 % 截断padding部分回到原始长度 Z Z(1:seqLen, :, :, :); % 残差连接 if numChannels layer.NumFilters Y Z X; else Y Z; end % 激活 Y relu(Y); end Z Y; end end end这里要说明一下dlconv在MATLAB里对序列数据的维度约定比较特立独行实际使用中建议先用小的测试张量跑一次forward验证维度。我在第一次实现时就因为对第1维和第2维的理解偏差花了整整一个下午调试维度错误。TCN的膨胀因子我通常从小到大设成[1, 2, 4, 8]这样叠加起来感受野等于(FilterSize-1) * (1248)再加1对窗口长度50-100的序列来说足够覆盖了。3.2 Transformer编码器模块MATLAB R2022a之后引入了transformerLayer可以直接用来构建Transformer编码器。如果是早期版本就需要自己写multi-head attention层。这里以可用transformerLayer为基础来写。numHeads 4; numKeyChannels 32; numModelChannels 64; transformerEncoder [ sequenceInputLayer(numFeatures, Name, input_seq) % 位置编码我用的是sinusoidal的固定位置编码函数 % 通过自定义层添加 positionEmbeddingLayer(positionEmbedding) transformerLayer(numHeads, numKeyChannels, numModelChannels, ... Name, transformer_encoder) layerNormalizationLayer(Name, ln_after_transformer) ];这里有一个容易被忽略的点transformerLayer默认期望的输入是[H, 1, C, N]格式如果直接从TCN接过来需要保证C维度等于numModelChannels。所以在TCN和Transformer之间我会加一个convolution2dLayer(1,1)来调整通道数。3.3 LSTM时序增强层Transformer输出之后数据格式还是序列这对LSTM来说是理想输入。LSTM能按时间顺序把注意力加权后的特征进一步压缩成时序状态向量。lstmLayers [ lstmLayer(64, Name, lstm_1, OutputMode, last) dropoutLayer(0.3, Name, dropout_1) fullyConnectedLayer(numOutputs, Name, fc_output) regressionLayer(Name, reg_output) ];注意OutputMode一定要设置为last否则fullyConnected层会收到一个序列而非向量训练直接报维度错误。如果不想丢失序列中间状态可以用sequence然后再接一个globalAveragePooling1dLayer或其他池化层但实测对比下来多输出回归任务用last就够了。3.4 组合完整网络与训练配置将所有模块拼在一起后完整网络结构就是输入 → TCN → Transformer → LSTM → 全连接 → 回归输出。% 组合所有层 layers [ sequenceInputLayer(numFeatures, Name, input) % TCN模块这里用自定义层包一层 tcnBlock(64, 3, [1 2 4 8], Name, tcn_1) % 通道对齐层 convolution2dLayer(1, 64, Name, channel_align) % Transformer编码器 transformerLayer(4, 32, 64, Name, transformer) % LSTM lstmLayer(64, Name, lstm, OutputMode, last) dropoutLayer(0.3, Name, dropout) % 多输出回归 fullyConnectedLayer(numOutputs, Name, fc) regressionLayer(Name, output) ];训练选项我用的是这个组合solver设为adam初始学习率0.001mini-batch size 32或64视显存调整max epochs 100-200搭配learnRateSchedulepiecewise每30轮衰减0.5validation patience控制早停options trainingOptions(adam, ... InitialLearnRate, 0.001, ... LearnRateSchedule, piecewise, ... LearnRateDropFactor, 0.5, ... LearnRateDropPeriod, 30, ... MiniBatchSize, 64, ... MaxEpochs, 150, ... ValidationData, {XVal, YVal}, ... ValidationFrequency, 20, ... ValidationPatience, 10, ... Plots, training-progress, ... Verbose, true, ... Shuffle, every-epoch); net trainNetwork(XTrain, YTrain, layers, options);一个重要的细节trainNetwork要求XTrain是cell数组或4-D数组。如果用滑动窗口生成的是[H, 1, C, N]格式可以直接用。如果数据量特别大建议转成单个dlarray用自定义训练循环会更灵活。4. SHAP分析打开黑盒看清每个特征的影响方向与程度模型训练完之后很多人就停在这里了——预测精度不错但为什么好、哪些特征在起作用完全说不出来。写论文或者做业务落地时这个解释性缺口很致命。SHAPSHapley Additive exPlanations就是用来补这个缺口的。4.1 SHAP原理简述和MATLAB实现路径SHAP的核心思想来自博弈论里的Shapley值它把每个特征当作一个“玩家”把模型的预测输出当作“总收益”然后计算每个玩家对总收益的边际贡献。具体到模型里就是遍历所有特征子集计算加入某个特征前后预测值的变化量求加权平均。直接穷举所有特征子集在特征维度高的时候是不可行的所以MATLAB和Python的实现都用蒙特卡洛采样近似。在MATLAB里最直接的方式是利用Statistics and Machine Learning Toolbox中的shapley函数它支持自定义预测函数。% 假设已经训练好了net我们定义预测函数 % 注意SHAP分析需要大量调用predict对速度有要求因此可以简化网络推理 predictFcn (X) predict(net, X, Outputs, fc); % 使用shapley函数计算 explainer shapley(predictFcn, XTrain); explainer fit(explainer);但需要注意shapley函数的设计初衷是表格数据对深度学习序列输入的支持往往不够友好。更稳的做法是自己实现一个简化版的SHAP采样下面是我的做法。4.2 自定义SHAP采样实现大体思路选一批背景样本集对每个要解释的样本随机替换部分特征为背景样本中的值观察输出变化多次采样取平均。对时序模型来说特征维度的粒度可以是一个时间点的全部特征也可以是单个特征通道。如果按整个时间点做mask分析的是“某个时刻的状态有多大影响”按通道做mask分析的是“某个物理量整体有多大影响”。两者都有价值。function [shapValues, baseline] computeSHAPApprox(net, XRef, XExplain, numSamples, featureMask) % XRef: 背景参考数据 % XExplain: 要解释的样本 % featureMask: 逻辑数组标记要单独解释的特征通道 numExplain size(XExplain, 4); shapValues zeros(size(XExplain, 3), numExplain); % 基线值所有输入特征替换为背景数据平均后的预测均值 baselinePred predict(net, XRef); baseline mean(baselinePred, 4); for i 1:numExplain x0 XExplain(:, :, :, i); % 对每个特征维度做蒙特卡洛采样 for f 1:size(XExplain, 3) if ~featureMask(f) continue; end sumDelta 0; for s 1:numSamples % 随机选一个背景样本 idx randi(size(XRef, 4)); xBg XRef(:, :, :, idx); % 构造两个版本的样本 % 不带该特征将该通道置为背景值 xWithout x0; xWithout(:, 1, f, 1) xBg(:, 1, f, 1); % 带该特征使用原值 predWith predict(net, x0); predWithout predict(net, xWithout); % 边际贡献 delta predWith - predWithout; sumDelta sumDelta delta; end shapValues(f, i) sumDelta / numSamples; end end end严格来说这种“单特征替换”是Simplified SHAP的一种变体和完整的Shapley值采样有所差别但实践中的排序结果已经很接近而且计算量可控。如果你要发论文建议直接用官方shapley或引入更严格的采样公式如果是业务分析这个方法够用。4.3 SHAP结果可视化与解读算出shapValues之后我通常做三张图第一张是全局特征重要性排序。把每个时间步、每个通道的|SHAP|值做平均画柱状图。globalImportance mean(abs(shapValues), 2); figure; barh(globalImportance); set(gca, YTick, 1:numChannels); set(gca, YTickLabel, featureNames); xlabel(平均 |SHAP| 值); title(全局特征重要性排序);第二张是SHAP依赖图。对某个重点特征横轴是该特征的原始值纵轴是SHAP值能看到该特征在不同取值区间对输出的影响方向和幅度。如果有明显的趋势说明这个特征和预测目标存在单调或非线性关系。第三张是个别样本的力导图。对某一条新数据把各特征的SHAP值按正负排列成一条彩带正贡献往左负贡献往右直观看到这个预测结果是怎么形成的。根据经验SHAP分析通常会发现一些和你直觉不一致的结论。比如我的项目里时序窗口末尾几个时间点的特征重要性显著高于开头说明模型主要依赖近期状态但个别物理量在中段窗口的重要性呈U型说明存在一个“关键时间区间”这个信息对业务理解很有价值。5. 新数据预测与多输出结果后处理模型训练好、解释做完之后真正要落地的还是“来一批新数据立刻算出结果”。这一节把新数据预测和反归一化讲清楚。5.1 新数据预处理链路封装新数据的格式大概率跟训练数据不一样可能是只有输入特征也可能时间长度不同。关键是严格复用训练时的预处理流水线不能重新计算均值方差。function YPred predictNewData(net, XNewRaw, windowSize, Xmu, Xsigma, Ymu, Ysigma) % XNewRaw: 新的原始输入数据序列 % 先标准化 XNewNorm (XNewRaw - Xmu) ./ Xsigma; % 构造滑动窗口 numNewSamples floor((size(XNewNorm, 1) - windowSize)) 1; XNewWindows zeros(windowSize, 1, size(XNewNorm, 2), numNewSamples); for t 1:numNewSamples XNewWindows(:, 1, :, t) XNewNorm(t:twindowSize-1, :); end % 预测 YNorm predict(net, XNewWindows); % 反归一化 YPred YNorm .* Ysigma Ymu; end5.2 多输出预测结果的存储和可视化多输出预测最怕的是结果出来后不知道哪一个对应哪一路。我的建议是一开始就把输出通道名存下来预测完成后用一个结构体或者表格把结果打包。outputNames {转速预测, 温度预测, 振动幅值预测}; YPredArray predictNewData(...); % 可视化对比 figure; tiledlayout(1, numOutputs); for outIdx 1:numOutputs nexttile; plot(YPredArray(outIdx, :), LineWidth, 1.5); hold on; plot(YTestOriginal(outIdx, :), LineWidth, 0.75); legend(预测值, 真实值); title(outputNames{outIdx}); end如果需要保存推荐用save把结果和关键参数放到同一个mat文件方便后面复现。5.3 预测结果的评价指标计算多输出回归不能只看单一指标。我习惯每个输出通道分别计算RMSE、MAE、R²然后汇总成一张表。function metrics evaluateRegression(YTrue, YPred, outputNames) numOut size(YTrue, 1); metrics table(); for i 1:numOut yTrue YTrue(i, :); yPred YPred(i, :); rmse sqrt(mean((yTrue - yPred).^2)); mae mean(abs(yTrue - yPred)); r2 1 - sum((yTrue - yPred).^2) / sum((yTrue - mean(yTrue)).^2); metrics [metrics; table(outputNames{i}, rmse, mae, r2)]; end metrics.Properties.VariableNames {输出通道, RMSE, MAE, R²}; disp(metrics); end这里注意R²在样本量小、预测方差接近0的时候可能变成负数这不代表模型完全没用而是测试集本身方差很小任何模型都很难做得比均值基线好。碰上这种情况建议补充RMSE和MAPE再一起看。6. 常见问题与调优排查实录这一节全部来自我实际跑代码时踩过的坑列成清单给各位做个参考。6.1 维度不匹配问题这个是最常见也是最让人崩溃的。MATLAB深度学习层的维度约定和Python差异很大尤其是序列数据。我遇到的典型报错是输入到transformerLayer时维度不符合预期报错原因是序列数据进入transformer前需要把通道维放在第2维LSTM层后接fullyConnectedLayer时如果OutputMode忘记设lastfc会收到序列张量直接报维度错误dlconv的DilationFactor参数在MATLAB里是按[D1, D2]传的只传一个数字会报错。解决办法是每加一个层先用一个假的小张量过一遍网络检查尺寸变化。哪怕多花几分钟也比训练到一半才报错强得多。6.2 模型过拟合组合模型的参数量不小小数据集上很容易过拟合。我的标志性特征是训练集loss一路降到很低但验证集loss在某个epoch后开始反弹。应对策略按优先级排序增加dropout比例从0.2加到0.4或0.5减少Transformer的numHeads和通道数LSTM的hidden size减半增加数据增强手段比如给输入加轻微噪声提前停机的validationPatience调小到5轮L2正则系数设一个小的数值比如0.0001。6.3 SHAP分析计算太慢SHAP采样要反复调用predict在深度学习模型上会非常慢。我实测一个窗口100、通道12、样本几百条的数据跑100次采样要几十分钟。加速思路用subset背景样本背景不需要用全部数据取100-200条代表性样本即可减少采样次数到50左右先看趋势用MATLAB的并行池parpool之后把for循环改成parfor预测时关掉训练选项和梯度计算让网络处于只推理状态。% 加速SHAP的并行采样 parpool(local, 4); parfor s 1:numSamples ... end delete(gcp(nocreate));6.4 多输出预测值失真如果某个输出通道的预测值一直在一个很小的区间内抖动先检查反归一化的Ymu和Ysigma是否用对了列。这个错误很容易在代码复制粘贴时出现——把第一列的均值和第二列的均值用反了预测结果的量纲和分布就会全乱。另一个常见原因是输出通道之间物理量纲差异太大归一化后loss被大尺度通道主导小尺度通道完全没有被有效学习。解决方案是按输出通道分别计算loss权重或者先用MinMax归一化把每个通道压到相同区间。7. 我的实操总结与几点延伸建议组合模型并不是“把三个模型拼在一起就一定变强”它的收益来自每个模型都贡献了自己擅长的那部分表达。在我跑过的几个数据集上TCN-Transformer-LSTM组合的效果优于任何单一模型但前提是数据量足够支撑这个参数量。如果你的数据只有几百条建议先把Transformer部分去掉或者把hidden size缩到很小不然会训出个复读机。代码层面我强烈建议把数据预处理、模型定义、SHAP分析、新数据预测拆成独立脚本每个脚本都能独立运行并输出中间结果。这样调试时不用把整个流程全跑一遍节省的时间非常可观。后续如果你还想继续扩展有几个方向值得尝试一是把TCN替换成TCN的膨胀因子自适应版本二是在Transformer之后接入一个注意力池化层而不是直接用LSTM的last state三是把SHAP分析从特征维度扩展到时间步维度分析模型更依赖序列的哪一段历史。这些方向我都在验证中等跑出稳定的结果再继续分享。本文还有配套的精品资源点击获取
返回列表