Недавно у меня возник резкий интерес к шахматным движкам, и желание разработать подобный. Писать конечно же нужно по последним веяниям моды с использованием оценки нейросети, но вот незадача, подобного опыта да и знаний у меня не имеется. Я собираюсь написать серию статей о моем тернистом пути изучения нейронных сетей. Постараюсь сразу расписывать все ответы на возникшие у меня вопросы. Основная цель - понять суть самым маленьким. Данная статья будет первой и надеюсь не последней.

Первым шагом нужно в принципе понять практическую часть работы нейронных сетей. Для достижения поставленной задачи я решил узнать основы, и написать нейронную сеть на столько простую, на сколько это возможно. Использовать я буду язык c++.

Максимально простой задачей будет написать нейронную сеть, которая конвертирует градусы цельсия в градусы фаренгейта. Подобная нейронная сеть будет иметь всего один вес и смещение если посмотреть на формулу Т (° F) = Т (° C) × 1,8 + 32 . В идеале, после обучения наша нейронная сеть должна иметь вес 1.8 и смещение 32. Я буду использовать метод градиентного спуска.

Инициализируем класс хранящий нужные нам значения, в конструкторе изначальные значения поставим как душа пожелает.

class NeuralNetwork
{
private:
  float weight;
  float bias;
public:
  NeuralNetwork(){
    weight = 1.0;
    bias = 1.0;
  }
};

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

float valueFahrenheit(float valueCelsius){
    return valueCelsius*weight + bias;
}

Так же нам понадобится основная функция, в которой будет происходить вся магия(подбор). Она будет в себя принимать два вектора, хранящих значения градусов цельсия и соответствующие им градусы фаренгейта, а так же значение скорости обучения, которая будет определять, как быстро будут меняться вес и смещение с каждой итерацией.

В переменной result будем хранить результат работы нашей немного бессмысленной нейросети, для оценки требуемых изменений веса и смещения. В переменную error поместим разницу полученного и ожидаемого значения. Градиент веса учитывается в зависимости от величины отклонения(в нашем случае error) и входного значения celsiusData[i]. Градиент же смещения будет приравниваться только к величине ошибки. Это различие связано с тем, что вес определяет степень влияния каждого нейрона (не будем обращать внимание на то, что он у нас один), вес умножается на входное значение, и нам нужно корректировать веса, чтобы соответствовать данным обучения. С другой стороны, смещения является дополнительным параметром и не связано с входным значением. От веса и смещения отнимаем произведение нужных градиентов на скорость обучения. Отнимаем мы, а не прибавляем, так как градиент по сути показывает нам направление наискорейшего роста функции потерь, а мы стремимся как раз к обратному.

void train(std::vector<float> celsiusData, std::vector<float> fahrenheitData, float learningRate){
    for (int i = 0; i < celsiusData.size(); i++)
    {
      float result = valueFahrenheit(celsiusData[i]);
      float error = result - fahrenheitData[i];
      float gradientWeight = error * celsiusData[i];
      float gradientBias = error;

      weight -= learningRate*gradientWeight;
      bias -= learningRate*gradientBias;
    }
  }

Остается сгенерировать данные для примера.

std::srand(std::time(nullptr));
for (int i = 0; i < valueOfData; i++)
{
  int value = std::rand()%200-100;
  celsiusData.push_back(value);
  fahrenheitData.push_back(value*1.8 + 32);
}

Несложными манипуляциями задаем нужные значения обучая нейронную сеть и проверяем ее работу.

int main(){
  NeuralNetwork mynn;

  std::vector<float> celsiusData;
  std::vector<float> fahrenheitData;

  float learningRate = 0.025;
  int valueOfData = 10000;

  std::srand(std::time(nullptr));
  for (int i = 0; i < valueOfData; i++)
  {
    int value = std::rand()%200-100;
    celsiusData.push_back(value);
    fahrenheitData.push_back(value*1.8 + 32);
  }
  
  mynn.train(celsiusData,fahrenheitData,learningRate);

  float testCount = 25.0;
  std::cout<<"Degrees Celsius: "<<testCount<<"\n"<<"Degrees Fahrenheit: "<<mynn.valueFahrenheit(testCount);
  
  return 0;
}

В результате получаем nan, ищем ошибку. Первое, что мне пришло в голову, это проверить значение веса и смещения при каждой итерации. Выясняется что наши вес и смещение улетают в бесконечность. После некоторых поисков я узнал, что данное явление называется взрывом градиента(Gradient Explosion) и чаще всего появляется при неправильном подборе начальных весов или скорости обучения. После добавления пары ноликов после точки в скорости обучений проблема решилась. Не буду утруждать себя слишком доскональным подбором скорости обучения и количества итераций обучения, оптимальные значения подобранные на скорую руку: learningRate = 0.00025, valueOfData = 100000. После обучения вес и смещение получили такие значения: Weight: 1.80001, Bias: 31.9994.

