Run this notebook online: or Colab:
8.5. 递归神经网络从头开始的实现¶
在本节中,我们将根据 Section 8.4中的描述, 从头开始基于循环神经网络实现字符级语言模型。 这样的模型将在H.G.威尔斯的时光机器数据集上训练。 和前面 Section 8.3中介绍过的一样, 我们先读取数据集。
%load ../utils/djl-imports
%load ../utils/plot-utils
%load ../utils/Functions.java
%load ../utils/PlotUtils.java
%load ../utils/StopWatch.java
%load ../utils/Accumulator.java
%load ../utils/Animator.java
%load ../utils/Training.java
%load ../utils/timemachine/Vocab.java
%load ../utils/timemachine/RNNModelScratch.java
%load ../utils/timemachine/TimeMachine.java
%load ../utils/timemachine/SeqDataLoader.java
@FunctionalInterface
public interface TriFunction<T, U, V, W> {
public W apply(T t, U u, V v);
}
@FunctionalInterface
public interface QuadFunction<T, U, V, W, R> {
public R apply(T t, U u, V v, W w);
}
@FunctionalInterface
public interface SimpleFunction<T> {
public T apply();
}
@FunctionalInterface
public interface voidFunction<T> {
public void apply(T t);
}
@FunctionalInterface
public interface voidTwoFunction<T, U> {
public void apply(T t, U u);
}
NDManager manager = NDManager.newBaseManager();
int batchSize = 32;
int numSteps = 35;
Pair<List<NDList>, Vocab> timeMachine = SeqDataLoader.loadDataTimeMachine(batchSize, numSteps, false, 10000, manager);
List<NDList> trainIter = timeMachine.getKey();
Vocab vocab = timeMachine.getValue();
8.5.1. 独热编码¶
回想一下,每个标记在 trainIter
. 中都表示为一个数字索引。
将这些指数直接输入神经网络可能会使其难以识别 学习
我们通常将每个标记表示为更具表现力的特征向量。 最简单的表示法称为
(one-hot encoding), 介绍了 in
Section 3.4.1.
简言之,我们将每个索引映射到一个不同的单位向量:假设词汇表中不同标记的数量为
\(N\) (vocab.length()
) ,标记索引的范围为0到 \(N-1\)。
如果token的索引是整数 \(i\),那么我们创建一个长度为 \(N\)
的所有0的向量,并将元素的位置 \(i\) 设置为1。
此向量是原始token的一个热向量。索引为0和2的一个独热向量如下所示。
manager.create(new int[] {0, 2}).oneHot(vocab.length())
ND: (2, 29) gpu(0) float32
[[1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 9 more],
[0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 9 more],
]
我们每次采样的小批量的形状是(批量大小、时间步数)。 oneHot
函数将这样一个小批量转换为三维数据数组,最后一个维度等于词汇表大小(vocab.length()
).
我们经常转换输入,以便获得一个 形状输出
(时间步数、批次大小、词汇表大小)。 这将使我们 更方便
循环通过最外层维度 用于更新小批量的隐藏状态。
NDArray X = manager.arange(10).reshape(new Shape(2,5));
X.transpose().oneHot(28).getShape()
(5, 2, 28)
8.5.2. 初始化模型参数¶
接下来,我们初始化的模型参数 循环神经网络模型。 隐藏单元数
numHiddens
是一个可调超参数。 在培训语言模型时,
输入和输出来自同一词汇表。 因此,它们具有相同的维度, 这等于词汇量。
public static NDList getParams(int vocabSize, int numHiddens, Device device) {
int numOutputs = vocabSize;
int numInputs = vocabSize;
// 隐藏层参数
NDArray W_xh = normal(new Shape(numInputs, numHiddens), device);
NDArray W_hh = normal(new Shape(numHiddens, numHiddens), device);
NDArray b_h = manager.zeros(new Shape(numHiddens), DataType.FLOAT32, device);
// 输出层参数
NDArray W_hq = normal(new Shape(numHiddens, numOutputs), device);
NDArray b_q = manager.zeros(new Shape(numOutputs), DataType.FLOAT32, device);
// 加上梯度
NDList params = new NDList(W_xh, W_hh, b_h, W_hq, b_q);
for (NDArray param : params) {
param.setRequiresGradient(true);
}
return params;
}
public static NDArray normal(Shape shape, Device device) {
return manager.randomNormal(0f, 0.01f, shape, DataType.FLOAT32, device);
}
8.5.3. 循环神经网络模型¶
要定义循环神经网络模型, 我们首先需要一个 initRNNState
‘函数
在初始化时返回隐藏状态。
它返回一个填充为0且形状为(批量大小、隐藏单元数)的数据数组。
public static NDList initRNNState(int batchSize, int numHiddens, Device device) {
return new NDList(manager.zeros(new Shape(batchSize, numHiddens), DataType.FLOAT32, device));
}
下面的 rnn
函数定义了如何计算隐藏状态和输出 在一个时间步。 注意
循环神经网络模型 循环通过inputs
的最外层维度
这样它就可以更新小批量的隐藏状态 H
, 此外 这里的激活函数使用
\(\tanh\) 函数。 像 描述于 Section 4.1, 该
当元素均匀分布时, \(\tanh\) 函数的平均值为0 分布在实数上。
public static Pair<NDArray, NDList> rnn(NDArray inputs, NDList state, NDList params) {
// 输入的形状:(`numSteps`、`batchSize`、`vocabSize`)
NDArray W_xh = params.get(0);
NDArray W_hh = params.get(1);
NDArray b_h = params.get(2);
NDArray W_hq = params.get(3);
NDArray b_q = params.get(4);
NDArray H = state.get(0);
NDList outputs = new NDList();
// 'X'的形状:('batchSize','vocabSize`)
NDArray X, Y;
for (int i = 0; i < inputs.size(0); i++) {
X = inputs.get(i);
H = (X.dot(W_xh).add(H.dot(W_hh)).add(b_h)).tanh();
Y = H.dot(W_hq).add(b_q);
outputs.add(Y);
}
return new Pair<>(outputs.size() > 1 ? NDArrays.concat(outputs) : outputs.get(0), new NDList(H));
}
定义了所有需要的功能, 接下来,我们创建一个类来包装这些函数,并存储从头实现的循环神经网络模型的参数。
/** 从头开始实现的RNN模型 */
public class RNNModelScratch {
public int vocabSize;
public int numHiddens;
public NDList params;
public TriFunction<Integer, Integer, Device, NDList> initState;
public TriFunction<NDArray, NDList, NDList, Pair> forwardFn;
public RNNModelScratch(
int vocabSize,
int numHiddens,
Device device,
TriFunction<Integer, Integer, Device, NDList> getParams,
TriFunction<Integer, Integer, Device, NDList> initRNNState,
TriFunction<NDArray, NDList, NDList, Pair> forwardFn) {
this.vocabSize = vocabSize;
this.numHiddens = numHiddens;
this.params = getParams.apply(vocabSize, numHiddens, device);
this.initState = initRNNState;
this.forwardFn = forwardFn;
}
public Pair forward(NDArray X, NDList state) {
X = X.transpose().oneHot(this.vocabSize);
return this.forwardFn.apply(X, state, this.params);
}
public NDList beginState(int batchSize, Device device) {
return this.initState.apply(batchSize, this.numHiddens, device);
}
}
让我们检查输出是否具有正确的形状,例如,以确保隐藏状态的维度保持不变。
int numHiddens = 512;
TriFunction<Integer, Integer, Device, NDList> getParamsFn = (a, b, c) -> getParams(a, b, c);
TriFunction<Integer, Integer, Device, NDList> initRNNStateFn =
(a, b, c) -> initRNNState(a, b, c);
TriFunction<NDArray, NDList, NDList, Pair> rnnFn = (a, b, c) -> rnn(a, b, c);
NDArray X = manager.arange(10).reshape(new Shape(2, 5));
Device device = manager.getDevice();
RNNModelScratch net =
new RNNModelScratch(
vocab.length(), numHiddens, device, getParamsFn, initRNNStateFn, rnnFn);
NDList state = net.beginState((int) X.getShape().getShape()[0], device);
Pair<NDArray, NDList> pairResult = net.forward(X.toDevice(device, false), state);
NDArray Y = pairResult.getKey();
NDList newState = pairResult.getValue();
System.out.println(Y.getShape());
System.out.println(newState.get(0).getShape());
(10, 29)
(2, 512)
我们可以看到输出形状是(时间步数 \(\times\) batch大小,词汇表大小),而隐藏状态形状保持不变,即(批大小,隐藏单元数)。
8.5.4. 预测¶
让我们首先定义预测函数来生成prefix
之后的新字符,
其中的prefix
是一个用户提供的包含多个字符的字符串。
在循环遍历prefix
中的开始字符时,
我们不断地将隐状态传递到下一个时间步,但是不生成任何输出。
这被称为预热(warm-up)期,
因为在此期间模型会自我更新(例如,更新隐状态), 但不会进行预测。
预热期结束后,隐状态的值通常比刚开始的初始值更适合预测,
从而预测字符并输出它们。
/** 在 `prefix` 后面生成新字符。 */
public static String predictCh8(
String prefix, int numPreds, RNNModelScratch net, Vocab vocab, Device device) {
NDList state = net.beginState(1, device);
List<Integer> outputs = new ArrayList<>();
outputs.add(vocab.getIdx("" + prefix.charAt(0)));
SimpleFunction<NDArray> getInput =
() ->
manager.create(outputs.get(outputs.size() - 1))
.toDevice(device, false)
.reshape(new Shape(1, 1));
for (char c : prefix.substring(1).toCharArray()) { // 预热期
state = (NDList) net.forward(getInput.apply(), state).getValue();
outputs.add(vocab.getIdx("" + c));
}
NDArray y;
for (int i = 0; i < numPreds; i++) {
Pair<NDArray, NDList> pair = net.forward(getInput.apply(), state);
y = pair.getKey();
state = pair.getValue();
outputs.add((int) y.argMax(1).reshape(new Shape(1)).getLong(0L));
}
StringBuilder output = new StringBuilder();
for (int i : outputs) {
output.append(vocab.idxToToken.get(i));
}
return output.toString();
}
现在我们可以测试 predict_ch8
函数。 我们将前缀指定为
time traveller
,并让它生成10个附加字符。
鉴于我们没有对网络进行培训, 它将产生荒谬的预测。
predictCh8("time traveller ", 10, net, vocab, manager.getDevice());
time traveller ks<unk>s<unk>s<unk>s<unk>s
8.5.5. 梯度裁剪¶
对于长度为 \(T\) 的序列, 我们在一次迭代中计算这些 \(T\) 时间步上的梯度,这导致在反向传播期间产生长度为 \(\mathcal{O}(T)\) 的矩阵乘积链。 如 Section 4.8, 中所述,它可能导致数值不稳定,例如,当 \(T\) 较大时,梯度可能会爆炸或消失。因此,RNN模型通常需要额外的帮助来稳定训练。
一般来说, 在解决优化问题时, 我们对模型参数采取更新步骤, 以向量形式说 \(\mathbf{x}\), 在小批量上的负梯度方向 \(\mathbf{g}\) 例如, 以 \(\eta > 0\) 作为学习率, 在一次迭代中,我们更新 \(\mathbf{x}\) 作为\(\mathbf{x} - \eta \mathbf{g}\). 让我们进一步假设目标函数 \(f\) 行为良好,例如, Lipschitz continuous ,常数为 \(L\). 就是说, 对于任何 \(\mathbf{x}\) 和 \(\mathbf{y}\) 我们都有
在这种情况下,我们可以安全地假设,如果我们将参数向量更新为 \(\eta \mathbf{g}\), 那么
也就是说 我们观察到的变化不会超过 \(L \eta \|\mathbf{g}\|\)。这既是坏事也是好事。 在坏事方面, 它限制了进步的速度; 而在好事方面, 它限制了如果我们朝着错误的方向前进,事情可能会出错的程度。
有时梯度可能相当大,优化算法可能无法收敛。我们可以通过降低学习率 \(\eta\). 但是如果我们很少 得到大的梯度呢?在这种情况下,这种做法可能显得毫无根据。一种流行的替代方法是通过将梯度 \(\mathbf{g}\) 投影回给定半径的球,例如 \(\theta\) 来剪裁梯度 \(\mathbf{g}\)
通过这样做,我们知道梯度范数永远不会超过 \(\theta\) ,并且 更新的梯度与 \(\mathbf{g}\) 的原始方向完全对齐。 它还具有限制任何给定影响的理想副作用 minibatch(以及其中的任何给定样本)可以应用于参数向量。这 赋予模型一定程度的鲁棒性。渐变剪裁提供 快速修复渐变爆炸。虽然它不能完全解决问题,但它是缓解问题的众多技术之一。
下面我们定义一个函数来剪裁 从头开始实现的模型或由高级API构建的模型。 还要注意,我们计算了所有模型参数的梯度范数。
/** 修剪梯度 */
public static void gradClipping(RNNModelScratch net, int theta, NDManager manager) {
double result = 0;
for (NDArray p : net.params) {
NDArray gradient = p.getGradient();
gradient.attach(manager);
result += gradient.pow(2).sum().getFloat();
}
double norm = Math.sqrt(result);
if (norm > theta) {
for (NDArray param : net.params) {
NDArray gradient = param.getGradient();
gradient.muli(theta / norm);
}
}
}
8.5.6. 训练¶
在训练模特之前, 让我们定义一个函数来在一个历元中训练模型。它与我们在三个地方训练: Section 3.6 模型的方式不同:
顺序数据的不同采样方法(随机采样和顺序分区)将导致隐藏状态初始化的差异。
在更新模型参数之前,我们剪裁梯度。这确保了模型不会发散,即使在训练过程中的某个点上坡度增大。
我们使用困惑度来评估模型。如 Section 8.4.4 中所述,这确保了不同长度的序列具有可比性。
明确地 当使用顺序分区时,我们仅在每个历元开始时初始化隐藏状态。 由于下一个minibatch中的 \(i^\mathrm{th}\) 子序列示例与当前的 \(i^\mathrm{th}\) 子序列示例相邻, 当前批处理结束时的隐藏状态 将 用于初始化 下一个迷你批处理开始时的隐藏状态。 这样, 序列的历史信息 以隐藏状态存储 可能溢出 一个epoch内相邻的子序列。 然而,隐藏状态的计算 任何时候都取决于以前的所有小批量 在同一epoch, 这使得梯度计算复杂化。 为了降低计算成本, 我们在处理任何小批量之前分离梯度 使隐态的梯度计算 始终限于 一个小批量中的时间步长。
在使用随机抽样时,
我们需要为每个迭代重新初始化隐藏状态,因为每个示例都是使用随机位置采样的。
与 Section 3.6 中的trainepoch3
函数相同,
updater
是一个通用函数 以更新模型参数。
它可以是从头开始实现的函数,也可以是中的内置优化函数 深度学习框架。
/** 在一个opoch内训练一个模型。 */
public static Pair<Double, Double> trainEpochCh8(
RNNModelScratch net,
List<NDList> trainIter,
Loss loss,
voidTwoFunction<Integer, NDManager> updater,
Device device,
boolean useRandomIter) {
StopWatch watch = new StopWatch();
watch.start();
Accumulator metric = new Accumulator(2); // 训练损失总数
try (NDManager childManager = manager.newSubManager()) {
NDList state = null;
for (NDList pair : trainIter) {
NDArray X = pair.get(0).toDevice(device, true);
X.attach(childManager);
NDArray Y = pair.get(1).toDevice(device, true);
Y.attach(childManager);
if (state == null || useRandomIter) {
// 在第一次迭代或
// 使用随机取样
state = net.beginState((int) X.getShape().getShape()[0], device);
} else {
for (NDArray s : state) {
s.stopGradient();
}
}
state.attach(childManager);
NDArray y = Y.transpose().reshape(new Shape(-1));
X = X.toDevice(device, false);
y = y.toDevice(device, false);
try (GradientCollector gc = manager.getEngine().newGradientCollector()) {
Pair<NDArray, NDList> pairResult = net.forward(X, state);
NDArray yHat = pairResult.getKey();
state = pairResult.getValue();
NDArray l = loss.evaluate(new NDList(y), new NDList(yHat)).mean();
gc.backward(l);
metric.add(new float[] {l.getFloat() * y.size(), y.size()});
}
gradClipping(net, 1, childManager);
updater.apply(1, childManager); // 因为已经调用了“mean”函数
}
}
return new Pair<>(Math.exp(metric.get(0) / metric.get(1)), metric.get(1) / watch.stop());
}
训练功能支持 实现了一个RNN模型 要么从头开始 或者使用高级API。
/** 训练一个模型 */
public static void trainCh8(
RNNModelScratch net,
List<NDList> trainIter,
Vocab vocab,
int lr,
int numEpochs,
Device device,
boolean useRandomIter) {
SoftmaxCrossEntropyLoss loss = new SoftmaxCrossEntropyLoss();
Animator animator = new Animator();
// 初始化
voidTwoFunction<Integer, NDManager> updater =
(batchSize, subManager) -> Training.sgd(net.params, lr, batchSize, subManager);
Function<String, String> predict = (prefix) -> predictCh8(prefix, 50, net, vocab, device);
// 训练和推理
double ppl = 0.0;
double speed = 0.0;
for (int epoch = 0; epoch < numEpochs; epoch++) {
Pair<Double, Double> pair =
trainEpochCh8(net, trainIter, loss, updater, device, useRandomIter);
ppl = pair.getKey();
speed = pair.getValue();
if ((epoch + 1) % 10 == 0) {
animator.add(epoch + 1, (float) ppl, "");
animator.show();
}
}
System.out.format(
"perplexity: %.1f, %.1f tokens/sec on %s%n", ppl, speed, device.toString());
System.out.println(predict.apply("time traveller"));
System.out.println(predict.apply("traveller"));
}
现在我们可以训练循环神经网络模型。 因为我们在数据集中只使用10000个标记,所以模型需要更多的时间来更好地收敛
int numEpochs = Integer.getInteger("MAX_EPOCH", 500);
int lr = 1;
trainCh8(net, trainIter, vocab, lr, numEpochs, manager.getDevice(), false);
perplexity: 1.0, 42283.1 tokens/sec on gpu(0)
time traveller but now you begin to seethe object of my investig
traveller chour and simet burateer gat omey l bat of pracie
最后 让我们检查一下使用随机抽样方法的结果。
trainCh8(net, trainIter, vocab, lr, numEpochs, manager.getDevice(), true);
perplexity: 1.1, 41992.6 tokens/sec on gpu(0)
time traveller held in his hand was o caing abogitt to veeettor
traveller suicu and nst doun now to ssiage op s ais anl peg
从零开始实现上述循环神经网络模型, 虽然有指导意义,但是并不方便。 在下一节中,我们将学习如何改进循环神经网络模型。 例如,如何使其实现地更容易,且运行速度更快。
8.5.7. 总结¶
我们可以训练一个基于循环神经网络的字符级语言模型,根据用户提供的文本的前缀生成后续文本。
一个简单的循环神经网络语言模型包括输入编码、循环神经网络模型和输出生成。
循环神经网络模型在训练以前需要初始化状态,不过随机抽样和顺序划分使用初始化方法不同。
当使用顺序划分时,我们需要分离梯度以减少计算量。
在进行任何预测之前,模型通过预热期进行自我更新(例如,获得比初始值更好的隐状态)。
梯度裁剪可以防止梯度爆炸,但不能应对梯度消失。
8.5.8. 练习¶
显示一个热编码相当于为每个对象选择不同的嵌入。
调整超参数(例如,epoch数、隐藏单元数、小批量时间步数和学习速率)以改善困惑。
你能降到多低?
用可学习的嵌入替换一个热编码。这会导致更好的性能吗?
它在H.G.威尔斯的其他书籍上的效果如何,例如 *世界大战*?
修改预测函数,例如使用采样,而不是拾取最可能的下一个字符。
会发生什么?
使模型偏向更可能的输出,例如,通过从 \(q(x_t \mid x_{t-1}, \ldots, x_1) \propto P(x_t \mid x_{t-1}, \ldots, x_1)^\alpha\) for \(\alpha > 1\)进行采样。
在不剪切梯度的情况下运行本节中的代码。会发生什么?
更改顺序分区,使其不会从计算图中分离隐藏状态。运行时间有变化吗?那么困惑呢?
用ReLU替换本节中使用的激活功能,并重复本节中的实验。我们还需要梯度剪裁吗?为什么?