使用循环神经网络对序列数据进行建模
1. 序列数据介绍
在机器学习中,序列数据是一种常见且独特的数据类型。与其他类型的数据不同,序列数据中的元素按特定顺序出现,且彼此之间并非相互独立。
1.1 建模序列数据 - 顺序很重要
典型的监督学习机器学习算法通常假设输入数据是独立同分布(IID)的,即训练示例相互独立且具有相同的底层分布。在这种假设下,训练示例的输入顺序对模型训练并无影响。例如,鸢尾花数据集,每朵花的测量数据都是独立的,一朵花的测量结果不会影响其他花的测量结果。
然而,当处理序列数据时,这种独立性假设不再成立。以预测某只股票的市场价值为例,每个训练示例代表某一天该股票的市场价值。若要预测未来三天的股票市场价值,按日期排序考虑先前的股票价格以得出趋势是合理的,而随机使用这些训练示例则无法有效获取趋势信息。
1.2 序列数据与时间序列数据
时间序列数据是序列数据的一种特殊类型,其中每个示例都与时间维度相关联。在时间序列数据中,样本是在连续的时间戳上采集的,因此时间维度决定了数据点之间的顺序。例如,股票价格和语音记录都属于时间序列数据。
但并非所有序列数据都具有时间维度,如文本数据或 DNA 序列,这些示例虽然有序,但不属于时间序列数据。不过,循环神经网络(RNN)可用于处理时间序列数据和非时间序列的序列数据。
1.3 表示序列
在机器学习模型中,为了利用序列数据的顺序信息,我们通常将序列表示为〈𝒙(1), 𝒙(2), …, 𝒙(T)〉,其中上标索引表示实例的顺序,序列的长度为 T。
标准的神经网络模型,如多层感知机(MLP)和用于图像数据的卷积神经网络(CNN),假设训练示例相互独立,不包含顺序信息,即这些模型没有对先前训练示例的记忆。而 RNN 则专门设计用于建模序列数据,能够记住过去的信息并相应地处理新事件,这在处理序列数据时具有明显优势。
1.4 序列建模的不同类别
序列建模有许多有趣的应用,如语言翻译、图像字幕生成和文本生成等。根据输入和输出数据的关系,序列建模任务可分为以下几类:
-
多对一
:输入数据是序列,但输出是固定大小的向量或标量,而非序列。例如,在情感分析中,输入是基于文本的(如电影评论),输出是一个类别标签(如表示评论者是否喜欢该电影的标签)。
-
一对多
:输入数据是标准格式,不是序列,但输出是序列。图像字幕生成就是这种类型的例子,输入是一张图像,输出是总结该图像内容的英文短语。
-
多对多
:输入和输出数组都是序列。此类又可根据输入和输出是否同步进一步细分。同步的多对多建模任务如视频分类,视频中的每一帧都被标记;延迟的多对多建模任务如将一种语言翻译成另一种语言,机器必须先读取和处理整个英文句子,才能生成其德语翻译。
2. 用于建模序列的 RNN
在使用 TensorFlow 实现 RNN 之前,我们先来了解 RNN 的主要概念。
2.1 理解 RNN 循环机制
RNN 的架构与标准前馈神经网络有所不同。在标准前馈网络中,信息从输入层流向隐藏层,再从隐藏层流向输出层。而在 RNN 中,隐藏层的输入不仅来自当前时间步的输入层,还来自上一个时间步的隐藏层。
这种相邻时间步之间的信息流动使网络能够记住过去的事件,这种信息流动通常以循环的形式表示,这也是 RNN 名称的由来。RNN 可以有多个隐藏层,通常将只有一个隐藏层的 RNN 称为单层 RNN,但要注意它与没有隐藏层的单层神经网络(如 Adaline 或逻辑回归)不同。
RNN 的输出类型可以是序列〈𝒐(0), 𝒐(1), …, 𝒐(T)〉,也可以只是最后一个输出(即 𝒐(T))。在 TensorFlow Keras API 中,可以通过设置
return_sequences
参数为
True
或
False
来指定循环层返回序列输出还是仅使用最后一个输出。
在 RNN 中,每个隐藏单元在第一个时间步(t = 0)时初始化为零或小随机值,在 t > 0 的时间步,隐藏单元的输入来自当前时间的数据点 𝒙(t) 和上一个时间步的隐藏单元值 𝒉(t - 1)。对于多层 RNN,各层的信息流动如下:
-
第一层
:隐藏层表示为 𝒉1(t),其输入来自数据点 𝒙(t) 和上一个时间步同一层的隐藏值 𝒉1(t - 1)。
-
第二层
:隐藏层 𝒉2(t) 的输入来自当前时间步下一层的输出 𝒐1(t) 和上一个时间步自身的隐藏值 𝒉2(t - 1)。
除最后一层外,每个循环层都必须接收序列作为输入,因此除最后一层外,所有循环层都必须返回序列作为输出(即
return_sequences = True
),最后一个循环层的行为取决于具体问题。
2.2 计算 RNN 中的激活值
为了计算 RNN 中隐藏层和输出层的实际激活值,我们以单层 RNN 为例(多层 RNN 的原理相同)。在 RNN 的表示中,每个有向边(框之间的连接)都与一个权重矩阵相关联,这些权重不依赖于时间 t,因此在时间轴上是共享的。单层 RNN 中的不同权重矩阵如下:
-
𝑾𝑥ℎ
:输入 𝒙(t) 与隐藏层 h 之间的权重矩阵。
-
𝑾ℎℎ
:与循环边相关联的权重矩阵。
-
𝑾ℎ𝑜
:隐藏层与输出层之间的权重矩阵。
在某些实现中,权重矩阵 𝑾𝑥ℎ 和 𝑾ℎℎ 会连接成一个组合矩阵 𝑾ℎ = [𝑾𝑥ℎ; 𝑾ℎℎ]。
隐藏层的净输入 𝒛ℎ(预激活)通过线性组合计算,即计算权重矩阵与相应向量的乘积之和,并加上偏置单元:
[ 𝒛ℎ(t) = 𝑾𝑥ℎ𝒙(t) + 𝑾ℎℎ𝒉(t - 1) + 𝒃ℎ ]
然后,时间步 t 时隐藏单元的激活值计算如下:
[ 𝒉(t) = 𝜙ℎ(𝒛ℎ(t)) = 𝜙ℎ(𝑾𝑥ℎ𝒙(t) + 𝑾ℎℎ𝒉(t - 1) + 𝒃ℎ) ]
其中,𝒃ℎ 是隐藏单元的偏置向量,𝜙ℎ(∙) 是隐藏层的激活函数。
如果使用连接后的权重矩阵 𝑾ℎ = [𝑾𝑥ℎ; 𝑾ℎℎ],计算隐藏单元的公式将变为:
[ 𝒉(t) = 𝜙ℎ([𝑾𝑥ℎ; 𝑾ℎℎ][
\begin{matrix}
𝒙(t) \
𝒉(t - 1)
\end{matrix}
] + 𝒃ℎ) ]
计算出当前时间步隐藏单元的激活值后,输出单元的激活值计算如下:
[ 𝒐(t) = 𝜙𝑜(𝑾ℎ𝑜𝒉(t) + 𝒃𝑜) ]
2.3 隐藏循环与输出循环
到目前为止,我们看到的 RNN 中,隐藏层具有循环特性。但还有一种替代模型,其循环连接来自输出层。在这种情况下,上一个时间步输出层的净激活值 𝒐𝑡 - 1 可以通过以下两种方式添加:
-
输出到隐藏循环
:添加到当前时间步的隐藏层 𝒉𝑡。
-
输出到输出循环
:添加到当前时间步的输出层 𝒐𝑡。
2.4 使用时间反向传播(BPTT)训练 RNN
RNN 的学习算法于 1990 年被提出。其基本思想是,总体损失 L 是从 t = 1 到 t = T 所有时间步的损失函数之和:
[ 𝐿 = \sum_{t = 1}^{T} 𝐿(t) ]
由于时间 t 的损失依赖于所有先前时间步(1 到 t)的隐藏单元,因此梯度的计算如下:
[ \frac{\partial 𝐿(t)}{\partial 𝑾ℎℎ} = \frac{\partial 𝐿(t)}{\partial 𝒐(t)} \times \frac{\partial 𝒐(t)}{\partial 𝒉(t)} \times (\sum_{k = 1}^{t} \frac{\partial 𝒉(t)}{\partial 𝒉(k)} \times \frac{\partial 𝒉(k)}{\partial 𝑾ℎℎ}) ]
其中,(\frac{\partial 𝒉(t)}{\partial 𝒉(k)}) 计算为相邻时间步的乘积:
[ \frac{\partial 𝒉(t)}{\partial 𝒉(k)} = \prod_{i = k + 1}^{t} \frac{\partial 𝒉(i)}{\partial 𝒉(i - 1)} ]
为了更好地理解 RNN 的前向传播过程,我们使用 TensorFlow Keras API 中的
SimpleRNN
层进行示例。以下是具体代码:
import tensorflow as tf
tf.random.set_seed(1)
rnn_layer = tf.keras.layers.SimpleRNN(
units=2, use_bias=True,
return_sequences=True)
rnn_layer.build(input_shape=(None, None, 5))
w_xh, w_oo, b_h = rnn_layer.weights
print('W_xh shape:', w_xh.shape)
print('W_oo shape:', w_oo.shape)
print('b_h shape:', b_h.shape)
x_seq = tf.convert_to_tensor(
[[1.0]*5, [2.0]*5, [3.0]*5],
dtype=tf.float32)
# 输出 of SimpleRNN:
output = rnn_layer(tf.reshape(x_seq, shape=(1, 3, 5)))
# 手动计算输出:
out_man = []
for t in range(len(x_seq)):
xt = tf.reshape(x_seq[t], (1, 5))
print('Time step {} =>'.format(t))
print(' Input :', xt.numpy())
ht = tf.matmul(xt, w_xh) + b_h
print(' Hidden :', ht.numpy())
if t>0:
prev_o = out_man[t-1]
else:
prev_o = tf.zeros(shape=(ht.shape))
ot = ht + tf.matmul(prev_o, w_oo)
ot = tf.math.tanh(ot)
out_man.append(ot)
print(' Output (manual) :', ot.numpy())
print(' SimpleRNN output:', output[0][t].numpy())
print()
在手动前向计算中,我们使用了双曲正切(tanh)激活函数,这也是
SimpleRNN
的默认激活函数。从打印结果可以看出,手动前向计算的输出与
SimpleRNN
层在每个时间步的输出完全匹配。
2.5 学习长距离交互的挑战
BPTT 算法在训练 RNN 时会引入一些新的挑战,主要是所谓的梯度消失和梯度爆炸问题。这是由于在计算损失函数的梯度时,存在乘法因子 (\frac{\partial 𝒉(t)}{\partial 𝒉(k)}),它包含 t - k 次乘法运算。如果循环边的权重 |w| < 1,当 t - k 很大时,这个因子会变得非常小;如果 |w| > 1,当 t - k 很大时,这个因子会变得非常大。
在实践中,有以下几种解决方案:
-
梯度裁剪
:为梯度指定一个截断或阈值,将超过该值的梯度值赋为该截断值。
-
截断时间反向传播(TBPTT)
:限制每次前向传播后信号可以反向传播的时间步数。例如,即使序列有 100 个元素或步骤,我们可能只反向传播最近的 20 个时间步。
-
长短期记忆网络(LSTM)
:由 Sepp Hochreiter 和 Jürgen Schmidhuber 于 1997 年设计,通过使用记忆单元,在处理长距离依赖时,在解决梯度消失和梯度爆炸问题上更为成功。
虽然梯度裁剪和 TBPTT 可以解决梯度爆炸问题,但截断会限制梯度有效回流和正确更新权重的步数。而 LSTM 在处理长距离依赖时表现更优,下面我们将详细讨论 LSTM。
3. 长短期记忆网络(LSTM)
LSTM 是为了解决 RNN 在处理长距离依赖时遇到的梯度消失和梯度爆炸问题而设计的。它通过引入记忆单元和门控机制,能够更好地捕捉序列中的长期信息。
3.1 LSTM 的结构
LSTM 单元主要由输入门($i_t$)、遗忘门($f_t$)、输出门($o_t$)和记忆单元($C_t$)组成。以下是各部分的作用:
-
遗忘门
:决定上一时刻的记忆单元 $C_{t - 1}$ 中有多少信息需要被遗忘。其计算公式为:
[ f_t = \sigma(W_f[h_{t - 1}, x_t] + b_f) ]
其中,$\sigma$ 是 sigmoid 函数,$W_f$ 是遗忘门的权重矩阵,$b_f$ 是偏置向量,$h_{t - 1}$ 是上一时刻的隐藏状态,$x_t$ 是当前时刻的输入。
-
输入门
:决定当前输入 $x_t$ 中有多少信息需要被添加到记忆单元中。计算公式为:
[ i_t = \sigma(W_i[h_{t - 1}, x_t] + b_i) ]
同时,会计算一个候选记忆单元 $\tilde{C}
t$:
[ \tilde{C}_t = \tanh(W_C[h
{t - 1}, x_t] + b_C) ]
-
更新记忆单元
:根据遗忘门和输入门的输出,更新当前时刻的记忆单元 $C_t$:
[ C_t = f_t \odot C_{t - 1} + i_t \odot \tilde{C}
t ]
其中,$\odot$ 表示逐元素相乘。
-
输出门
:决定当前记忆单元 $C_t$ 中有多少信息需要被输出到当前时刻的隐藏状态 $h_t$。计算公式为:
[ o_t = \sigma(W_o[h
{t - 1}, x_t] + b_o) ]
当前时刻的隐藏状态 $h_t$ 计算如下:
[ h_t = o_t \odot \tanh(C_t) ]
下面是 LSTM 单元的工作流程 mermaid 流程图:
graph LR
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([输入 x_t 和 h_{t - 1}]):::startend --> B(计算遗忘门 f_t):::process
A --> C(计算输入门 i_t):::process
A --> D(计算候选记忆单元 \tilde{C}_t):::process
B --> E(更新记忆单元 C_t):::process
C --> E
D --> E
A --> F(计算输出门 o_t):::process
E --> G(计算隐藏状态 h_t):::process
F --> G
G --> H([输出 h_t 和 C_t]):::startend
3.2 LSTM 的优势
LSTM 通过门控机制,能够选择性地遗忘和保留信息,从而有效地处理长距离依赖。与普通 RNN 相比,LSTM 在处理长序列时,能够更好地避免梯度消失和梯度爆炸问题,使得模型能够学习到更长期的依赖关系。
4. 截断时间反向传播(TBPTT)
TBPTT 是一种用于训练 RNN 的方法,旨在解决梯度消失和梯度爆炸问题。
4.1 TBPTT 的原理
TBPTT 的基本思想是限制每次前向传播后信号可以反向传播的时间步数。在标准的 BPTT 中,梯度会沿着整个序列进行反向传播,这可能导致梯度在长序列中变得不稳定。而 TBPTT 只允许梯度在有限的时间步内反向传播。
例如,对于一个长度为 $T$ 的序列,我们可以设置一个截断长度 $k$,在每次前向传播后,只对最近的 $k$ 个时间步进行反向传播。这样可以减少梯度在长序列中累积的影响,从而缓解梯度消失和梯度爆炸问题。
4.2 TBPTT 的实现步骤
以下是 TBPTT 的实现步骤列表:
1. 进行前向传播:从序列的起始位置开始,依次计算每个时间步的隐藏状态和输出。
2. 选择截断点:在序列中选择一个截断点,确定反向传播的时间步数。
3. 进行反向传播:从截断点开始,反向计算梯度,更新模型的参数。
4. 重复步骤 1 - 3:直到处理完整个序列。
5. 在 TensorFlow 中实现多层 RNN 进行序列建模
在 TensorFlow 中,我们可以方便地实现多层 RNN 进行序列建模。以下是一个简单的示例代码:
import tensorflow as tf
# 定义输入数据的形状
input_shape = (None, None, 5) # 批量大小、序列长度、特征维度
# 创建多层 RNN 模型
model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(units=10, return_sequences=True, input_shape=input_shape),
tf.keras.layers.SimpleRNN(units=20, return_sequences=True),
tf.keras.layers.Dense(1, activation='sigmoid')
])
# 编译模型
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
# 打印模型结构
model.summary()
在这个示例中,我们创建了一个包含两个
SimpleRNN
层和一个全连接层的多层 RNN 模型。第一个
SimpleRNN
层的
return_sequences
参数设置为
True
,表示该层会返回整个序列的输出,以便传递给下一个
SimpleRNN
层。最后一个全连接层使用 sigmoid 激活函数,用于二分类任务。
6. 项目实践
6.1 项目一:IMDb 电影评论数据集的 RNN 情感分析
在这个项目中,我们将使用 RNN 对 IMDb 电影评论数据集进行情感分析。以下是大致的步骤:
1.
数据预处理
:加载 IMDb 数据集,对文本进行分词、编码等处理。
2.
模型构建
:构建一个 RNN 模型,例如使用
SimpleRNN
或
LSTM
层。
3.
模型训练
:使用训练数据对模型进行训练。
4.
模型评估
:使用测试数据评估模型的性能。
以下是一个简化的代码示例:
import tensorflow as tf
from tensorflow.keras.datasets import imdb
from tensorflow.keras.preprocessing.sequence import pad_sequences
# 加载 IMDb 数据集
vocab_size = 10000
(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=vocab_size)
# 填充序列
max_length = 200
x_train = pad_sequences(x_train, maxlen=max_length)
x_test = pad_sequences(x_test, maxlen=max_length)
# 构建模型
model = tf.keras.Sequential([
tf.keras.layers.Embedding(input_dim=vocab_size, output_dim=100),
tf.keras.layers.SimpleRNN(units=32),
tf.keras.layers.Dense(1, activation='sigmoid')
])
# 编译模型
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
# 训练模型
model.fit(x_train, y_train, epochs=5, batch_size=64, validation_data=(x_test, y_test))
6.2 项目二:使用儒勒·凡尔纳《神秘岛》文本数据的 LSTM 字符级语言建模
在这个项目中,我们将使用 LSTM 进行字符级语言建模。以下是大致的步骤:
1.
数据预处理
:加载《神秘岛》的文本数据,将字符转换为数字编码。
2.
构建数据集
:将文本数据划分为输入序列和目标序列。
3.
模型构建
:构建一个 LSTM 模型。
4.
模型训练
:使用训练数据对模型进行训练。
5.
文本生成
:使用训练好的模型生成新的文本。
以下是一个简化的代码示例:
import tensorflow as tf
import numpy as np
# 加载文本数据
text = open('the_mysterious_island.txt', 'rb').read().decode(encoding='utf-8')
# 创建字符到索引的映射
vocab = sorted(set(text))
char2idx = {u: i for i, u in enumerate(vocab)}
idx2char = np.array(vocab)
# 将文本转换为数字编码
text_as_int = np.array([char2idx[c] for c in text])
# 创建训练数据集
seq_length = 100
examples_per_epoch = len(text) // (seq_length + 1)
char_dataset = tf.data.Dataset.from_tensor_slices(text_as_int)
sequences = char_dataset.batch(seq_length + 1, drop_remainder=True)
def split_input_target(chunk):
input_text = chunk[:-1]
target_text = chunk[1:]
return input_text, target_text
dataset = sequences.map(split_input_target)
# 构建模型
vocab_size = len(vocab)
embedding_dim = 256
rnn_units = 1024
model = tf.keras.Sequential([
tf.keras.layers.Embedding(vocab_size, embedding_dim),
tf.keras.layers.LSTM(rnn_units, return_sequences=True),
tf.keras.layers.Dense(vocab_size)
])
# 编译模型
model.compile(optimizer='adam', loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True))
# 训练模型
BATCH_SIZE = 64
BUFFER_SIZE = 10000
dataset = dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True)
model.fit(dataset, epochs=10)
# 文本生成
def generate_text(model, start_string):
num_generate = 1000
input_eval = [char2idx[s] for s in start_string]
input_eval = tf.expand_dims(input_eval, 0)
text_generated = []
temperature = 1.0
model.reset_states()
for i in range(num_generate):
predictions = model(input_eval)
predictions = tf.squeeze(predictions, 0)
predictions = predictions / temperature
predicted_id = tf.random.categorical(predictions, num_samples=1)[-1, 0].numpy()
input_eval = tf.expand_dims([predicted_id], 0)
text_generated.append(idx2char[predicted_id])
return (start_string + ''.join(text_generated))
print(generate_text(model, start_string=u"Captain Nemo"))
7. 使用梯度裁剪避免梯度爆炸
梯度裁剪是一种简单而有效的方法,用于避免梯度爆炸问题。
7.1 梯度裁剪的原理
梯度裁剪的基本思想是为梯度指定一个截断或阈值,当梯度的范数超过该阈值时,将梯度缩放到该阈值范围内。这样可以防止梯度在训练过程中变得过大,从而保证模型的稳定性。
7.2 梯度裁剪的实现
在 TensorFlow 中,可以通过在优化器中设置
clipvalue
或
clipnorm
参数来实现梯度裁剪。以下是一个示例代码:
import tensorflow as tf
# 构建模型
model = tf.keras.Sequential([
tf.keras.layers.Dense(64, activation='relu', input_shape=(10,)),
tf.keras.layers.Dense(1)
])
# 定义优化器并设置梯度裁剪
optimizer = tf.keras.optimizers.Adam(learning_rate=0.001, clipvalue=0.5)
# 编译模型
model.compile(optimizer=optimizer, loss='mse')
# 训练模型
x_train = tf.random.normal((100, 10))
y_train = tf.random.normal((100, 1))
model.fit(x_train, y_train, epochs=10)
在这个示例中,我们使用
Adam
优化器,并设置
clipvalue=0.5
,表示当梯度的绝对值超过 0.5 时,将其裁剪为 0.5。
8. 引入 Transformer 模型并理解自注意力机制
Transformer 模型是一种基于自注意力机制的深度学习模型,在自然语言处理领域取得了巨大的成功。
8.1 自注意力机制的原理
自注意力机制允许模型在处理序列时,关注序列中的不同位置,从而捕捉序列中的长距离依赖关系。具体来说,自注意力机制通过计算输入序列中每个位置与其他位置的相关性,为每个位置分配不同的权重,然后根据这些权重对输入序列进行加权求和,得到每个位置的表示。
以下是自注意力机制的计算步骤表格:
|步骤|描述|
|----|----|
|1|将输入序列 $X$ 分别乘以三个权重矩阵 $W_Q$、$W_K$ 和 $W_V$,得到查询矩阵 $Q$、键矩阵 $K$ 和值矩阵 $V$。|
|2|计算注意力分数:$scores = QK^T / \sqrt{d_k}$,其中 $d_k$ 是查询和键的维度。|
|3|对注意力分数进行 softmax 操作,得到注意力权重:$attention_weights = softmax(scores)$。|
|4|根据注意力权重对值矩阵进行加权求和,得到输出:$output = attention_weights \cdot V$。|
8.2 Transformer 模型的结构
Transformer 模型主要由编码器和解码器组成。编码器负责对输入序列进行编码,解码器负责根据编码器的输出生成目标序列。
编码器和解码器都由多个相同的层堆叠而成,每个层包含多头自注意力机制和前馈神经网络。多头自注意力机制允许模型在不同的表示子空间中关注输入序列,从而提高模型的表达能力。
Transformer 模型的结构 mermaid 流程图如下:
graph LR
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([输入序列]):::startend --> B(编码器):::process
B --> C(解码器):::process
C --> D([输出序列]):::startend
B1(多头自注意力机制):::process --> B2(前馈神经网络):::process
C1(多头自注意力机制):::process --> C2(编码器 - 解码器注意力机制):::process
C2 --> C3(前馈神经网络):::process
subgraph 编码器层
B1
B2
end
subgraph 解码器层
C1
C2
C3
end
通过引入 Transformer 模型和自注意力机制,我们可以更有效地处理序列数据,尤其是在处理长序列时,能够取得更好的性能。
超级会员免费看
9422

被折叠的 条评论
为什么被折叠?



