背景
作为词向量化到推理的系列中,本篇作为本地优化篇。在上篇深度学习-RNN-词向量化到推理实践 的rnn模型存在较多的提升空间,这里主要分为训练质量、推理质量这两个大维度,所以这次文章接着transformer模型来接着“为自己写歌”。
Transformer 以自注意力机制为核心,通过并行计算和全局依赖建模,彻底改变了序列建模的范式。其灵活性和高性能使其成为深度学习的基础架构之一,后续的 BERT、GPT 等大模型均基于 Transformer 改进而来。
理解 Transformer 的关键在于掌握自注意力机制和编码器 - 解码器结构,这是理解现代大模型的基础。
概念
基本概念
-
起源与定位
- Transformer 是 2017 年由 Google 在论文《Attention Is All You Need》中提出的深度学习架构,最初用于自然语言处理(NLP)任务(如机器翻译),现已广泛应用于计算机视觉(CV)、语音处理等领域。
- 区别于传统循环神经网络(RNN)和卷积神经网络(CNN),Transformer 完全基于自注意力机制(Self-Attention) ,实现了并行计算和长距离依赖建模,大幅提升了训练效率和性能。
-
核心用途
- 解决 RNN 无法并行处理长序列的问题(如 LSTM/GRU 受限于时序依赖)。
- 替代 CNN 的多层卷积操作,通过注意力机制直接捕捉全局语义关联。
工作原理
Transformer 的核心架构由编码器(Encoder) 和解码器(Decoder) 组成,两者均由多个相同模块堆叠而成(通常为 6 层)。
编码器结构与工作流程
-
模块组成
-
每个编码器模块包含:
- 多头自注意力层(Multi-Head Self-Attention) :捕捉序列内部各位置的依赖关系。
- 前馈神经网络(Feed Forward Network) :对注意力输出进行非线性变换。
- 残差连接(Residual Connection) 和层归一化(Layer Normalization) :稳定训练过程。
-
-
工作流程
-
输入处理:输入序列(如单词嵌入向量)先与位置编码(Positional Encoding) 相加,获取时序信息。
-
多头自注意力计算:
- 将输入映射为查询向量(Query, Q)、键向量(Key, K)和值向量(Value, V)。
- 计算任意两个位置的注意力分数: (\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V) 其中,(\sqrt{d_k})为缩放因子,避免梯度消失。
- 多头机制将 Q、K、V 分割成多个子空间并行计算,再拼接结果,增强模型捕捉不同语义信息的能力。
-
前馈网络:对注意力输出进行线性变换和激活函数(如 ReLU)处理: (\text{FFN}(x) = \max(0, xW_1 + b_1)W_2 + b_2)
-
残差与归一化:每一层输出为(\text{LayerNorm}(x + \text{Attention}(x))),缓解梯度消失问题。
-
解码器结构与工作流程
-
模块差异
-
解码器在编码器基础上增加:
- 掩码多头自注意力层(Masked Multi-Head Self-Attention) :确保解码时只关注已生成的位置(避免未来信息泄露)。
- 编码器 - 解码器注意力层(Encoder-Decoder Attention) :解码器通过该层关注编码器的输出,获取源序列语义。
-
-
工作流程
- 输入处理:解码器输入为已生成的目标序列(如翻译后的单词),同样添加位置编码。
- 掩码自注意力:通过掩码矩阵屏蔽未来位置的注意力计算,保证自回归生成逻辑。
- 编码器 - 解码器注意力:解码器的 Q 来自当前层输入,K 和 V 来自编码器的最终输出,实现源序列与目标序列的语义对齐。
- 输出层:最后一层解码器的输出通过线性层和 softmax 函数,生成下一个 token 的概率分布。
自注意力机制(Self-Attention)
自注意力是 Transformer 的灵魂,用于计算序列中每个 token 与其他所有 token 的关联强度(注意力权重),并据此聚合信息。
- 计算步骤
给定输入序列的特征矩阵 X(形状为 [序列长度,特征维度]),自注意力计算分为 4 步:
-
生成 Q、K、V 矩阵:通过线性变换将 X 转换为三个矩阵:
- Query(查询):表示当前 token 要 “查询” 的信息。
- Key(键):表示其他 token 提供的 “键”,用于匹配 Query。
- Value(值):表示其他 token 提供的 “值”,即实际要聚合的信息。
- 公式:Q=XWQ,K=XWK,V=XWV(W**Q,W**K,W**V 为可学习参数)。
-
计算注意力权重:通过 Query 和 Key 的相似度计算权重,公式为:Attention(Q,K,V)=softmax(dkQKT)V
- QKT:计算 Query 与每个 Key 的点积(相似度),形状为 [序列长度,序列长度]。
- d**k:缩放因子(d**k 为 Key 的维度),避免点积结果过大导致 softmax 梯度消失。
- softmax:将权重归一化,确保总和为 1。
-
聚合 Value:用注意力权重对 Value 加权求和,得到每个 token 的上下文特征。
- 多头注意力(Multi-Head Attention)
将 Q、K、V 分成多个子矩阵(“头”),并行计算自注意力,再拼接结果。这样做的好处是:
- 捕捉不同子空间的注意力模式(如有的头关注语法,有的关注语义)。
- 增加模型的表达能力,避免单一注意力头的局限性。
位置编码(Positional Encoding)
Transformer 本身不依赖时序顺序,为了让模型理解 token 的位置信息,需要在输入中加入位置编码。位置编码通过公式生成,与输入特征相加:
- 对于位置 p**os 和维度 i:PE(p**os,2i)=sin(100002i/dmodelp**os)PE(p**os,2i+1)=cos(100002i/dmodelp**os) 其中 dmodel 为特征维度。正弦和余弦函数能让模型捕捉位置之间的相对关系(如距离为 k 的两个位置,编码差值固定)。
Transformer 的优势
- 并行计算:无需按顺序处理 token,可同时计算整个序列的特征,训练速度远超 RNN。
- 长距离依赖:自注意力机制直接建模任意两个 token 的关联,解决了 RNN 长距离信息衰减的问题。
- 灵活性:适用于多种任务(翻译、生成、分类等),通过微调即可迁移到新场景。
应用场景
- 自然语言处理:机器翻译(如 Google Translate)、文本生成(歌词、小说)、情感分析、问答系统。
- 计算机视觉:ViT(Vision Transformer)将图像分割为 “图像块”,用 Transformer 进行分类。
- 多模态任务:CLIP、DALL・E 等模型结合文本和图像的 Transformer 特征,实现跨模态生成与检索。
实践
回归正传,之前用RNN实现的歌词生成,会发现在长文本连贯性、语义关联性和韵律捕捉上优势明显。
现在直接给出对应的结论,大家有兴趣的可以自己进行性能测试。
训练特性对比
维度 | Transformer | RNN(含 LSTM/GRU) |
---|---|---|
并行计算能力 | 强。完全基于注意力机制,可并行处理整个序列,训练速度快,适合大规模数据。 | 弱。依赖时序顺序,需逐 token 处理,并行效率低,长序列训练耗时。 |
长距离依赖捕捉 | 强。自注意力机制直接建模任意两个 token 的关联,无论距离远近。 | 弱。依赖 “记忆传递”,长序列中信息易衰减(如早期歌词的意象难以影响后期)。 |
训练稳定性 | 稍复杂。需配合残差连接、层归一化等技巧,否则易出现训练不稳定(如梯度爆炸)。 | 较简单。递归结构天然适合序列训练,但易出现梯度消失(需 LSTM/GRU 改进)。 |
数据需求 | 高。需大量数据才能发挥注意力机制的优势,否则易过拟合。 | 较低。对小规模数据(如单一歌手的歌词)适应性更好。 |
歌词生成效果对比
- 文本连贯性与逻辑性
- Transformer: 优势显著。通过全局注意力,能更好地保持歌词的主题一致性(如围绕 “青春”“回忆” 等核心意象展开),避免 RNN 常见的 “上下文脱节” 问题(如前句讲 “雨天”,后句突然跳到 “沙漠”)。 例:生成的歌词可能出现 “窗外的麻雀 / 在电线杆上多嘴 / 你说这一句 / 很有夏天的感觉”(前后意象关联紧密)。
- RNN: 短序列(如 2-4 句)连贯性尚可,但长序列易出现逻辑断裂,尤其在歌词段落转换(主歌→副歌)时容易 “跑题”。 例:可能生成 “窗外的麻雀 / 在电线杆上多嘴 / 沙漠里的骆驼 / 背着沉重的水”(意象跳跃无关联)。
- 韵律与节奏捕捉
- Transformer: 更擅长捕捉歌词的韵律模式(如押韵、句式长短)。通过注意力关注押韵词的位置(如每句结尾词的韵母),生成的歌词更符合 “歌词感”。 例:可能出现 “故事的小黄花 / 从出生那年就飘着 / 童年的荡秋千 / 随记忆一直晃到现在”(押 “ao” 韵,句式对称)。
- RNN: 对局部韵律(如相邻两句押韵)有一定捕捉能力,但长距离韵律(如段落间押韵呼应)较弱,生成的文本更像 “散文” 而非 “歌词”。
- 语义丰富度
- Transformer: 能更好地融合上下文语义,生成更具层次感的歌词。例如,通过注意力关联 “过去” 与 “现在” 的意象(如 “旧照片” 与 “新泪痕”),增强情感深度。
- RNN: 更依赖局部语义关联,生成的歌词往往停留在表面描述,难以形成复杂的情感递进或意象隐喻。
- 创新与多样性
- Transformer: 在大规模数据训练下,能生成更具创新性的组合(如 “月光揉碎在窗台 / 而你躲进回忆里发呆”),但需控制 “温度参数” 避免无意义的拼凑。
- RNN: 生成结果更保守,易重复训练数据中的句式(如反复出现 “爱你”“想你” 等简单表达),多样性较弱。
代码实战
比之前的RNN示例,主要有以下额外不同之处,有一半函数变化不大。
分词:额外增加了特殊标记处理
# 数据预处理函数 - 增强断句处理
def preprocess_data(file_path, use_tokenizer=True, augment_punctuation=True):
print(f"开始处理数据文件: {file_path}")
text = ""
# 检查是否为ZIP文件
if file_path.endswith('.zip'):
with zipfile.ZipFile(file_path, 'r') as zip_ref:
# 获取所有文件名
file_names = zip_ref.namelist()
logger.info(f"ZIP文件包含 {len(file_names)} 个文件")
# 使用进度条读取文本文件内容
with tqdm(file_names, desc="读取文件", unit="file") as pbar:
for name in pbar:
if name.endswith('.txt'):
pbar.set_postfix({"当前文件": name})
with zip_ref.open(name) as f:
try:
content = f.read().decode('utf-8')
except UnicodeDecodeError:
content = f.read().decode('gbk')
text += content
# 保留重要的断句符号
# pattern = r'[^\u4e00-\u9fa5a-zA-Z0-9,。!?、;:“”‘’()《》【】{}()[]<>—-—,.!?;:'"`~@#$%^&*()_+=-|\/*]'
# text = re.sub(pattern, '', text)
# 增强断句符号的表示
if augment_punctuation:
# 增加断句符号的权重(复制多次)
punctuation_marks = [',', '。', '!', '?', ';', ':', ',', '.', '!', '?']
for mark in punctuation_marks:
text = text.replace(mark, mark * 3) # 每个符号复制3次,增加模型关注
# 应用分词器
if use_tokenizer:
logger.info("使用分词器处理文本...")
words = jieba.lcut(text)
# 过滤空字符串
tokens = [word for word in words if word.strip()]
logger.info(f"分词后总词数: {len(tokens)}")
else:
# 未使用分词器,按字符处理
tokens = [char for char in text if char.strip()]
logger.info(f"总字符数: {len(tokens)}")
# 创建词到索引和索引到词的映射
unique_tokens = sorted(list(set(tokens)))
# 确保包含特殊标记(小写和大写形式)
special_tokens = ['<pad>', '<unk>', '<bos>', '<eos>',
'<PAD>', '<UNK>', '<BOS>', '<EOS>',
'[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]']
# 将特殊标记添加到词汇表开头
for token in reversed(special_tokens): # 逆序添加确保顺序正确
if token not in unique_tokens:
unique_tokens.insert(0, token)
# 构建映射
token_to_idx = {token: i for i, token in enumerate(unique_tokens)}
idx_to_token = {i: token for i, token in enumerate(unique_tokens)}
vocab_size = len(unique_tokens)
# 统计断句符号频率
# punct_freq = {mark: tokens.count(mark) for mark in punctuation_marks if mark in tokens}
# logger.info(f"断句符号频率统计: {punct_freq}")
print(f"词汇表大小: {vocab_size} 个唯一token,包含特殊标记")
return tokens, token_to_idx, idx_to_token, vocab_size
神经网络模型:
# 位置编码
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
super(PositionalEncoding, self).__init__()
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
self.register_buffer('pe', pe)
def forward(self, x):
return x + self.pe[:x.size(1), :]
# Transformer模型
class TransformerModel(nn.Module):
def __init__(self, vocab_size, embed_size, hidden_size, num_layers, num_heads, dropout=0.5):
super(TransformerModel, self).__init__()
self.embed_size = embed_size
self.hidden_size = hidden_size
# 嵌入层和位置编码
self.embedding = nn.Embedding(vocab_size, embed_size)
self.pos_encoder = PositionalEncoding(embed_size)
# Transformer编码器层
encoder_layer = nn.TransformerEncoderLayer(
d_model=embed_size,
nhead=num_heads,
dim_feedforward=hidden_size,
dropout=dropout,
batch_first=True
)
self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers)
# 输出层
self.decoder = nn.Linear(embed_size, vocab_size)
self.init_weights()
def init_weights(self):
initrange = 0.1
self.embedding.weight.data.uniform_(-initrange, initrange)
self.decoder.bias.data.zero_()
self.decoder.weight.data.uniform_(-initrange, initrange)
def forward(self, src, src_mask=None):
# 嵌入和位置编码
src = self.embedding(src) * math.sqrt(self.embed_size)
src = self.pos_encoder(src)
# Transformer编码
if src_mask is None:
src_mask = self._generate_square_subsequent_mask(src.size(1)).to(src.device)
output = self.transformer_encoder(src, src_mask)
# 解码为词汇表大小
output = self.decoder(output)
return output
def _generate_square_subsequent_mask(self, sz):
mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1)
mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
return mask
歌词生成:这里主要引入一些断句输出
def generate_text(model, token_to_idx, idx_to_token, seed_text, max_length=200, temperature=0.8, use_tokenizer=True,
beam_width=1, unk_penalty=2.0, punctuation_bias=0.5, min_sentence_length=5,
repeat_penalty=0.5, rhyme_bonus=1.0, max_sentence_length=20, coherence_threshold=0.8):
"""生成歌词文本,增强断句逻辑"""
model.eval()
# 断句符号列表
sentence_enders = ['。', '!', '?', '.', '!', '?', ';', ';']
line_breakers = [',', '、', ',', ':', ':']
all_punctuation = sentence_enders + line_breakers
# 处理种子文本
if use_tokenizer:
seed_tokens = jieba.lcut(seed_text)
else:
seed_tokens = list(seed_text)
# 过滤不在词汇表中的词
seed_tokens = [token for token in seed_tokens if token in token_to_idx]
if not seed_tokens:
print("种子文本中的所有词都不在词汇表中,使用随机种子")
seed_tokens = [random.choice(list(token_to_idx.keys()))]
input_idx = [token_to_idx[token] for token in seed_tokens]
# 获取<unk> token的索引
unk_idx = token_to_idx.get('<unk>', None)
# 跟踪当前句子长度
current_sentence_length = 0
# 记录最近生成的词
recent_tokens = []
# 记录上一句最后一个字符,用于押韵
last_char = None
# 记录生成的行
prev_lines = []
# 文本生成
generated_text = seed_text
generated_tokens = seed_tokens.copy()
with torch.no_grad():
for _ in range(max_length):
input_tensor = torch.tensor([input_idx], dtype=torch.long).to(device)
output = model(input_tensor)
# 应用温度参数调整预测分布
output = output[:, -1, :] / temperature
# 惩罚生成<unk> token
if unk_idx is not None:
output[0, unk_idx] -= unk_penalty
# 惩罚最近生成的词
for token in recent_tokens:
if token in token_to_idx:
output[0, token_to_idx[token]] *= repeat_penalty
# 押韵机制
if last_char and rhyme_bonus > 0:
for token, idx in token_to_idx.items():
if token.endswith(last_char):
output[0, idx] += rhyme_bonus
# 动态断句策略
if current_sentence_length > min_sentence_length:
# 随着句子变长,增加断句概率
dynamic_bias = punctuation_bias * (current_sentence_length / max_sentence_length)
for punc in all_punctuation:
if punc in token_to_idx:
output[0, token_to_idx[punc]] += dynamic_bias
# 强制断句:句子过长时强制使用结束符号
if current_sentence_length >= max_sentence_length:
for punc in sentence_enders:
if punc in token_to_idx:
output[0, token_to_idx[punc]] += 100 # 大幅提高结束符号概率
# 连贯性增强:惩罚最近出现过的词
if len(generated_tokens) > 2:
recent_tokens = generated_tokens[-3:]
for token in recent_tokens:
if token in token_to_idx:
output[0, token_to_idx[token]] *= coherence_threshold
# 采样下一个词
probs = torch.softmax(output, dim=1)
if beam_width > 1:
# 使用beam search
next_indices = torch.topk(probs, beam_width, dim=1)[1][0].tolist()
next_idx = next_indices[0]
else:
# 采样
next_idx = torch.multinomial(probs, num_samples=1).item()
# 获取下一个token
next_token = idx_to_token[next_idx]
generated_text += next_token
generated_tokens.append(next_token)
input_idx = input_idx[1:] + [next_idx] # 滑动窗口
# 更新最近生成的词
recent_tokens = (recent_tokens + [next_token])[-5:]
# 更新句子长度
current_sentence_length += 1
# 如果生成了句子结束符号,重置句子长度和记录最后一个字符
if next_token in sentence_enders:
current_sentence_length = 0
last_char = next_token if len(next_token) > 0 else None
prev_lines.append(''.join(generated_tokens[-current_sentence_length:]))
# 智能断句:根据语义连贯性和长度断句
if current_sentence_length >= max_sentence_length * 0.7:
for punc in all_punctuation:
if punc in token_to_idx:
output[0, token_to_idx[punc]] += 20 # 提高断句概率
"""后处理文本"""
processed_text = ""
for token in generated_tokens:
processed_text += token
if token in sentence_enders:
processed_text += "\n" # 句子结束后换行
elif token in line_breakers:
processed_text += "\n" # 行内停顿后换行
# 优化歌词结构:去除孤立的句号行
lines = processed_text.split('\n')
cleaned_lines = []
for line in lines:
# 去除空白字符
stripped_line = line.strip()
# 如果行只包含句号或为空,跳过
if stripped_line == '' or len(stripped_line) == 1:
continue
cleaned_lines.append(line)
# 重新组合文本,确保段落间有一个空行
final_text = '\n'.join(cleaned_lines)
# 避免开头或结尾有空行
final_text = final_text.strip()
# 确保段落间只有一个空行
final_text = re.sub(r'\n{3,}', '\n\n', final_text)
return final_text
效果展示
/Users/lucas/.pyenv/versions/3.9.18/bin/python /Users/lucas/PycharmProjects/ai/deepLearning/jaychou_lyrics_generator_transformer.py
2025-07-02 20:25:13,553 - INFO - ZIP文件包含 1 个文件
Using MPS-(Metal Performance Shaders) for GPU acceleration
开始处理数据文件: jaychou_lyrics.txt.zip
读取文件: 100%|██████████| 1/1 [00:00<00:00, 354.10file/s, 当前文件=jaychou_lyrics.txt]
2025-07-02 20:25:13,577 - INFO - 使用分词器处理文本...
Building prefix dict from the default dictionary ...
2025-07-02 20:25:13,578 - DEBUG - Building prefix dict from the default dictionary ...
Loading model from cache /var/folders/3m/s8hp_14s7t50f5y44mfbs3hm0000gn/T/jieba.cache
2025-07-02 20:25:13,578 - DEBUG - Loading model from cache /var/folders/3m/s8hp_14s7t50f5y44mfbs3hm0000gn/T/jieba.cache
Loading model cost 0.316 seconds.
2025-07-02 20:25:13,894 - DEBUG - Loading model cost 0.316 seconds.
Prefix dict has been built successfully.
2025-07-02 20:25:13,894 - DEBUG - Prefix dict has been built successfully.
2025-07-02 20:25:13,991 - INFO - 分词后总词数: 34154
词汇表大小: 5713 个唯一token,包含特殊标记
2025-07-02 20:25:14,622 - INFO - 开始训练,总轮数: 3, 学习率阈值: 1e-06
/Users/lucas/.pyenv/versions/3.9.18/lib/python3.9/site-packages/torch/utils/data/dataloader.py:683: UserWarning: 'pin_memory' argument is set as true but not supported on MPS now, then device pinned memory won't be used.
warnings.warn(warn_msg)
Using MPS-(Metal Performance Shaders) for GPU acceleration
Using MPS-(Metal Performance Shaders) for GPU acceleration
Using MPS-(Metal Performance Shaders) for GPU acceleration
Using MPS-(Metal Performance Shaders) for GPU acceleration
Using MPS-(Metal Performance Shaders) for GPU acceleration
Using MPS-(Metal Performance Shaders) for GPU acceleration
Using MPS-(Metal Performance Shaders) for GPU acceleration
Using MPS-(Metal Performance Shaders) for GPU acceleration
Epoch-b 1/3, Loss: 0.1189, cur_loss: 1083.7431: 100%|██████████| 533/533 [00:55<00:00, 9.62it/s]
2025-07-02 20:26:16,344 - INFO - Epoch 1: 保存最佳模型,损失=2.0333
Epoch-b 2/3, Loss: 0.1221, cur_loss: 67.6585: 100%|██████████| 533/533 [00:55<00:00, 9.58it/s]
2025-07-02 20:27:12,092 - INFO - Epoch 2: 保存最佳模型,损失=0.1269
Epoch-b 3/3, Loss: 0.0901, cur_loss: 50.3111: 100%|██████████| 533/533 [00:55<00:00, 9.57it/s]
2025-07-02 20:28:07,876 - INFO - Epoch 3: 保存最佳模型,损失=0.0944
2025-07-02 20:28:07,944 - INFO - Epoch 3: 学习率 0.00000001 低于阈值 1e-06, 提前终止训练
===== 生成的歌词 =====
窗外的麻雀在电线杆上多嘴你说这幸福很可惜没有祝福当爱你并不对我努力在。
燈熄的時候滿天所以你最善变的表情我有一点汗我要一步往上爬在最高点。
燈熄的時候滿天的星空最明亮的是寂寞我感到比法典刻在黑色的?
我不想拆穿你当作是谁幸运的陪在你却微笑像拥抱多想藏着你?
我不想拆穿你当作是我最后才明白看不见你的笑我怎么睡得着你的!
我会遇到了周润发所以你可以给将我的信号已陪你在一起等雨变强?
败给黑色幽默不想太多我会一直好好过你已经远远离开我也已经失去你的微笑。
燈熄的時候滿天都不长你们有几个一起上好了正义呼唤我美女需要。
预兆气氛微妙因为爱你在我胸口睡著像这样的生活我爱你你爱我还在。
燈熄的時候滿天的星空最明亮的是寂寞我只是卑微的小丑翻幾個跟斗就?
我的认真投又不会掩护我选你讲了其实都知道我没办法教表错情。
人在预兆石阶上焚着油膏在我的生活我爱你你爱我还在回味你!
☆支持正版你喜欢的样子线条一致隔壁的橱窗一把吉他远远欣赏木炭一箩筐木炭一直?
哈哈哈,
我来问他一下好了!
我会遇到难过请你忘了吧记得你叫我的是故意哈哈你是不是
=====================
Process finished with exit code 0
交流
请问本实战还有哪些可以提升之处?
欢迎+v:chou_lucas
本文代码请在vx公众号后台回复【AI】后的链接见 /ai/deepLearning/目录下 transformer.py