Попробуем повысить точность, заменив везде float на double. Это оказалось правильным решением, теперь при правильном количестве итераций вес всегда принимает значение 1.8 и смещение 32.

Весь код кому интересно:

Код
#include <iostream>
#include <vector>
#include <ctime>
#include <cmath>

class NeuralNetwork
{
private:
  double weight;
  double bias;
public:
  NeuralNetwork(){
    weight = 1.0;
    bias = 1.0;
  }

  double valueFahrenheit(double valueCelsius){
    return valueCelsius*weight + bias;
  }

  void printValue(){
    std::cout<<"Weight: "<<weight<<"\n"<<"Bias: "<<bias<<"\n";
  }

  void train(std::vector<double> celsiusData, std::vector<double> fahrenheitData, double learningRate){
    for (int i = 0; i < celsiusData.size(); i++)
    {
      double result = valueFahrenheit(celsiusData[i]);
      double error = result - fahrenheitData[i];
      double gradientWeight = error * celsiusData[i];
      double gradientBias = error;

      weight -= learningRate*gradientWeight;
      bias -= learningRate*gradientBias;
      //printValue();
    }
  }
};

int main(){
  NeuralNetwork mynn;

  std::vector<double> celsiusData;
  std::vector<double> fahrenheitData;

  double learningRate = 0.00025;
  int valueOfData = 60000;

  std::srand(std::time(nullptr));
  for (int i = 0; i < valueOfData; i++)
  {
    int value = std::rand()%200-100;
    celsiusData.push_back(value);
    fahrenheitData.push_back(value*1.8 + 32);
  }
  
  mynn.train(celsiusData,fahrenheitData,learningRate);

  double testCount = 1000.0;
  std::cout<<"Degrees Celsius: "<<testCount<<"\n"<<"Degrees Fahrenheit: "<<mynn.valueFahrenheit(testCount)<<"\n";
  mynn.printValue();
  
  return 0;
}

Теперь можно и попробовать сделать нахождение коэффициентов функции y = a*7 + b*3 + c*5 + 32. Переменную одного веса поменяем на вектор, и в тренировки добавим обновление каждого нейрона. Также теперь функция тренировки будет принимать вектор из векторов, так как у нас несколько коэффициентов. Упростим код для большей читабельности. В итоге наша функция примет такой вид:

void train(std::vector<std::vector<double>> inputValue, std::vector<double> outputValue, double learningRate){
    for (int i = 0; i < outputValue.size(); i++)
    {
      double result = expectedValue(inputValue[i][0], inputValue[i][1], inputValue[i][2]);
      double error = result - outputValue[i];
      weight[0] -= learningRate * error * inputValue[i][0];
      weight[1] -= learningRate * error * inputValue[i][1];
      weight[2] -= learningRate * error * inputValue[i][2];
      bias -= learningRate*error;
    }
  }

Обновляем генерацию данных для обучения, настраиваем скорость обучения и наслаждаемся.

Код
#include <iostream>
#include <vector>
#include <ctime>
#include <cmath>

class NeuralNetwork
{
private:
  std::vector<double> weight;
  double bias;
public:
  NeuralNetwork(){
    weight = {1.0,1.0,1.0};
    bias = 1.0;
  }

  double getWeight(int value){
    return weight[value];
  }
  double getBias(){
    return bias;
  }

  double expectedValue(double a, double b, double c){
    return a*weight[0] + b*weight[1] + c*weight[2] + bias;
  }

  void train(std::vector<std::vector<double>> inputValue, std::vector<double> outputValue, double learningRate){
    for (int i = 0; i < outputValue.size(); i++)
    {
      double result = expectedValue(inputValue[i][0], inputValue[i][1], inputValue[i][2]);
      double error = result - outputValue[i];
      weight[0] -= learningRate * error * inputValue[i][0];
      weight[1] -= learningRate * error * inputValue[i][1];
      weight[2] -= learningRate * error * inputValue[i][2];
      bias -= learningRate*error;
    }
  }
};

double targetFunction(double a, double b, double c){
  return a*7 + b*3 + c*5 + 32;
}

int main(){
  NeuralNetwork mynn;

  std::vector<std::vector<double>> inputValue;
  std::vector<double> outputValue;

  double learningRate = 0.0002;
  int valueOfData = 70000;

  std::srand(std::time(nullptr));
  for (int i = 0; i < valueOfData; i++)
  {
    std::vector<double> input;
    input.push_back((double)(std::rand()%200-100)/10);
    input.push_back((double)(std::rand()%200-100)/10);
    input.push_back((double)(std::rand()%200-100)/10);
    inputValue.push_back(input);
    outputValue.push_back(targetFunction(inputValue[i][0], 
                                        inputValue[i][1],
                                        inputValue[i][2]));
  }
  
  mynn.train(inputValue, outputValue,learningRate);

  std::cout<<"Weight 0: "<<mynn.getWeight(0)<<"\n"<<
            "Weight 1: "<<mynn.getWeight(1)<<"\n"<<
            "Weight 2: "<<mynn.getWeight(2)<<"\n"<<
            "Bias: "<<mynn.getBias()<<"\n";
  
  return 0;
}

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

