Abril de 2018
Volumen 33, número 4
Serie de pruebas: introducción a las celdas de LSTM mediante C#
Por James McCaffrey
Una celda de memoria a corto plazo duradera (LSTM) es un pequeño componente de software que se puede usar para crear una red neuronal recurrente que puede hacer predicciones en relación con secuencias de datos. Las redes de LSTM han permitido grandes avances en varias áreas del aprendizaje automático. En este artículo, mostraré cómo implementar una celda de LSTM mediante C#. Aunque es improbable que alguna vez necesite crear una red neuronal recurrente desde cero, comprender el funcionamiento exacto de las celdas de LSTM le ayudará en caso de que necesite crear una red de LSTM con una biblioteca de código como Microsoft CNTK o Google TensorFlow.
La captura de pantalla de la Figura 1 muestra el propósito de este artículo. El programa de demostración crea una celda de LSTM que acepta un vector de entrada de tamaño n = 2, y genera un vector de salida explícito de tamaño m = 3 y un vector de estado de celda de tamaño m = 3. Una característica clave de las celdas de LSTM es que mantienen un estado.
Figura 1 Demostración de entrada-salida de celda de LSTM
Una celda de LSTM tiene (4 x n x m) + (4 x m x m) pesos y (4 x m) sesgos. Los pesos y los sesgos son constantes, con valores como 0,1234, que definen el comportamiento de la celda de LSTM. La demostración tiene 60 pesos y 12 sesgos definidos con valores arbitrarios.
La demostración envía una entrada (1.0, 2.0) a la celda de LSTM. La salida calculada es (0.0629, 0.0878, 0.1143) y el nuevo estado de celda es (0.1143, 0.1554, 0.1973). En breve explicaré qué pueden representar estos valores numéricos, pero, por ahora, lo importante es que una celda de LSTM acepta entradas, produce salidas y actualiza su estado. Los valores de los pesos y los sesgos no han cambiado.
A continuación, la demostración envía (3.0, 4.0) a la celda de LSTM. El nuevo resultado calculado es (0.1282, 0.2066, 0.2883). El nuevo estado de celda es (0.2278, 0.3523, 0.4789). Aunque no es evidente, el nuevo valor de estado de celda contiene información sobre todos los valores de entrada y salida anteriores. Esta es la parte “duradera” de la memoria a corto plazo duradera.
En este artículo se supone que tiene conocimientos intermedios o altos de programación, pero no que no sepa nada acerca de las celdas de LSTM. Esta demostración se ha programado con C#, pero, si quiere, debería poder refactorizar el código en otro lenguaje, como Python o Visual Basic. El código de demostración es demasiado largo para presentarlo por completo, pero está disponible en la descarga que se incluye con este artículo.
Presentación de las celdas de LSTM
Existen tres formas de describir las celdas de LSTM: con un diagrama de arquitectura, como se muestra en la Figura 2; con ecuaciones matemáticas, como se muestra en la Figura 3; y mediante código, como el que se presenta en este artículo. La impresión inicial del diagrama de arquitectura de la Figura 2 es, probablemente, del tipo "¿Qué es esto?" Las ideas clave son que una celda de LSTM acepta un vector de entrada x(t), donde la t representa el tiempo. El resultado explícito es h(t). La utilización inusual de h (en lugar de o) para representar el resultado es histórica y se explica con el hecho de que los sistemas neuronales se solían describir como funciones g y h.
La celda de LSTM también usa h(t-1) y c(t-1) como entradas. Aquí, t-1 hace referencia al paso de tiempo anterior. La c representa el estado de celda, de modo que h(t-1) y c(t-1) son los valores de salida y de estado anteriores. El interior de la arquitectura de la celda de LSTM parece complicado. Y lo es, pero no tanto como podría pensar.
Las ecuaciones matemáticas de la Figura 3 definen el comportamiento de la celda de LSTM del programa de demostración. Si no suele trabajar con definiciones matemáticas, probablemente, su reacción a la Figura 3 sea, de nuevo "¿Qué es esto?". Las ecuaciones (1), (2) y (3) definen tres puertas: una puerta de olvido, una de entrada y una de salida. Cada puerta es un vector de valores entre 0,0 y 1,0, que se usan para determinar cuánta información se olvida (o, equivalentemente, se recuerda) en cada ciclo de entrada-salida. La ecuación (4) calcula el nuevo estado de celda, mientras que la ecuación (5) calcula el nuevo resultado.
Figura 2 Arquitectura de celda de LSTM
Figura 3 Ecuaciones matemáticas de celda de LSTM
Estas ecuaciones son más sencillas de lo que parece. Por ejemplo, el código de demostración implementa la ecuación (1) como:
float[][] ft = MatSig(MatSum(MatProd(Wf, xt),
MatProd(Uf, h_prev), bf));
Aquí, MatSig es una función definida por un programa que aplica la función sigmoide logística a cada valor de una matriz. MatSum agrega tres matrices. MatProd multiplica dos matrices. Una vez que comprende la matriz básica y las operaciones de vector, implementar una celda de LSTM es bastante fácil.
Estructura general del programa de demostración
La estructura del programa de demostración, con algunos cambios menores para ahorrar espacio, se presenta en la Figura 4. La demostración usa un enfoque de método estático, en lugar de un enfoque de OOP para que las ideas principales estén tan claras como sea posible.
Figura 4 Estructura del programa de demostración
using System;
namespace LSTM_IO
{
class LSTM_IO_Program
{
static void Main(string[] args)
{
Console.WriteLine("Begin LSTM IO demo");
// Set up inputs
// Set up weights and biases
float[][] ht, ct; // Outputs, new state
float[][][] result;
result = ComputeOutputs(xt, h_prev, c_prev,
Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc, bf, bi, bo, bc);
ht = result[0]; // Outputs
ct = result[1]; // New state
Console.WriteLine("Output is:");
MatPrint(ht, 4, true);
Console.WriteLine("New cell state is:");
MatPrint(ct, 4, true);
// Set up new inputs
// Call ComputeOutputs again
Console.WriteLine("End LSTM demo");
}
static float[][][] ComputeOutputs(float[][] xt,
float[][] h_prev, float[][] c_prev,
float[][] Wf, float[][] Wi, float[][] Wo, float[][] Wc,
float[][] Uf, float[][] Ui, float[][] Uo, float[][] Uc,
float[][] bf, float[][] bi, float[][] bo, float[][] bc)
{ . . }
// Helper matrix functions defined here
}
}
Para crear la demostración, inicié Visual Studio, creé una nueva aplicación de consola de C# y la llamé LSTM_IO. Utilicé Visual Studio 2015, pero la demostración no tiene dependencias significativas de .NET Framework, por lo que cualquier versión de Visual Studio funcionará bien.
Cuando se cargó el código de la plantilla, en la ventana del Explorador de soluciones cambié el nombre del archivo Program.cs por LSTM_IO_Program.cs y permití que Visual Studio cambiara el nombre de la clase Program automáticamente. En la parte superior de la ventana del editor, eliminé todas las referencias innecesarias a espacios de nombres y dejé únicamente la referencia al espacio de nombres de nivel superior System. La función ComputeOutputs hace todo el trabajo.
Matrices que usan C#
Para poder implementar una celda de LSTM, debe saber trabajar con matrices de C#. En C#, una matriz es una matriz de matrices. En aprendizaje automático, es habitual usar un tipo flotante de 32 bits, en lugar de un tipo de doble byte de 64 bits.
La demostración define una aplicación auxiliar para crear una matriz como se indica a continuación:
static float[][] MatCreate(int rows, int cols)
{
float[][] result = new float[rows][];
for (int i = 0; i < rows; ++i)
result[i] = new float[cols];
return result;
}
La primera instrucción crea una matriz con el número de filas especificado, donde cada fila es una matriz de tipo flotante. La instrucción de bucle for asigna cada fila como matriz con el número de columnas especificado. Tenga en cuenta que, al contrario de lo que sucede con la mayoría de lenguajes de programación, C# admite un tipo de matriz true, pero el enfoque de matriz de matrices es mucho más común.
En aprendizaje automático, el término vector de columna, o vector para abreviar, se refiere a una matriz con una columna. La mayor parte del código de aprendizaje automático funciona con vectores, en lugar de con matrices unidimensionales. La demostración define una función para generar una matriz/vector desde una matriz unidimensional:
static float[][] MatFromArray(float[] arr, int rows, int cols)
{
float[][] result = MatCreate(rows, cols);
int k = 0;
for (int i = 0; i < rows; ++i)
for (int j = 0; j < cols; ++j)
result[i][j] = arr[k++];
return result;
}
La función se puede invocar para crear un vector 3 x 1 (3 filas, 1 columna) como el siguiente:
float[][] v = MatFromArray(new float[] {1.0f, 9.0f, 5.0f}, 3,1);
La demostración define una función MatCopy que puede duplicar una matriz. Se puede llamar a MatCopy:
float[][] B = MatCopy(A);
Aquí, B es una matriz nueva e independiente con los mismos valores que A. Tenga cuidado con este código:
float[][] B = A;
Esto crea B como referencia de A, de modo que cualquier cambio realizado en una matriz afecta a la otra. Puede que quiera que se comporten así, pero, probablemente, no.
Operaciones basadas en elemento de matriz
Una implementación de celda de LSTM utiliza varias funciones basadas en elemento en matrices, donde cada valor de la matriz se usa o modifica. Por ejemplo, se define la función MatTanh:
static float[][] MatTanh(float[][] m)
{
int rows = m.Length; int cols = m[0].Length;
float[][] result = MatCreate(rows, cols);
for (int i = 0; i < rows; ++i) // Each row
for (int j = 0; j < cols; ++j) // Each col
result[i][j] = Tanh(m[i][j]);
return result;
}
La función recorre su matriz de entrada m y aplica la tangente hiperbólica (tanh) a cada valor. Se define la función auxiliar Tanh:
static float Tanh(float x)
{
if (x < -10.0) return -1.0f;
else if (x > 10.0) return 1.0f;
return (float)(Math.Tanh(x));
}
La demostración también define una función MatSigmoid exactamente igual que MatTanh, excepto en que se aplica la función sigmoide logística a cada valor. La función sigmoide logística está estrechamente relacionada con tanh y devuelve un valor entre 0.0 y 1.0, en lugar de entre -1.0 y +1.0.
La demostración define una función MatSum que agrega los valores en dos matrices de la misma forma. Si observa la ecuación matemática (1) de la Figura 3, verá que una LSTM agrega tres matrices. La demostración sobrecarga MatSum para que funcione con dos o tres matrices.
La función MatHada multiplica los valores correspondientes en dos matrices que tienen la misma forma:
static float[][] MatHada(float[][] a, float[][] b)
{
int rows = a.Length; int cols = a[0].Length;
float[][] result = MatCreate(rows, cols);
for (int i = 0; i < rows; ++i)
for (int j = 0; j < cols; ++j)
result[i][j] = a[i][j] * b[i][j];
return result;
}
A veces, la multiplicación basada en elemento se llama función Hadamard. En las ecuaciones matemáticas (4) y (5) de la Figura 3, la función Hadamard se indica con el símbolo de punto abierto. No se debe confundir con la función Hadamard basada en elemento con multiplicación de matriz, que es una función muy distinta.
Multiplicación de matrices
Si nunca ha visto una multiplicación de matrices, la operación no resulta nada fácil. Imagine que A es una matriz de 3 x 2:
1.0, 2.0
3.0, 4.0
5.0, 6.0
Y que B es una matriz de 2 x 4:
10.0, 11.0, 12.0, 13.0
14.0, 15.0, 16.0, 17.0
El resultado de C = AB (multiplicar A y B) es una matriz de 3 x 4:
38.0 41.0 44.0 47.0
86.0 93.0 100.0 107.0
134.0 145.0 156.0 167.0
La demostración implementa la multiplicación de matrices como función MatProd. Tenga en cuenta que, si usa C#, para matrices muy grandes, puede usar la instrucción Parallel.For de la biblioteca TPL.
En resumen, para implementar una celda de LSTM mediante C#, se requieren varias funciones auxiliares que creen matrices (matrices de matrices) y vectores (matrices de una columna) y trabajen con ellos. El código de demostración define las funciones MatCreate, MatFromArray, MatCopy(m), MatSig(m), MatTanh(m), MatHada(a, b), MatSum(a, b), MatSum(a, b, c) y MatProd(a, b). Aunque no es imprescindible para crear una celda LSTM, resulta útil tener una función para mostrar una matriz C#. La demostración define una función MatPrint.
Implementación e invocación de entrada/salida de LSTM
El código para la función ComputeOutputs se presenta en la Figura 5. La función tiene 15 parámetros, pero 12 de ellos son, básicamente, lo mismo.
Figura 5 Función ComputeOutputs
static float[][][] ComputeOutputs(float[][] xt,
float[][] h_prev, float[][] c_prev,
float[][] Wf, float[][] Wi, float[][] Wo, float[][] Wc,
float[][] Uf, float[][] Ui, float[][] Uo, float[][] Uc,
float[][] bf, float[][] bi, float[][] bo, float[][] bc)
{
float[][] ft = MatSig(MatSum(MatProd(Wf, xt),
MatProd(Uf, h_prev), bf));
float[][] it = MatSig(MatSum(MatProd(Wi, xt),
MatProd(Ui, h_prev), bi));
float[][] ot = MatSig(MatSum(MatProd(Wo, xt),
MatProd(Uo, h_prev), bo));
float[][] ct = MatSum(MatHada(ft, c_prev),
MatHada(it, MatTanh(MatSum(MatProd(Wc, xt),
MatProd(Uc, h_prev), bc))));
float[][] ht = MatHada(ot, MatTanh(ct));
float[][][] result = new float[2][][];
result[0] = MatCopy(ht);
result[1] = MatCopy(ct);
return result;
}
El vector xt es la entrada; por ejemplo, (1.0, 2.0). Los vectores h_prev y c_prev son el vector de salida anterior y el estado de celda anterior. Las cuatro matrices W son los pesos de puerta asociados con los valores de entrada, donde f es una puerta de olvido, i es una puerta de entrada y o es una puerta de salida. Las cuatro matrices U son los pesos asociados con la salida de celda. Los cuatro vectores b son sesgos.
En el cuerpo de la función, las cinco primeras instrucciones son una asignación de uno a uno de las cinco ecuaciones matemáticas de la Figura 3. Tenga en cuenta que las puertas ft, it y ot usan la función MatSig. Por lo tanto, las tres son vectores con valores entre 0.0 y 1.0. Puede considerarlos filtros que se aplican a la entrada, salida o estado, mientras que el valor de la puerta es el porcentaje que se conserva. Por ejemplo, si uno de los valores de ft es 0.75, se conserva el 75 por ciento del valor correspondiente en el vector de entrada y salida anterior combinados. En otras palabras, se olvida el 25 por ciento de la información.
El cálculo del nuevo estado de celda, ct, es fácil de implementar, pero bastante profundo, conceptualmente. En un nivel superior, el nuevo estado de celda depende de una combinación cerrada del vector de entrada xt y el vector de salida y el estado de celda anteriores: h_prev y c_prev. La nueva salida, ht, depende del nuevo estado de celda y de la puerta de salida. Extraordinario.
La función devuelve el nuevo resultado y el nuevo estado de celda en una matriz. Esto conduce a un tipo devuelto float[][][], donde result[0] es una matriz de matrices que contiene la salida y result[1] contiene el nuevo estado de celda.
Llamar a ComputeOutputs es, principalmente, cuestión de configurar los valores de parámetros. La demostración inicia la preparación con:
float[][] xt = MatFromArray(new float[] {
1.0f, 2.0f }, 2, 1);
float[][] h_prev = MatFromArray(new float[] {
0.0f, 0.0f, 0.0f }, 3, 1);
float[][] c_prev = MatFromArray(new float[] {
0.0f, 0.0f, 0.0f }, 3, 1);
Tanto la salida anterior como el estado de celda se inicializan explícitamente en cero. A continuación, se crean dos conjuntos de valores de peso arbitrarios:
float[][] W = MatFromArray(new float[] {
0.01f, 0.02f,
0.03f, 0.04f,
0.05f, 0.06f }, 3, 2);
float[][] U = MatFromArray(new float[] {
0.07f, 0.08f, 0.09f,
0.10f, 0.11f, 0.12f,
0.13f, 0.14f, 0.15f }, 3, 3);
Tenga en cuenta que las dos matrices tienen formas distintas. Los valores de peso se copian en los parámetros de entrada:
float[][] Wf = MatCopy(W); float[][] Wi = MatCopy(W);
float[][] Wo = MatCopy(W); float[][] Wc = MatCopy(W);
float[][] Uf = MatCopy(U); float[][] Ui = MatCopy(U);
float[][] Uo = MatCopy(U); float[][] Uc = MatCopy(U);
Dado que los pesos no cambian, la demostración podría haber asignado por referencia, en lugar de utilizar MatCopy. Los sesgos se configuran con el mismo patrón:
float[][] b = MatFromArray(new float[] {
0.16f, 0.17f, 0.18f }, 3, 1);
float[][] bf = MatCopy(b); float[][] bi = MatCopy(b);
float[][] bo = MatCopy(b); float[][] bc = MatCopy(b);
Se llama a la función ComputeOutputs como se indica a continuación:
float[][] ht, ct;
float[][][] result;
result = ComputeOutputs(xt, h_prev, c_prev,
Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc,
bf, bi, bo, bc);
ht = result[0]; // Output
ct = result[1]; // New cell state
El objetivo de una celda de LSTM es alimentar una secuencia de vectores de entrada, de modo que la demostración configura y envía un segundo vector de entrada:
h_prev = MatCopy(ht);
c_prev = MatCopy(ct);
xt = MatFromArray(new float[] {
3.0f, 4.0f }, 2, 1);
result = ComputeOutputs(xt, h_prev, c_prev,
Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc,
bf, bi, bo, bc);
ht = result[0];
ct = result[1];
Tenga en cuenta que la demostración envía explícitamente los vectores de estado y salida anteriores a ComputeOutputs. Una alternativa es alimentar solo el nuevo vector de entrada, ya que la salida y el estado de celda anteriores siguen almacenados en ht y ct.
Conectar los puntos
¿Para qué sirven? Una celda de LSTM se puede usar para construir una red neuronal recurrente de LSTM: una celda de LSTM con ciertas modificaciones. Estas redes han sido las responsables de grandes avances en los sistemas de predicción que funcionan con datos de secuencias. Por ejemplo, imaginemos que se le pide que prevea la palabra siguiente de la frase “En 2017, ganó el campeonato __”. Solo con esa información, resultaría muy difícil hacer una predicción. Pero imagine que el sistema tuviera un estado y que recordara que parte de una frase anterior era “La NBA ha celebrado un partido de campeonato desde 1947”. Ahora estaría en posición de prever uno de los 30 equipos de la NBA.
Hay docenas de variaciones de arquitecturas de LSTM. Además, dado que las celdas de LSTM son complejas, existen docenas de variaciones de implementaciones para cada arquitectura. Pero, si entiende el mecanismo de celda de LSTM básico, puede comprender las variaciones fácilmente.
El programa de demostración define los pesos y sesgos de LSTM con valores arbitrarios. Los pesos y sesgos de una red de LSTM real se determinarían mediante el entrenamiento de la red. Obtendría un conjunto de datos de entrenamiento con valores de entrada conocidos y valores de salida conocidos y correctos. A continuación, usaría un algoritmo, por ejemplo, de propagación inversa, para buscar valores para los pesos y sesgos que minimicen el error entre las salidas calculadas y las correctas.
El Dr. James McCaffrey trabaja para Microsoft Research en Redmond, Washington. Ha colaborado en el desarrollo de varios productos de Microsoft como, por ejemplo, Internet Explorer y Bing. Puede ponerse en contacto con el Dr. McCaffrey en jamccaff@microsoft.com.
Gracias a los siguientes expertos técnicos de Microsoft por revisar este artículo: Ricky Loynd y Adith Swaminathan