/ machine learning

Red Neuronal simple en JavaScript desde 0 - Parte 1

Manos a la obra! Escribamos una red neuronal simple desde cero con JavaScript - Parte uno de la serie de Redes Neuronales

Sobre el post

Este es un post que forma parte de una serie de tutoriales donde desarrollamos una red neuronal desde cero, en este primer post, encontrarás la idea sobre la implementación de una neurona / perceptrón con cálculos que si bien no son los más actuales, sirven para reducir la complejidad del tema. En los post posteriores, iremos mejorando cada sección

que es una neurona?

No vamos a entrar a explicaciones a bajo nivel sobre que es una neurona, brincaremos directo a la analogía por lo cual reciben dicho nombre: Las neuronas están interconectadas y se comunican entre ellas, las salidas de una neurona sirven como entradas para otras, y estas se pueden activar mediante la corriente eléctrica que circula en el cerebro

Para nuestro caso, esas entradas son las observaciones, de aquí brincamos al proceso de activación, que es un calculo para indicar la prediccion

Para mantener la simplicidad del ejemplo en el post, vamos a utilizar una sola neurona, pero básicamente este proceso se repite una y otra vez cuando se tienen más neuronas (red neuronal)

cálculos de la neurona

Ahora comencemos con las operaciones de la neurona. Inicialmente, la neurona recibirá los parámetros de entrada, que es lo que describe a una observación, supongamos que queremos predecir si lloverá o no

entradas

En este caso describiremos la observación únicamente como:

¿Está nublado? - ¿Hay viento?

Digamos que para indicar si utilizaremos un valor 1 y para no un 0, entonces, registremos unas observaciones iniciales:

Nublado - Viento - Llovió
1 - 1 - 1
1 - 0 - 1
0 - 1 - 0
0 - 0 - 0

Es una forma muy burda de saber si lloverá o no, pero al final usaremos la misma implementación para entrenar la red en otro problema, así que follow me

Entonces, podemos decir que la arquitectura de nuestro modelo se ve de la siguiente forma:

97588918-B838-48EF-8164-BE627DAE811B

función de activación

Ahora pasemos a la función de activación, hay muchas funciones de activación, pero por ahora sólo diremos que si el valor es mayor a cierto umbral, será activado, en caso contrario, no se activará. Como en nuestro ejemplo estamos intentando predecir si lloverá o no, y dijimos que 1 significa si y 0 no, utilizaremos de igual forma esos valores binarios, 1 = activado (llovió), 0 = no activado (no llovió)

const activationFunction = (value) => {
  // si sobrepasa el valor mínimo requerido
  if (value > 0) {
    // indicamos que la neurona puede activarse
    return 1
  }
  
  // aqui la neurona no se activará
  return 0
}

predicción

Cada conexión a la neurona (parámetro de entrada) tiene un peso asociado, pensemos en los pesos como la importancia de ese parámetro para la neurona, esto juega una parte importante para los cálculos posteriores

Supongamos entonces que nuestras observaciones serán guardadas en un arreglo sencillo:

const random = _ => {
  const max = 1
  const min = -1
  return Math.random() * (max - min) + min
}

// observación: { nublado: 0, viento: 1 }
const input = [0, 1]

const predict = (input) => {
  // los pesos deben estar asociados a la cantidad de entradas
  const weights = []
  // por ahora solo los inicializaremos de manera aleatoria
  input.forEach(field => {
    weights.push(random())
  })
}

Ahora tenemos que multiplicar cada peso por su equivalente al valor real y finalmente sumados, por ejemplo: weight0 * field0 + weight1 * field1

Tomando en cuenta el código anterior

// observación: { nublado: 0, viento: 1 }
const input = [0, 1]

const predict = (input) => {
  // los pesos deben estar asociados a la cantidad de entradas
  const weights = []
  // por ahora solo los inicializaremos de manera aleatoria
  input.forEach(field => {
    weights.push(random())
  })
  
  // ahora multiplicamos el peso corrrespondiente a cada elemento
  // esta va a ser la entrada que utilizará la neurona para procesar
  const weightedInput = []

  // la suma total de todos los pesos con las entradas
  let sum = 0

  // realizar la operación
  input.forEach((field, index) => {
    sum += field * weights[index]
  })

  return activationFunction(sum)
}

const prediction = predict(input)
console.log(`The prediction is ${prediction}`)

Hasta ahora, ya tenemos un mecanismo base para poder realizar una predicción, sin embargo, al ejecutarlo varias veces, notamos que no hay una consistencia en los resultados:

$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 0
$ node index.js 
The prediction is 0
$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 0
$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 0
$ node index.js 
The prediction is 1
$ node index.js 
The prediction is 1

machine learning

Hasta ahora sólo hemos estado aplicando funciones directamente, y no tenemos resultados adecuados, la parte de aprendizaje aquí es tomar el output de la predicción y calcular qué tan bien lo hizo, y en base a esto, si el resultado está muy desviado, habrá que ajustar los pesos en base a ese error

Existen distintos algoritmos de optimización, el que utilizaremos (que es un pilar importante para distintos tópicos de machine learning) se llama descenso del gradiente

