既然我们已经介绍了循环神经网络(RNN)、长短期记忆网络(LSTM)和门控循环单元(GRU)的理论基础,是时候将这些知识付诸实践了。在此动手实践部分,我们将使用常用的深度学习库TensorFlow及其Keras API来构建一个简单的序列模型。本次练习将巩固你对如何准备序列文本数据以及如何为典型任务构建基本循环模型的理解。“我们将解决一个简化的情感分析问题:将短文本片段归类为正面或负面。尽管情感分析通常涉及更复杂的数据集和模型,但本例纯粹侧重于设置和训练序列模型的机制。”准备工作:库和数据首先,请确保已安装TensorFlow。如果未安装,通常可以使用pip进行安装: pip install tensorflow我们将使用TensorFlow自带的Keras来构建模型。让我们定义一个小型合成数据集用于演示。import numpy as np import tensorflow as tf from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, SimpleRNN, Dense, LSTM, GRU from tensorflow.keras.optimizers import Adam # 示例数据:(文本,标签) -> 0 表示负面,1 表示正面 texts = [ "this is a great movie", "i really enjoyed the experience", "what a fantastic performance", "loved the acting", "truly amazing", "this is terrible", "i did not like it at all", "what a boring show", "hated the plot", "really awful film" ] labels = np.array([1, 1, 1, 1, 1, 0, 0, 0, 0, 0]) # 5个正面,5个负面 print(f"样本数量: {len(texts)}") print(f"示例文本: '{texts[0]}', 标签: {labels[0]}") print(f"示例文本: '{texts[5]}', 标签: {labels[5]}")准备文本数据序列模型不直接处理原始文本。我们需要将句子转换为模型可以处理的数值表示。这涉及两个主要步骤:词元化和填充。词元化: 我们为数据集(即我们的词汇表)中的每个不同词汇分配一个唯一的整数索引。填充: 由于RNN逐步处理序列,它们通常要求输入序列具有统一的长度。我们通过向较短序列添加特殊的“填充”词元(通常用0表示)来实现这一点,直到它们匹配最长序列的长度(或预定义的最大长度)。# --- 词元化 --- vocab_size = 100 # 根据词频保留的最大词汇数量 tokenizer = Tokenizer(num_words=vocab_size, oov_token="<OOV>") # <OOV> 用于表示词汇表外词汇 tokenizer.fit_on_texts(texts) word_index = tokenizer.word_index sequences = tokenizer.texts_to_sequences(texts) print("\n词汇索引示例:", list(word_index.items())[:10]) print("原始文本:", texts[0]) print("序列表示:", sequences[0]) # --- 填充 --- max_length = 10 # 定义最大序列长度(可推断或设定) padded_sequences = pad_sequences(sequences, maxlen=max_length, padding='post', truncating='post') print("\n填充序列示例(后置填充):") print(padded_sequences[0]) print("填充序列的形状:", padded_sequences.shape)注意 pad_sequences 如何在末尾添加零(padding='post')以使所有序列长度为10。如果序列长度超过 max_length,则会被截断(truncating='post')。构建序列模型现在,让我们构建模型。我们将使用Keras的Sequential API,它允许我们线性堆叠层。嵌入层: 这是第一层。它接收整数编码的词汇表,并为每个词查找对应的嵌入向量。这些嵌入在训练期间被学习。它需要 input_dim(词汇表大小)和 output_dim(嵌入向量的维度)。我们还指定了 input_length,它对应于我们填充操作中的 max_length。循环层: 这是我们序列模型的核心。我们将从 SimpleRNN 开始。主要参数是 units,它定义了隐藏状态(和输出空间)的维度。其他循环层,如 LSTM 或 GRU,可以在此处替换使用。全连接输出层: 由于这是一个二分类问题(正面/负面),我们需要一个带有单个单元和 sigmoid 激活函数的最终 Dense 层。sigmoid 函数输出一个介于0和1之间的值,表示正面类别的概率。embedding_dim = 16 # 词嵌入的维度 rnn_units = 32 # RNN层中的单元数量 model = Sequential([ # 1. 嵌入层 Embedding(input_dim=vocab_size, output_dim=embedding_dim, input_length=max_length), # 2. 循环层 (SimpleRNN) # 稍后尝试将 SimpleRNN 替换为 LSTM 或 GRU! SimpleRNN(units=rnn_units), # 如果堆叠RNN层,在中间层使用 return_sequences=True: # SimpleRNN(units=rnn_units, return_sequences=True), # SimpleRNN(units=rnn_units), # 最后一层RNN不需要 return_sequences=True # 3. 输出层 Dense(units=1, activation='sigmoid') ]) # 显示模型的架构 model.summary()摘要显示了层、它们的输出形状以及可训练参数的数量。注意 SimpleRNN 层如何输出形状为 (None, 32) 的单个向量,其中32是 rnn_units。如果设置了 return_sequences=True,则输出形状将是 (None, max_length, rnn_units)。编译模型在训练之前,我们需要使用 model.compile() 配置学习过程。这涉及指定:优化器: 用于更新模型权重的算法(例如,Adam、RMSprop、SGD)。Adam通常是一个好的默认选择。损失函数: 衡量模型在训练数据上的表现。对于带有 sigmoid 输出的二分类,binary_crossentropy 是合适的。指标: 用于监控训练和测试步骤。对于分类任务,accuracy 是一个常用指标。model.compile(optimizer=Adam(learning_rate=0.001), loss='binary_crossentropy', metrics=['accuracy']) print("\n模型编译成功。")训练模型现在,我们使用准备好的数据训练模型。我们将填充后的序列作为输入 (X),并提供相应的标签 (y)。epochs(周期): 模型将遍历整个训练数据集的次数。batch_size(批大小): 在模型权重更新之前处理的样本数量。validation_split(验证分割): 可选地,留出一部分训练数据,在每个周期结束时评估损失和指标。这有助于监控过拟合。num_epochs = 30 batch_size = 2 validation_fraction = 0.2 # 使用20%的数据进行验证 print(f"\n开始训练,共 {num_epochs} 个周期...") history = model.fit(padded_sequences, labels, epochs=num_epochs, batch_size=batch_size, validation_split=validation_fraction, verbose=1) # 设置 verbose=0 可隐藏周期进度 print("\n训练完成。")在训练期间,Keras会在每个周期之后打印训练集和验证集(如果提供)的损失和准确率。可视化训练历史绘制训练和验证损失以及准确率随周期的变化曲线,是评估模型学习进展和检查过拟合的标准方式。当模型在训练数据上表现良好,但在未见的验证数据上表现不佳时(即训练损失降低而验证损失增加),就会发生过拟合。import plotly.graph_objects as go from plotly.subplots import make_subplots # 提取历史数据 acc = history.history['accuracy'] val_acc = history.history.get('val_accuracy') # 使用 .get() 以防 validation_split 为 0 loss = history.history['loss'] val_loss = history.history.get('val_loss') epochs_range = range(1, num_epochs + 1) # 创建带子图的图形 fig = make_subplots(rows=1, cols=2, subplot_titles=('训练和验证准确率', '训练和验证损失')) # 添加准确率轨迹 fig.add_trace(go.Scatter(x=list(epochs_range), y=acc, name='训练准确率', mode='lines+markers', marker_color='#1f77b4'), row=1, col=1) if val_acc: fig.add_trace(go.Scatter(x=list(epochs_range), y=val_acc, name='验证准确率', mode='lines+markers', marker_color='#ff7f0e'), row=1, col=1) # 添加损失轨迹 fig.add_trace(go.Scatter(x=list(epochs_range), y=loss, name='训练损失', mode='lines+markers', marker_color='#1f77b4'), row=1, col=2) if val_loss: fig.add_trace(go.Scatter(x=list(epochs_range), y=val_loss, name='验证损失', mode='lines+markers', marker_color='#ff7f0e'), row=1, col=2) # 更新布局 fig.update_layout( height=400, width=800, xaxis_title='周期', yaxis_title='准确率', xaxis2_title='周期', yaxis2_title='损失', legend_title_text='指标', margin=dict(l=20, r=20, t=50, b=20) # 调整边距 ) # 显示图表(在支持 Plotly 渲染的环境中) # fig.show() # 如果配置了 Plotly,取消注释以在本地显示 # 或者提供用于网页嵌入的 JSON 表示 plotly_json = fig.to_json(){ "layout": { "height": 400, "width": 800, "xaxis": { "anchor": "y", "domain": [0.0, 0.45], "title": { "text": "周期" } }, "yaxis": { "anchor": "x", "domain": [0.0, 1.0], "title": { "text": "准确率" } }, "xaxis2": { "anchor": "y2", "domain": [0.55, 1.0], "title": { "text": "周期" } }, "yaxis2": { "anchor": "x2", "domain": [0.0, 1.0], "title": { "text": "损失" } }, "legend": { "title": { "text": "指标" } }, "margin": { "l": 20, "r": 20, "t": 50, "b": 20 }, "annotations": [ { "xref": "paper", "yref": "paper", "x": 0.225, "y": 1.03, "showarrow": false, "text": "训练和验证准确率", "xanchor": "center", "yanchor": "bottom", "font": { "size": 14 } }, { "xref": "paper", "yref": "paper", "x": 0.775, "y": 1.03, "showarrow": false, "text": "训练和验证损失", "xanchor": "center", "yanchor": "bottom", "font": { "size": 14 } } ] }, "data": [ { "type": "scatter", "xaxis": "x", "yaxis": "y", "x": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], "y": [0.625, 0.75, 0.875, 0.875, 0.875, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "name": "训练准确率", "mode": "lines+markers", "marker": { "color": "#1f77b4" } }, { "type": "scatter", "xaxis": "x", "yaxis": "y", "x": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], "y": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "name": "验证准确率", "mode": "lines+markers", "marker": { "color": "#ff7f0e" } }, { "type": "scatter", "xaxis": "x2", "yaxis": "y2", "x": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], "y": [0.6824744343757629, 0.6536840199828148, 0.6198422312736511, 0.5794422030448914, 0.5321754217147827, 0.47910547256469727, 0.42173120379447937, 0.3622015118598938, 0.3033258020877838, 0.24836888909339905, 0.20007747411727905, 0.1600513458251953, 0.1280420422554016, 0.10294786095619202, 0.08333367854356766, 0.06800711154937744, 0.056001797318458557, 0.04653342440724373, 0.038988735526800156, 0.03292558714747429, 0.028001997619867325, 0.023957084864377975, 0.02061031386256218, 0.01782264932990074, 0.01548493653535843, 0.013511774122714996, 0.011837894096970558, 0.010411089286208153, 0.009190460667014122, 0.008141844533383846], "name": "训练损失", "mode": "lines+markers", "marker": { "color": "#1f77b4" } }, { "type": "scatter", "xaxis": "x2", "yaxis": "y2", "x": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], "y": [0.712626039981842, 0.6993573904037476, 0.6869385242462158, 0.6756075024604797, 0.6648035049438477, 0.6540269255638123, 0.6425734162330627, 0.6297341585159302, 0.6147915124893188, 0.5973262786865234, 0.5771406292915344, 0.554181694984436, 0.528782308101654, 0.5015338659286499, 0.47317805886268616, 0.4445108473300934, 0.41624969244003296, 0.38901472091674805, 0.36323219537734985, 0.33918818831443787, 0.3170371949672699, 0.2968186140060425, 0.27847063541412354, 0.26186615228652954, 0.24684034287929535, 0.2332199513912201, 0.22083988785743713, 0.20955359935760498, 0.19923369586467743, 0.18977202475070953], "name": "验证损失", "mode": "lines+markers", "marker": { "color": "#ff7f0e" } } ] }训练和验证准确率及损失曲线随训练周期变化的图示。在这个数据易于分离的简单例子中,准确率迅速达到1.0(即100%)。在更真实的数据集上,你预期会看到更平缓的准确率提升和潜在的过拟合迹象(训练和验证曲线之间的差异)。进行预测最后,让我们看看如何使用训练好的模型来预测新的、未见文本的情感。请记住对新数据应用相同的预处理步骤(词元化和填充)。new_texts = [ "it was truly great", "a complete waste of time", "amazing film loved it" ] # 预处理新文本 new_sequences = tokenizer.texts_to_sequences(new_texts) new_padded = pad_sequences(new_sequences, maxlen=max_length, padding='post', truncating='post') print("\n新的填充序列:") print(new_padded) # 获取预测结果(概率) predictions = model.predict(new_padded) print("\n原始预测结果(概率):") print(predictions) # 解释预测结果(阈值为0.5) predicted_labels = (predictions > 0.5).astype(int).flatten() # flatten 将 [[0],[1]] 转换为 [0,1] print("\n预测标签(0=负面, 1=正面):") for text, label in zip(new_texts, predicted_labels): sentiment = "正面" if label == 1 else "负面" print(f"'{text}' -> {sentiment}")输出显示了模型分配给正面类别的概率(值越接近1表示正面情感,越接近0表示负面情感)以及基于0.5阈值的最终预测标签。实验与后续步骤本例提供了一个基本框架。建议你进行实验:替换循环层: 在模型定义中将 SimpleRNN 替换为 LSTM 或 GRU。观察训练速度或最终性能是否存在差异(尽管此数据集过于简单,难以看出与梯度消失相关的显著差异)。# 使用LSTM的示例 # model = Sequential([ # Embedding(input_dim=vocab_size, output_dim=embedding_dim, input_length=max_length), # LSTM(units=rnn_units), # 将 SimpleRNN 替换为 LSTM # Dense(units=1, activation='sigmoid') # ])调整超参数: 更改 embedding_dim、rnn_units、learning_rate、batch_size 或 num_epochs 并重新训练模型。堆叠层: 尝试堆叠多个循环层(记住在除最后一个循环层之外的所有层上设置 return_sequences=True)。尝试不同的任务: 调整结构以适应不同的序列任务,或许是多类别分类,甚至是简单的字符级生成模型(尽管那需要更重大的更改)。使用真实数据: 将此过程应用于更大、更真实的数据集,例如 tensorflow_datasets 中提供的IMDB电影评论数据集。本次实践练习展示了构建和训练用于文本分类的简单序列模型的端到端过程。你现在已经掌握了基础代码结构,可以处理使用RNN、LSTM或GRU进行更复杂的序列处理任务。