Как стать автором
Обновить

Создание нейронной сети Хопфилда на JavaScript

Время на прочтение8 мин
Количество просмотров15K

Столкнувшись в университете с нейронными сетями, одной из любимых для меня стала именно сеть Хопфилда. Удивительно, что она оказалась последней в списке лабораторных, ведь ее работу можно наглядно продемонстрировать при помощи изображений и она не так сложна в реализации.

В этой статье при помощи нейросети Хопфилда будем решать задачу восстановления искаженных изображений. Нейросеть предварительно обучим на эталонных изображениях.

Пошагово напишем программу, позволяющую прямо в браузере поиграть с нейросетью, обучить ее на собственноручно нарисованных образах и проверить работу на искаженных образах.

Исходники на Github и демо.

Для реализации понадобится:

  • Браузер

  • Базовое понимание нейросетей

  • Базовые знания JavaScript / HTML

Немного теории

Нейронная сеть Хопфилда (англ. Hopfield network) — полносвязная нейронная сеть с симметричной матрицей связей. Такая сеть может быть использована для организации ассоциативной памяти, как фильтр, а также для решения некоторых задач оптимизации.

Сеть Хопфилда является абсолютно однородной структурой без какой-либо внутренней специализации ее нейронов. Её классический вариант состоит из единственного слоя нейронов, число которых является одновременно числом входов и выходов сети. Каждый нейрон сети связан со всеми остальными нейронами, а также имеет один вход, через который осуществляется ввод сигнала.

Структурная схема нейросети Хопфилда
Структурная схема нейросети Хопфилда

В общем виде задача, решаемая данной сетью в качестве ассоциативной памяти, формулируется следующим образом. Известен набор двоичных сигналов (например, изображений), которые считаются эталонными. Сеть должна уметь из произвольного искаженного сигнала, поданного на вход, «вспомнить» соответствующий эталонный образец (если такой есть).

