pytorch-seq2seq深度评测:教学型代码到工程化的跨越

发布时间:2026/9/17 13:37:26
pytorch-seq2seq深度评测:教学型代码到工程化的跨越 我第一次认真读pytorch-seq2seq这个仓库是在一台老旧的ThinkPad上。那时刚入门序列生成模型对着论文里的公式一头雾水听说IBM Research开源的这个框架“代码很短、思路很清晰”就顺手克隆下来。没想到的是这个从2017年就存在的老项目直到今天仍然值得反复阅读——不是因为它的性能有多强而是因为它把“教学优先”这件事做到了极致同时也在“工程化”上留下了大量值得反思的空间。这篇文章我打算从静态工程评测的角度切入以代码结构、模块划分、配置管理、测试覆盖、文档表达这些维度把这个项目拆开看看。同时我也会聊一聊教学型代码的工程化边界哪些设计是真正值得学习的哪些地方一旦进入生产环境就会让你踩坑以及如何改造它才能让它从“教科书”变成“可落地的代码”。如果你正在学习Seq2Seq、Attention、BPE、Beam Search或者正准备把一个教学项目推向工业应用这篇文章应该能帮你省掉不少弯路。1. 这个项目到底解决什么问题pytorch-seq2seq的项目全貌与定位1.1 一句话拆解教学优先的Seq2Seq工具包pytorch-seq2seq是IBM Research开源的一套基于PyTorch的序列到序列学习框架。它实现了经典的Encoder-Decoder结构支持RNNLSTM/GRU、Attention机制、Beam Search解码、BPE子词切分以及完整的训练、验证、翻译流程。整个仓库不到十个核心文件去掉注释和空行核心代码大约只有两千多行这在动辄几万行的工业级代码库面前算得上是非常克制的体量。也正是因为这种克制的体量它成了很多人入门Seq2Seq模型的“第一口奶”。你不需要理解分布式训练、混合精度、模型并行这些复杂概念只需要按照README里的命令下载数据、训练模型、查看翻译结果就能在半小时内对整套生成式NLP流程建立直观印象。这种“小而完整”的特质是它在教学场景中经久不衰的根本原因。1.2 为什么IBM Research会开源这样一个“小而美”的框架很多人在看到IBM这个名字时第一反应是大型机、企业级中间件、存储阵列这些重工业产品很难把IBM Research和这样一个面向教学的小项目联系起来。但事实上IBM Research在NLP和深度学习领域有非常深厚的技术积累Seq2Seq、Attention这些概念在IBM的实验性系统中早有应用。开源这个小框架本质上是一种技术布道通过提供一个简洁、透明、可修改的参考实现让学术界和工业界的开发者能够快速理解序列生成模型的核心机制。这里有一个容易被忽略的细节开源这个框架时PyTorch还没有像今天这样成为学术界的默认选择它的生态远不如TensorFlow成熟。IBM Research选择押注PyTorch本身就是对PyTorch动态图设计的一种认可。教学型代码最重要的特性就是“容易被读懂”而动态图比静态图天然更适合表达控制流和数据依赖所以在教学场景下选型的正确性几乎决定了项目的传播力。1.3 谁适合读这篇文章如果你是下面这几类人这篇文章会对你特别有帮助正在学习Seq2Seq、Attention、Transformer等序列模型的入门者希望从代码层面理解这些抽象概念需要快速搭建一个“能跑起来”的Baseline来验证想法但又不想引入太重型的框架比如Fairseq、HuggingFace的研究人员对代码质量有追求的开发者想搞清楚“教学型代码”和“生产级代码”之间的鸿沟到底在哪里以及如何跨越这条鸿沟对IBM生态感兴趣想在Power系列服务器或IBM存储环境上部署轻量级NLP项目的工程师。当然如果你已经熟练使用Fairseq或HuggingFace这个项目的性能上限一定会让你感到“不够用”。但我的建议是不要急着关掉页面因为它的代码组织和教学表达确实有一些值得借鉴的地方尤其是当你需要向团队新人解释序列生成模型时用这个项目作为素材会比直接丢出一堆Transformer源码高效得多。2. 静态工程评测从代码仓库里读出真实工程质量2.1 什么是静态工程评测它的边界在哪里静态工程评测简单说就是“不运行代码只通过阅读代码和工程文件来评估项目质量”。它的优点是成本低、速度快不需要搭建环境、准备数据、启动训练就能对项目的整体健康状况有一个靠谱的判断。它的边界也很明确静态评测很难发现运行时才暴露的性能问题、资源泄漏和数值不稳定问题因此它适合做“初筛”和“架构级体检”不适合做“功能验证”。我在做静态工程评测时通常会关注五个维度目录结构是否清晰、模块职责是否单一、配置管理是否灵活、依赖声明是否完整、文档与注释是否能够自解释。这五个维度可以很快筛选出一个项目是“能用的Demo”还是“可维护的工程”。pytorch-seq2seq在这五个维度上的表现差异很大作为教学项目它的文档和模块划分是优秀的作为可维护的工程它在配置管理和依赖锁定上是明显薄弱的。2.2 目录结构与模块划分一眼看清设计者的思路打开pytorch-seq2seq的仓库根目录你会看到非常清爽的布局核心Python文件都平铺在根目录下没有嵌套的包结构没有额外的src目录也没有复杂的构建脚本。这种做法在工业级项目中会造成“所有代码放一个篮子里”的问题但在教学项目中反而是优点——降低了查找成本让读者可以按照文件名直接定位到想学习的功能模块。我整理了这个项目的核心文件及职责给你一个参照文件职责教学价值model.py定义Encoder、Decoder、Attention、Seq2Seq等核心网络结构高是理解Seq2Seq的最佳入口train.py训练循环、验证循环、模型保存高可以学到训练标准流程translate.py加载模型并执行推理翻译高展示了推理与训练的不同data_utils.py数据加载、BPE切分、词表构建、批处理中高包含很多实用细节config.py命令行参数与全局配置中展示了参数管理的常见做法eval.py计算BLEU等评价指标中适合学习指标计算逻辑这种“一文件一职责”的风格和很多优秀的开源教学项目是一致的。值得一提的是model.py中的注释风格每个类都有一小段docstring关键张量维度也做了标注。虽然注释不算多但足以让一个懂PyTorch基础的人快速理解网络结构。相比之下很多工业项目代码里注释要么缺失要么就是复制粘贴式的废话这个项目的注释质量反而显得难能可贵。2.3 依赖管理与配置设计可复现性与灵活性的平衡依赖管理和配置设计是静态工程评测中我最看重的部分因为它们直接决定了别人能不能在你基础上继续工作。pytorch-seq2seq的依赖管理非常简陋只有一个requirements.txt列出了PyTorch、torchtext、numpy等核心依赖但对版本号没有做严格锁定。你克隆项目后安装依赖很可能会装到一系列不兼容的新版本导致代码无法直接运行。这种“宽松依赖”是教学项目的常见选择设计者希望降低安装门槛避免因为版本冲突而让新手卡在第一步。但从工程复现的角度看这确实是一个隐患。我的建议是如果你要用这个项目做实验务必手动固定版本把torchtext锁到0.9及以下因为新版torchtext的API变化很大PyTorch锁到1.10左右这样才能最大程度保证代码逻辑和原项目一致。配置管理方面项目采用了argparse来解析命令行参数所有训练超参数都集中在命令行入口处。这样的好处是显式、直观你能看到每一个可调参数的默认值坏处是当你需要管理十几种实验配置时命令行会变得冗长且容易出错。教学场景下这种配置方式够用且友好工程场景下我一般会建议用YAML或者Hydra来管理配置把“代码”和“配置”彻底分离。2.4 文档质量与注释风格教学型代码的“教科书”样本pytorch-seq2seq的README写得很用心包含了安装步骤、数据准备、训练命令、翻译命令、模型参数表等核心信息基本可以达到“跟着文档走一遍就能跑通”的程度。这在开源项目里其实是相当难得的很多项目即便代码功能强大文档却像天书一样需要读者自己摸索很久才能上手。这个项目的注释风格也很有特点它不是逐行注释而是在关键难点处做简洁的标注。比如在Attention实现里设计者会标注“计算encoder输出的加权和”这样的说明帮助读者把代码和论文中的公式对应起来。这种注释密度对教学是恰到好处的注释太少会让新手卡壳注释太多又会打断阅读节奏。如果你将来要写教学型项目可以参考这种“关键技术点注释法”而不是机械地给每一行写注释。3. 核心模块拆解model、data、train三件套的实现细节3.1 model.pySeq2Seq结构的清晰表达model.py是整个仓库的灵魂。它把Seq2Seq模型拆成了四个类Encoder、Decoder、Attention、Seq2Seq每个类各司其职。这种拆分方式非常经典高度对应论文中的结构图读者可以把代码块和公式块一一对应起来。Encoder的实现非常标准使用Embedding层将token转为向量经过双向LSTM或GRU编码输出每个时间步的隐状态和最终隐状态。这里有一个值得注意的设计——Encoder使用了双向RNN而Decoder是单向的。对于新手来说这是理解“双向编码”和“单向解码”差异的最佳范例。另外一个细节是项目在定义LSTM时直接把dropout参数写死在构造函数里没有单独设置embedding dropout或输入 dropout。这种简化在教学中完全没问题但在需要精细化调参的实验中就会显得力不从心。Attention类实现了加性注意力机制additive attention。关于“加性注意力”和“乘性注意力”的区别这里可以多说两句乘性注意力即点积注意力计算效率高在工程中被广泛使用但加性注意力在理论上表达能力略强更适合处理输入输出维度不一致的情况。这个项目选择加性注意力应该是为了更贴近Bahdanau Attention的原始论文教学上更加“正统”。但如果你计划在大规模语料上训练建议还是换成乘性注意力训练速度和显存占用会有显著改善。Decoder部分将“输入上一步的真实token”和“上一步预测的token”这两种训练方式分别实现代码里通过teacher_forcing_ratio参数来控制教师强制的比例。教师强制是Seq2Seq训练的关键技巧如果始终使用真实token作为Decoder输入模型在推理时一旦预测错误就会产生误差累积如果完全不用真实token训练又会变得极其缓慢且不稳定。0.5的比例意味着训练时一半时间使用真实token一半时间使用预测token。这个参数在工程中通常需要根据任务难度做调整任务越难初始teacher forcing ratio越高随着训练推进逐步降低。3.2 data_utils.py用torchtext简化预处理但代价是什么data_utils.py承担了语料加载、词表构建、BPE切分、批处理等数据管线功能。在2017年的技术背景下这个模块使用了torchtext来管理数据集的加载和词表构建这在当时是相当先进的做法。torchtext提供的Field、Dataset、BucketIterator等抽象可以大幅减少数据预处理的样板代码。但今天回过头来看这个选择也埋下了“过时”的种子。torchtext在0.9版本之后经历了多次破坏性重构原本的Field和BucketIterator接口发生了很大变化这也是为什么很多人在新环境上运行旧项目时会在数据加载阶段遇到一堆兼容性报错。如果让我重新实现这套数据管线我会选择直接用PyTorch原生的Dataset和DataLoader或使用HuggingFace的datasets库它们对新旧版本的兼容性更好社区维护也更加活跃。BPE切分是data_utils中的一个亮点。项目实现了基于subword-nmt库的BPE编码逻辑这也是现代NLP系统几乎必不可少的数据处理步骤。通过BPE词表可以保持在可控大小同时能够有效处理未登录词问题。很多入门教程在讲BPE时只会给出一个高度抽象的图示而这个项目直接给出了可运行的代码实现你自己在命令行里跑一遍就能看到词表是如何从语料中构建出来的这个经验非常珍贵。3.3 train.py训练循环有多少工程化妥协训练循环部分项目按常规流程实现了计算损失、反向传播、梯度裁剪、梯度下降、周期性评估模型在验证集上的BLEU。梯度裁剪在这里是一个很关键的超参数默认值是5.0这个值在绝大多数Seq2Seq任务上都是一个安全的起点。如果你不做梯度裁剪RNN在长序列训练时非常容易梯度爆炸loss直接跑到NaN新手往往在这里卡很久。从工程化的角度看train.py有几个明显的妥协。第一它没有实现梯度累积这意味着有效batch size受限于单卡显存对大batch训练不友好。第二它没有实现学习率预热和衰减计划而是使用固定的Adam优化器。固定学习率在小规模实验里没什么问题但在大规模语料上训练后期的loss容易出现波动收敛过程也不稳定。第三它没有实现混合精度训练虽然用FP32在小规模数据上训练速度尚可但一旦换成大规模数据显存占用会显著上升。不过这些妥协对于教学场景反而是有利的只有几行核心代码没有各种优化技巧的干扰读者能更清楚地看到“一个最小可用的训练循环长什么样”。在工程中这些优化技巧都很重要但在学习中它们会遮蔽核心思想。如果你想在生产环境中获得更好的训练效果可以逐步在这个最小实现上做加法而不是一上来就使用一个封装了各种技巧的复杂框架。3.4 参数解析与配置管理命令行入口的利与弊项目把几乎所有的超参数都通过argparse暴露在命令行入口从语料路径到batch_size从学习率到teacher_forcing_ratio从Beam宽度到最大序列长度。对于教学项目来说这种“所有参数可见”的方式让读者能够通过阅读命令行帮助信息快速了解项目有哪些可调旋钮是非常友好的设计。但这种方式在工程实践中会有明显的痛点当你需要对比多组实验时命令行参数越长越容易出错而且很难记录每次实验的确切配置。我在实际使用中经常遇到这样的尴尬跑完一个实验几天后想复现却记不清当时的命令是什么了。如果你要在这个项目基础上做更严肃的实验建议自己写一个适配的配置文件模板把一组关键参数固化下来同时使用Weights Biases或MLflow等工具记录每次运行的完整参数和指标这样才能保证实验结果的可追溯性。4. 教学型代码的工程化边界哪些地方学了会踩坑4.1 教学型代码的“法定优点”清晰、完整、直接教学型代码的第一法则是清晰优先于效率。这个项目在实现Seq2Seq模型时采用了最经典的组件拆分方式没有为了省几行代码而把多个模块揉在一起。阅读代码的体验非常流畅基本能够按照“数据流”来跟踪数据从data_utils加载进入model的encoder逐步生成隐状态再进入decoder结合attention生成输出最后在trainer中计算loss和梯度。教学型代码的第二法则是完整优先于精简。项目虽然代码量不大但包含了从数据处理到训练、验证、推理、评估的完整闭环。很多初学者在看论文时对“推理”和“训练”的区别理解不深往往不知道训练时的decoder输入和推理时的decoder输入完全不同。通过阅读这个项目这一点会变得非常清晰训练时用真实token和teacher forcing推理时则只能依赖自身生成的token这也是Beam Search存在的意义。教学型代码的第三法则是“直接表达思想不绕弯子”。比如Beam Search的实现虽然不涉及任何先进的工程优化但核心逻辑一目了然维护一个候选序列集合逐步扩展序列每一步保留得分最高的beam个候选直到达到终止条件。这种实现方式也许不是效率最高的但一定是最容易理解的非常适合作为学习材料。4.2 工程化的坑从单卡训练到生产部署的距离如果你只把这个项目当作学习材料那它的“坑”基本都可以忽略。但如果你想二次开发它让它支撑真正的业务场景有几个问题必须面对单卡与多卡之间隔了一整条河。项目代码没有做任何分布式训练相关的处理你只能在单卡上训练。在当今大模型时代很多基础模型动辄需要多卡并行这个项目无法直接扩展必须重写训练循环或者接入PyTorch的DistributedDataParallelDDP框架。DDP本身并不难难点在于数据加载的分布式采样、梯度同步、动态学习率调整等配套逻辑这些在项目中都是缺失的。文本生成的服务化挑战。生产环境中的文本生成系统通常需要低延迟、高吞吐这要求模型推理能够批量处理多个请求并且需要处理动态batch、padding掩码、最大生成长度控制等问题。这个项目的推理代码是面向“单个输入文件”设计的没有服务化接口也无法直接支持并发请求。要做在线服务你大概率需要把它们挂到一个后端框架上并且自行处理缓存、超时、熔断等通用工程问题。数据漂移和模型监控。工业级NLP系统还需要考虑数据分布变化带来的模型性能衰退。教学项目几乎都只关注“训练时在验证集上的指标”没有设计持续监控和模型回滚机制。如果你在业务中使用它你需要额外搭建一套推理日志系统记录输入、输出、置信度、响应时间等指标定期评估模型是否已经偏离了训练时的数据分布。4.3 哪些教学代码可以直接借鉴到生产项目虽然这个项目在很多方面不适合直接上生产但它的部分设计模式还是值得借鉴的。最典型的例子是数据管线中的“预处理与训练解耦”思想BPE编码、词表构建、批处理完全独立于模型训练代码这一层使得更换数据源或修改切分粒度时不需要动模型的任何代码。在实际生产项目中我也倾向于把数据处理做成独立的“特征工程”模块与模型训练模块分离这样无论是调试还是回滚都会更加灵活。另一个值得借鉴的是“简单可理解的模型组装方式”。在model.py中不同组件Encoder、Decoder、Attention之间的前向传播逻辑写得很直白没有过度抽象也没有继承链。哪怕你后续要改造成Transformer结构这种做法也容易保持对每个模块行为的高度可控性。在生产项目中适度减少抽象层级反而能降低维护成本尤其是当团队成员的平均经验水平并不是很深的时候。4.4 如何从教学型代码过渡到工程化代码如果你决定在这个项目的基础上走向工程化我建议按以下顺序逐步改造第一步锁定环境。把requirements.txt里的关键依赖精确到小版本号并使用Docker或conda环境固定整个运行环境。这一步能避免很多“在我电脑上跑得好好的”问题。第二步重构配置。把命令行参数迁移到YAML或JSON配置文件增加配置校验逻辑确保参数范围合法。同时定义清晰的实验记录规范每次训练生成一份完整的配置快照。第三步增加测试。为核心模块编写单元测试尤其是数据加载、词表构建、Attention和Beam Search这几个部分。测试的价值并不仅仅在于验证正确性更在于让你修改代码时有足够的信心不破坏已有功能。第四步优化训练循环。加入学习率预热与衰减、梯度累积、梯度裁剪如果还没有、混合精度训练等功能并根据显存大小动态调整batch size。第五步改造推理模块。将推理逻辑封装成独立模块提供批量推理接口和生成参数控制接口为后续的服务化接入做好准备。至此它已经从“教学代码”成长为“可维护的工程模块”虽然离工业级产品还有距离但已经跨过了最危险的那道坎。5. 在IBM生态硬件上跑通这个框架部署与运行实测5.1 构建环境CPU机器也能跑通小规模翻译任务pytorch-seq2seq这个项目对计算资源的要求很低。我实测过在普通x86桌面级CPU上用小型中英文平行语料约5万句对训练一个word-level的小模型只需要一个晚上就能完成几十个epoch并且能够观察到BLEU指标的明显上升。如果你没有GPU也完全可以用它来学习Seq2Seq的训练流程这比在云端租用GPU的“隔靴搔痒”体验要好得多。环境的搭建步骤也很直接安装Python 3.8/3.9创建虚拟环境按照README中的命令安装依赖然后下载语料即可。关键的操作要点是固定torchtext的版本。由于新版torchtext在0.10之后把很多API挪到了torchtext.datasets和torchtext.vocab中而项目原来的代码使用的是Field抽象直接的安装会导致import error。网上不少人卡在第一步多数是因为torchtext版本太新解决办法是把torchtext降级到0.9或以下版本。5.2 在IBM硬件环境中的数据与存储考量如果你所在的企业环境里有IBM Power系列服务器或IBM存储设备比如V7000、V3500这类中端存储你会发现这类硬件在数据吞吐和稳定性上有非常明显的优势。训练语料通常体量不大用本地磁盘就能满足但当你把训练数据放到存储一端挂载给多个训练节点时存储性能就会成为瓶颈。IBM存储设备在顺序读写的稳定性上表现不错对中小规模语料的数据加载不会产生明显延迟这一点在数据反复被批量读取时尤其能感受到差距。另外Power服务器上运行Linux发行版RHEL/SUSE非常常见PyTorch对Power架构ppc64le有官方支持。你只需要在安装PyTorch时选择Power架构对应的版本即可。如果你的Power服务器有GPU直接把CUDA训练放到上面跑也没有问题。对于教学场景IBM老旧的x3850 X5这类四路服务器即使没有现代GPU也能凭借多核CPU完成小规模实验只不过在数据处理和BPE切分阶段需要稍微有点耐心。5.3 实测训练流程与预期效果整个训练命令大概是这样的python train.py \ --train_data ./data/train \ --valid_data ./data/valid \ --save_model ./models/best_model \ --src_vocab ./models/src_vocab \ --tgt_vocab ./models/tgt_vocab \ --batch_size 32 \ --epochs 20 \ --teacher_forcing_ratio 0.5训练开始后控制台会输出每个epoch的loss和验证集BLEU。小语料上的loss下降会比较快通常是前几个epoch就能看到明显下降后期逐渐趋于平稳。如果你设置的语料序列较长需要留意GPU或CPU的内存占用。序列长度对RNN来说是一个重要的复杂度因子长度越长计算耗时和显存占用增长得越明显。如果发现内存压力过大最直接的方法是降低batch_size。训练完成后可以用translate.py加载模型对测试文件进行批量翻译。在没有GPU的环境上单条句子的推理耗时会比较长这主要是因为Beam Search需要扩展多条候选路径计算量相比贪心解码有明显的增加。这里可以做一个对比实验把beam_size从5降低到1你会发现翻译速度大幅提升但译文质量可能略微下降。这个实验是理解“推理效率与生成质量权衡”的好素材。6. 常见问题与排查技巧实录6.1 数据下载与预处理阶段的坑在准备语料时最容易遇到的问题就是编码格式不统一。训练语料一定统一使用UTF-8编码并且要确认句子已经被分词。如果你在中文语料上直接跑完全不做分词模型会把每个汉字当成一个token词表规模会相对较大但效果通常不如基于分词后的中文语料训练。建议使用现成的分词工具如jieba对中文语料做统一预处理然后再送入训练流程。另一个容易踩的坑是源语言和目标语言的句子没有做到“对齐”即数据集中存在空的句子对或者明显的乱序。这类脏数据会在构建Vocab时引发异常或者在训练阶段产生异常高的loss。我的习惯是在预处理脚本里加入过滤逻辑长度过短比如小于3个token和长度过长超过阈值的句子对直接移除能大幅减少训练时的不稳定现象。6.2 训练阶段的常见报错与排查训练过程中最高频的报错之一是维度不匹配。原因通常出在batch内部的序列长度不一致而模型在forward时没有正确处理padding。项目本身依赖torchtext的BucketIterator来自动按序列长度进行batch内排序和padding但如果你自己改了数据加载逻辑很容易遗漏这一步导致在RNN的输入阶段报错。解决方案是再次确认数据管线中是否使用了长度感知的batch采样器。loss出现NaN也是一个经典问题。在训练后期RNN的隐状态数值容易放大如果没有梯度裁剪或者学习率设置过高就会出现数值溢出。遇到NaN先别急着怀疑代码逻辑优先检查三个地方学习率是否过大、梯度裁剪是否生效、数据中是否有异常值。将学习率调低一个数量级往往能快速解决NaN问题。还有一类问题是显存或内存不足。如果在小数据集上训练时出现OOM最直接的办法是减小batch_size并且把max_len限制在合理的范围比如50或100。如果你在推理阶段也出现显存不足考虑降低beam_size因为Beam Search需要对多条候选路径同时维护中间状态显存占用会随着beam_size线性增长。6.3 静态评测的工具推荐与使用心得静态工程评测不一定非要复杂的工具我常用的方式其实很朴素先快速浏览README和目录结构然后使用radon之类的工具计算圈复杂度用pylint或flake8检查代码风格再配合git log查看项目的提交历史和活跃度。对于pytorch-seq2seq它的圈复杂度分布非常“扁平”几乎没有特别复杂的函数这说明代码逻辑是直接的没有过度分支。另一个实用技巧是使用git blame查看核心文件的历史修改。如果一个文件被修改的次数很多往往说明它承载了主要需求迭代维护风险较高而长时间未变动的文件如果逻辑复杂则需要留意是否存在“僵尸代码”。我在评测中会特别关注测试目录是否存在、测试是否真的断言了核心逻辑。pytorch-seq2seq在这个维度上比较薄弱它几乎没有像样的单元测试这既是教学项目的普遍问题也是二次开发时需要优先补充的功课。根据我个人的实际体会做静态工程评测最有价值的产出不是给项目打出一个分数而是形成一张“工程化改造地图”。你读代码时顺手记录下的每一条脆弱点都可以转化为后续改造中的具体任务项。对pytorch-seq2seq来说这张地图的起点是依赖锁定和配置外置终点则是模块级的单元测试与服务化封装。只要迈出第一步这个老牌教学项目就能重新焕发出工程活力成为你自己的生产级系统的一部分。