Комментарии (14)


  1. nulovkin
    12.04.2024 18:48

    Забавно, я как-то написал статью с таким же названием.
    Правда там речь шла скорее о перцептроне.

    Попробуем повысить точность, заменив везде float на double. Это оказалось правильным решением, теперь при правильном количестве итераций вес всегда принимает значение 1.8 и смещение 32.

    А вот это не должно было сработать. В смысле нет никаких причин думать, что добавив нулей мы получим идеальную точность. Форматы с плавающей точкой по определению не точные.
    В целом хорошая статья, кому-то да поможет.


    1. milewe Автор
      12.04.2024 18:48
      +1

      Спасибо за положительную оценку, читал вашу статью, помогла понять некоторые вещи)


    1. nulovkin
      12.04.2024 18:48

      https://habr.com/ru/articles/714988/
      Я говорил об этой статье. Неправильно ссылку прикрепил.


  1. aka352
    12.04.2024 18:48

    Усилия и результаты похвальные, но если решили сделать нейросеть, способную играть в шахматы, то начинать надо не с этого. В конце концов есть множество готовых, отлаженных фрэймворков для программирования нейросетей, тот же tensorflow или pytorch. Основная сложность тут - собрать и подготовить обучающий датасет. Без него нейронная сеть не заработает, а практика показывает, что именно создание датасета - 95% всей работы. В Вашем случае нужно разобрать пошагово многие тысячи шахматных партий, создав из них обучающий датасет. А если создадите и еще сделаете публичным для других, сообщество будет очень благодарно ) Успехов!


    1. nulovkin
      12.04.2024 18:48

      Я думаю, понять идею градиентного спуска это важный шаг, хоть и маленький. Нельзя же сразу с места в карьер.


      1. aka352
        12.04.2024 18:48

        Идеологически конечно верно, но если каждый современный программист начнет изучение с ассемблера или даже машинных кодов, то IT-бум быстро закончится ) С другой стороны код станет быстрее и игры снова начнут умещаться на добрый старый компакт-диск ))


    1. milewe Автор
      12.04.2024 18:48
      +1

      Мне душа не позволяет использовать библиотеки, когда я не понимаю эту тему) Особенно когда сильно давят некоторые статьи с кучей формул, в которых такой школькик как я, врядли разберется. Обучать нейросеть изначально я планировал путем ее игры против себя, создание датасета, как я понимаю, завязано на оценки других, уже созданных моделей, что убирает половину интереса и смысла нейросети. Благодарю за положительную оценку!


      1. aka352
        12.04.2024 18:48

        Нейронной сети нужны примеры. Датасет состоит из пар - вход (текущее состояние фигур на доске) и выход (следующее состояние после хода). Играть против нее вы не сможете, т.к. необученная сеть будет выдавать просто рандомные ходы. Т.е. каждый раз вам придется исправлять ее, показывая "правильный ход". По факту это уже не обучение в игре, а сильно замедленное во времени формирование того же датасета из пар вход-выход. Пожалейте свое время. Простой разбор уже готовых партий сильно упростит вашу задачу и хорошо прокачает скилы ) Повторюсь, что задача формирования обучающих датасетов в ИИ не менее, а может даже более важна и востребована.

        Да, и датасет никак на завязан на оценки других моделей. В вашем случае это пошаговый разбор партий живых шахматистов. И чем выше уровень шахматных партий в датасете, тем она будет лучше играть ) Качество датасета имеет значение!


        1. milewe Автор
          12.04.2024 18:48

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


  1. KudryashovDA
    12.04.2024 18:48

    Точно так же разбирался с этим делом, проникся градиентным спуском, вдохновило это видео https://www.youtube.com/watch?v=eyCq3cNFpMU, там в первой части теория, во второй (эта ссылка) код на С++ для случая произвольного количества подбираемых параметров. Там такой ньюанс, что чем больше параметров хочется определить, тем больше локальных минимумов может образоваться и замучиешся подбирать начальные точки и смещения.


    1. milewe Автор
      12.04.2024 18:48

      Посмотрел по вашей рекомендации, видео о теории показалось мне более полезным, не зря посмотрел


  1. wmlab
    12.04.2024 18:48

    Как-то попадался на глаза движок, который можно натренировать на любую пошаговую игру с полной информацией. Воспроизводит гугловский AlphaZero. Гугл не открыл исходники, это кто-то имплементировал по описанию. Там Монте-Карло плюс нейросетка для оценки положения. Для тренировки датасет не нужен - только правила игры. Играет сам с своими копиями, пока не достигнет дзена (эволюционный алгоритм). Вот его бы разобрать.