Алгоритм работы сети:

  1. Инициализация
    Веса нейронов устанавливаются по следующей формуле:

    w_{ij}=\left\{\begin{matrix} \sum_{k=1}^{m} x_{i}^{k} * x_{j}^{k}   & i \neq j \\0,  & i=j \end{matrix}\right.

    где m— количество образов 
    x_{i}^{k}, x_{j}^{k}i- ый и j- ый элементы вектора k- ого образца.

  2. На входы сети подается неизвестный сигнал. Фактически его ввод осуществляется непосредственной установкой значений выходов:
    y_{j}(0) = x_{j}

  3. Рассчитывается выход сети (новое состояние нейронов и новые значения выходов):

    y_{j}(t+1)=f\left ( \sum_{i=1}^{n} w_{ij}*y_{i}(t)\right )

    где f — пороговая активационная функция с областью значений [-1;1];
    t — номер итерации;
    j = 1...n; n — количество входов и нейронов.

  4. Проверка изменения выходных значений за последнюю итерацию. Если выходы изменились — переход к пункту 3, иначе, если выходы стабилизировались, завершение функционирования. При этом выходной вектор представляет собой образец, наилучшим образом сочетающийся с входными данными.

Разработка

Визуальная часть

Посмотрим как работает итоговый проект.

Демонстрация работы программы
Демонстрация работы программы

Он состоит из двух элементов Canvas и трех кнопок. Это простейший HTML и CSS код.

Левый элемент Canvas нужен для рисования изображений, которые затем будут использованы для обучения или распознавания нейросети. На правом элементе отображается результат распознавания сигнала, находящегося на левом Canvas. В данном случае сеть «вспомнила» букву Т на основе искаженного сигнала.

Обратите внимение, что область для рисования представлена сеткой 10×10 и позволяет закрашивать клетки только черным цветом. Так как в сети Хопфилда число нейронов равно числу входов, количество нейронов будет равно длине входного сигнала, то есть 100 - у нас всего 100 клеток на экране. Входной сигнал при этом будет двоичным — массив, состоящий из −1 и 1, где −1 — это белый, а 1 — черный цвет.

Приступим к написанию кода. Инициализируем необходимые переменные.

Код инициализации
// Размер сетки 10 для простоты тестирования
const gridSize = 10;
// Размер одного квадрата в пикселях
const squareSize = 45;
// Размер входного сигнала (100)
const inputNodes = gridSize * gridSize;

// Массив для хранения текущего состояния картинки в левом канвасе,
// он же является входным сигналом сети
let userImageState = [];
// Для обработки движений мыши по канвасу
let isDrawing = false;
// Инициализация состояния
for (let i = 0; i < inputNodes; i += 1) {  
  userImageState[i] = -1;  
}

// Получаем контекст канвасов:
const userCanvas = document.getElementById('userCanvas');
const userContext = userCanvas.getContext('2d');
const netCanvas = document.getElementById('netCanvas');
const netContext = netCanvas.getContext('2d');

Напишем функцию рисования сетки, используя инициализированные ранее переменные.

Функция отрисовки сетки
// Функция принимает контекст канваса и рисует
// сетку в 100 клеток (gridSize * gridSize)
const drawGrid = (ctx) => {
  ctx.beginPath();
  ctx.fillStyle = 'white';
  ctx.lineWidth = 3;
  ctx.strokeStyle = 'black';
  for (let row = 0; row < gridSize; row += 1) {
    for (let column = 0; column < gridSize; column += 1) {
      const x = column * squareSize;
      const y = row * squareSize;
      ctx.rect(x, y, squareSize, squareSize);
      ctx.fill();
      ctx.stroke();
    }
  }
  ctx.closePath();
};

Чтобы «оживить» полученную сетку, добавим обработчики клика и движения мыши по канвасу.

Обработчики движений мыши
// Обработка клика мыши
const handleMouseDown = (e) => {
  userContext.fillStyle = 'black';
  // Рисуем залитый прямоугольник в позиции x, y
  // размером squareSize х squareSize (45х45 пикселей)
  userContext.fillRect(
    Math.floor(e.offsetX / squareSize) * squareSize,
    Math.floor(e.offsetY / squareSize) * squareSize,
    squareSize, squareSize,
  );

  // На основе координат вычисляем индекс,
  // необходимый для изменения состояния входного сигнала
  const { clientX, clientY } = e;
  const coords = getNewSquareCoords(userCanvas, clientX, clientY, squareSize);
  const index = calcIndex(coords.x, coords.y, gridSize);

  // Проверяем необходимо ли изменять этот элемент сигнала
  if (isValidIndex(index, inputNodes) && userImageState[index] !== 1) {
    userImageState[index] = 1;
  }

  // Изменяем состояние (для обработки движения мыши)
  isDrawing = true;
};

// Обработка движения мыши по канвасу
const handleMouseMove = (e) => {
  // Если не рисуем, т.е. не было клика мыши по канвасу, то выходим из функции
  if (!isDrawing) return;

  // Далее код, аналогичный функции handleMouseDown
  // за исключением последней строки isDrawing = true;
  userContext.fillStyle = 'black';

  userContext.fillRect(
    Math.floor(e.offsetX / squareSize) * squareSize,
    Math.floor(e.offsetY / squareSize) * squareSize,
    squareSize, squareSize,
  );

  const { clientX, clientY } = e;
  const coords = getNewSquareCoords(userCanvas, clientX, clientY, squareSize);
  const index = calcIndex(coords.x, coords.y, gridSize);

  if (isValidIndex(index, inputNodes) && userImageState[index] !== 1) {
    userImageState[index] = 1;
  }
};

Обработчики используют вспомогательные функции, такие как getNewSquareCoords, calcIndex и isValidIndex. Ниже их код с комментариями.

Вспомогательные функции
// Вычисляет индекс для изменения в массиве
// на основе координат и размера сетки
const calcIndex = (x, y, size) => x + y * size;

// Проверяет, помещается ли индекс в массив
const isValidIndex = (index, len) => index < len && index >= 0;

// Генерирует координаты для закрашивания клетки в пределах 
// размера сетки, на выходе будут значения от 0 до 9
const getNewSquareCoords = (canvas, clientX, clientY, size) => {
  const rect = canvas.getBoundingClientRect();
  const x = Math.ceil((clientX - rect.left) / size) - 1;
  const y = Math.ceil((clientY - rect.top) / size) - 1;
  return { x, y };
};

Напишем обработчик для кнопки Очистить. При нажатии на эту кнопку должны очищаться закрашенные квадраты двух кавасов и сбрасываться состояние входного сигнала.

Функция очистки сетки
const clearCurrentImage = () => {
  // Чтобы убрать закрашенные клетки, заново отрисовываем 
  // всю сетку и сбрасываем массив входного сигнала
  drawGrid(userContext);
  drawGrid(netContext);
  userImageState = new Array(gridSize * gridSize).fill(-1);
};

Теперь можно переходить к разработке «мозга» программы.

Реализация алгоритма нейросети

Первый этап — инициализация сети. Добавим переменную для хранения значений весов нейронов и немного изменим цикл инициализации входного сигнала.

Инициализация весов сети
...
const weights = [];  // Массив весов сети
for (let i = 0; i < inputNodes; i += 1) {
  weights[i] = new Array(inputNodes).fill(0); // Создаем пустой массив и заполняем его 0
  userImageState[i] = -1;
}
...

Так как каждый нейрон в сети Хопфилда связан со всеми остальными нейронами, веса сети представлены двумерным массивом. Каждый элемент массива является одномерным массивом размером inputNodes элементов. В итоге мы получаем 100 нейронов, у каждого из которых по 100 связей.

Теперь реализуем обработку входного сигнала нейросетью по формуле из первого шага алгоритма. Этот процесс происходит по нажатию на кнопку Запомнить. «Запомненные » образы будут является эталонами для восстановления.

Код обработки входного сигнала
const memorizeImage = () => {
  for (let i = 0; i < inputNodes; i += 1) {
    for (let j = 0; j < inputNodes; j += 1) {
      if (i === j) weights[i][j] = 0;
      else {
        // userImageState - массив входного сигнала.
        // Это набор -1 и 1, где -1 - это белый, а 1 - черный цвет клеток на канвасе
        weights[i][j] += userImageState[i] * userImageState[j];
      }
    }
  }
};

Запомнив образ, можно подать на вход сети искаженный образ и попробовать распознать его. Напишем еще функцию распознавания:

Функция распознавания искаженного сигнала
// Где-то в html подключаем библиотеку lodash:
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
...
const recognizeSignal = () => {
  let prevNetState;
  // На вход сети подается неизвестный сигнал. Фактически 
  // его ввод осуществляется непосредственной установкой значений выходов
  // (2 шаг алгоритма), просто копируем массив входного сигнала
  const currNetState = [...userImageState];
  do {
    // Копируем текущее состояние выходов, 
	// т.е. теперь оно становится предыдущим состоянием
    prevNetState = [...currNetState];
    // Рассчитываем выход сети согласно формуле 3 шага алгоритма
    for (let i = 0; i < inputNodes; i += 1) {
      let sum = 0;
      for (let j = 0; j < inputNodes; j += 1) {
        sum += weights[i][j] * prevNetState[j];
      }
      // Рассчитываем выход нейрона (пороговая ф-я активации)
      currNetState[i] = sum >= 0 ? 1 : -1;
    }
    // Проверка изменения выходов за последнюю итерацию
    // Сравниваем массивы при помощи ф-ии isEqual
  } while (!_.isEqual(currNetState, prevNetState));

  // Если выходы стабилизировались (не изменились), отрисовываем восстановленный образ
  drawImageFromArray(currNetState, netContext);
};

Для сравнения выходов сети на предыдущем и текущем шаге используется функция isEqual из библиотеки lodash.

Для отрисовки полученного сигнала мы написали функцию drawImageFromArray. Она преобразует выходной сигнал сети в двумерный массив и отрисовывает его на правом канвасе.

Функция отрисовки изображения из массива точек
const drawImageFromArray = (data, ctx) => {
  const twoDimData = [];
  // Преобразуем одномерный массив в двумерный
  while (data.length) twoDimData.push(data.splice(0, gridSize));

  // Предварительно очищаем сетку
  drawGrid(ctx);
  // Рисуем изображение по координатам
  for (let i = 0; i < gridSize; i += 1) {
    for (let j = 0; j < gridSize; j += 1) {
      if (twoDimData[i][j] === 1) {
        ctx.fillStyle = 'black';
        ctx.fillRect((j * squareSize), (i * squareSize), squareSize, squareSize);
      }
    }
  }
};

Финальные приготовления

Для полноценного запуска программы осталось добавить наши функции в качестве обработчиков для элементов HTML и отрисовать сетки.

Привязываем функции к HTML элементам
const resetButton = document.getElementById('resetButton');
const memoryButton = document.getElementById('memoryButton');
const recognizeButton = document.getElementById('recognizeButton');

// Вешаем слушатели на кнопки
resetButton.addEventListener('click', () => clearCurrentImage());
memoryButton.addEventListener('click', () => memorizeImage());
recognizeButton.addEventListener('click', () => recognizeSignal());

// Вешаем слушатели на канвасы
userCanvas.addEventListener('mousedown', (e) => handleMouseDown(e));
userCanvas.addEventListener('mousemove', (e) => handleMouseMove(e));
// Перестаем рисовать, если кнопка мыши отпущена или вышла за пределы канваса
userCanvas.addEventListener('mouseup', () => isDrawing = false);
userCanvas.addEventListener('mouseleave', () => isDrawing = false);

// Отрисовываем сетку
drawGrid(userContext);
drawGrid(netContext);

Демонстрация работы нейросети

Обучим сеть двум ключевым образам, буквам Т и Н:

Эталонные образы для обучения сети
Эталонные образы для обучения сети

Проверим работу сети на искаженных образах:

Попытка распознать искаженный образ буквы Н
Попытка распознать искаженный образ буквы Н
Попытка распознать искаженный образ буквы Т
Попытка распознать искаженный образ буквы Т

Сеть успешно восстановила исходные образы. Программа работает!

В заключение стоит отметить, что для сети Хопфилда число запоминаемых образов mне должно превышать величины, примерно равной 0.15*n, где n— размерность входного сигнала. Кроме того, если образы имеют сильное сходство, то они, возможно, будут вызывать у сети перекрестные ассоциации - то есть предъявление на входы сети вектора А приведет к появлению на ее выходах вектора Б и наоборот.

Исходники на Github и демо.

Теги:
Хабы:
Всего голосов 15: ↑15 и ↓0+15
Комментарии2

Публикации

Истории

Работа

Data Scientist
83 вакансии
React разработчик
56 вакансий

Ближайшие события