La implementación se verá afectada por estas modificaciones, primero, habrá que declarar la información de entreamiento, que en este caso, son aquellas observaciones que hicimos previamente, de las cuales ya conocemos el resultado:

/*
 replicaremos la tabla inicial
 
 Nublado - Viento - Llovió
   1     -   1    -   1
   1     -   0    -   1
   0     -   1    -   0
   0     -   0    -   0
*/
const trainingData = [
  [1, 1, 1],
  [1, 0, 1],
  [0, 1, 0],
  [0, 1, 0]
]

Definamos un método extra, este servirá para entrenar la red, lo que esto realmente significa, es que aquí vamos a ajustar los pesos previo a utilizar nuestro modelo para realizar predicciones, la ventaja de esto es que el modelo estará curado y será más útil, en lugar de comenzar siempre aleatorio

// primero, habrá que definir los pesos de forma global
// esto permitirá al entrenamiento alterar los pesos directamente
// y estos valores ajustados serán utilizados por la función de predicción
const weights = []

const train = (trainingData) => {
  trainingData.forEach(observation => {
    // tomar la predicción inicial
    const guess = predict(observation.slice(0, 2))
    // calcular el error (de forma muy básica, que tan "alejados" estamos)
    const error = observation[2] - guess
    
    for (let index = 0; index < weights.length; index++) {
      weights[index] += error * observation[index]
    }
  })
}

Lo que necesitamos en este paso es introducir también el concepto de ritmo de aprendizaje, esto es, que tan rápido queremos girar del error hacia el resultado deseado. La razón es porque, imaginemos que vamos en un auto y nuestro copiloto grita Ohh, aquí hay que girar a la izquierda!, si giras sumamente rápido, es posible que en realidad hagamos una U y no tomemos realmente la entrada a la izquierda, de igual forma el ritmo del aprendizaje es un valor que hay que considerar para no pasarnos del valor deseado al cual queremos llegar

El código previo sólo presentaría pequeños cambios:

// primero, habrá que definir los pesos de forma global
// esto permitirá al entrenamiento alterar los pesos directamente
// y estos valores ajustados serán utilizados por la función de predicción
const weights = []
// ritmo de aprendizaje pequeño para "acercarse" lentamente a la respuesta correcta
const learningRate = 0.1

const train = (trainingData) => {
  trainingData.forEach(observation => {
    // tomar la predicción inicial
    const guess = predict(observation.slice(0, 2))
    // calcular el error (de forma muy básica, que tan "alejados" estamos)
    const error = observation[2] - guess
    
    for (let index = 0; index < weights.length; index++) {
      weights[index] += error * observation[index] * learningRate
    }
  })
}

Si probamos el código completo, notaremos que las respuestas aún varían, incluso con el entrenamiento previo, esto es -por ahora- debido a que la cantidad de información de entrenamiento es muy poca, así que ajustaremos el script con un poco más de información repetida que serán más observaciones:

const activationFunction = (value) => {
  // si sobrepasa el valor mínimo requerido
  if (value > 0) {
    // indicamos que la neurona puede activarse
    return 1
  }
  
  // aqui la neurona no se activará
  return 0
}

const random = _ => {
  const max = 1
  const min = -1
  return Math.random() * (max - min) + min
}

// los pesos deben estar asociados a la cantidad de entradas
const weights = [random(), random()]
// ritmo de aprendizaje pequeño para "acercarse" lentamente a la respuesta correcta
const learningRate = 0.5


const predict = (input) => {
  
  // ahora multiplicamos el peso corrrespondiente a cada elemento
  // esta va a ser la entrada que utilizará la neurona para procesar
  const weightedInput = []

  // la suma total de todos los pesos con las entradas
  let sum = 0

  // realizar la operación
  input.forEach((field, index) => {
    sum += field * weights[index]
  })

  return activationFunction(sum)
}


const train = (trainingData) => {
  trainingData.forEach(observation => {
    // solo el input
    const input = observation.slice(0, 2)
    // tomar la predicción inicial
    const guess = predict(input)
    // calcular el error (de forma muy básica, que tan "alejados" estamos)
    const error = observation[2] - guess
    
    for (let index = 0; index < weights.length; index++) {
      weights[index] += error * input[index] * learningRate
    }
  })
}


// the training data
const training = [
  [1, 1, 1],
  [1, 0, 1],
  [0, 1, 0],
  [0, 1, 0],
  [0, 1, 0],
  [1, 1, 1],
  [1, 0, 1],
  [1, 1, 1],
  [1, 0, 1],
  [0, 1, 0],
  [0, 1, 0],
  [0, 1, 0],
  [0, 1, 0],
  [0, 1, 0],
  [0, 1, 0],
  [0, 1, 0]
]

// observación: { nublado: 0, viento: 1 }
const input = [0, 1]
train(training)

const prediction = predict(input)

console.log(`For ${input}, the prediction is ${prediction}`)

Probando el código, los output ahora son consistentes:

$ node  index.js 
For 0,1, the prediction is 0

En el siguiente post introduciremos más conceptos para mejorar las predicciones