Оказывается, всего одного простого искусственного нейрона достаточно, чтобы провести бинарную классификацию линейн��-разделимого множества объектов.

Исходные данные

Возьмем учебное множество "жуков" и "гусениц"

Код для исходных данных
import numpy as np
import matplotlib.pyplot as plt

# исходные данные
x_train = np.array([[10, 50], [20, 30], [25, 30], [20, 60], [15, 70], [40, 40], [30, 45], [20, 45], [40, 30], [7, 35]])
y_train = np.array([-1, 1, 1, -1, -1, 1, 1, -1, 1, -1])

# формируем множества по меткам
x_0 = x_train[y_train == 1]
x_1 = x_train[y_train == -1]

# создаем изображение
plt.xlim([0, max(x_train[:, 0]) + 10])
plt.ylim([0, max(x_train[:, 1]) + 10])
plt.scatter(x_0[:, 0], x_0[:, 1], color='blue')
plt.scatter(x_1[:, 0], x_1[:, 1], color='red')

plt.ylabel("длина")
plt.xlabel("ширина")
plt.grid(True)
plt.show()

Создаем простой искусственный нейрон

Простой искусственный нейрон - сумматор поступающих сигналов и весов, то есть сумма попарных произведений wixi.

Перед выдачей на функцию активацию есть еще смещение (пороговое значение), и математически это смещение также можно представить как произведение веса w0 и x0 (x0=1). Таким образом простой искусственный нейрон продолжает оставаться сумматором, мы лишь добавили один вес и один "сенсор" с постоянным значением.

Создадим простой искусственный нейрон в виде класса Neuron.

Код создания класса Neuron
class Neuron:
  def __init__(self, w):          # Действия при создании класса
    self.w = w

  def output(self, x):            # Сумматор
    return np.dot(self.w, x)      # Суммируем входы

Функция активации и Функция качества

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

Код функций активации и качества
# Функция активации - Функция единого скачка
def onestep(x):
  return 1 if x >= 0 else -1

# Функция сравнения вывода с меткой класса
def compare(x,y):
  return onestep(neuron.output(x)) == y

# Функция качества. Считает количество объектов с ошиибкой.
def Q(w):
  return sum([1 for index, x in enumerate(x_train) if not compare(x,y_train[index])])

Добавление размерности

Как указано выше, добавляем одну координату с фиксированным значением для учета порогового значения.

Код добавления размерности
# Функция добавления размерности
def add_axis(source_array, value):
  this_array = []
  for x in source_array: this_array.append(np.append(x, value))
  return np.array(this_array)

# добавляем фикированное значение (1)
x_train = add_axis(x_train, 1)

Обучение нейрона

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

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

Выбираем объект случайным образом и проверяем, является ли он ошибочным. Если объект является ошибочным, то выбираем вес случайным образом и корректируем. Так как метки класса изначально установлены как +1/-1, то для коррекции веса прибавляем заданную величину, умноженную на метку класса.

Соответственно, изначально инициируем веса случайным образом и задаем максимальное количество итераций, чтобы не получилось бесконечное зацикливание, если что-то пойдет не так. Для данного примера N = 500 и lmd = 0.2 дают вполне стабильный результат.

Код обучения нейрона
import random

# Случайный выбор для коэффициентов
def random_w():
  return round((random.random() * 10 - 5),1)

N = 500                            # максимальное число итераций
lmd = 0.2                          # шаг изменения веса

# Инициируем веса случайным образом
w = np.array([random_w(), random_w(), random_w()])
neuron = Neuron(w)

# Считаем количество ошибок
Q_current = Q(w)

# проходим итерации
for n in range(N):
  if Q_current == 0: break

  # Выбираем случайным образом объект
  random_index = random.randint(0,len(x_train)-1)
  x = x_train[random_index]
  y = y_train[random_index]

  # Если вывод не совпадает с меткой класса
  if not compare(x,y):

    # выбираем вес случайным образом
    index = random.randint(0,len(w)-1)

    # Применяем знак метки для выбора изменения и меняем вес
    w[index] = round(w[index] + y*lmd,4)
    neuron = Neuron(w)

    Q_current = Q(w)
    if Q_current == 0: break

# печатаем
print(w)
print('Q_current:', Q_current, 'n:', n) 

Получаем набор весов.
Дополнительно можно вывести количество ошибок и на какой итерации завершилось.

[ 1.4 -0.8 1.4]
Q_current: 0 n: 83

Визуализация

Трактовать получающийся набор весов ( [ 1.4 -0.8 1.4] ) весьма затруднит��льно. Существенно упростить трактовку может визуализация в виде прямой
Y = kX + b, где k = -w[0]/w[1], b = - w[2]/w[1]
В данном случае получаем прямую y = 1.75x + 1.75.

Код создания изображения с разделяющей линией
# Рисование линии
def draw_line(x,w,color):
    line_x = list(range(max(x[:, 0]) + 10))
    line_y = [-w[0]/w[1]*x - w[2]/w[1] for x in line_x]
    plt.plot(line_x, line_y, color=color)

plt.xlim([0, max(x_train[:, 0]) + 10])
plt.ylim([0, max(x_train[:, 1]) + 10])
plt.scatter(x_0[:, 0], x_0[:, 1], color='blue')
plt.scatter(x_1[:, 0], x_1[:, 1], color='red')

draw_line(x_train, w, 'green')

plt.ylabel("длина")
plt.xlabel("ширина")
plt.grid(True)
plt.show()

Классификация новых объектов

Для проведения классификации новых объектов нужно взять их координаты и передать в нейрон. Важно не забыть добавить размерность. Функция активации выдаст метку класса.

Зададим новые точки для проведения классификации:
x_prod = [[35,40], [15,50]]

Код для проведения классификации новых объектов
x_prod = [[35,40], [15,50]]
x_prod = add_axis(x_prod, 1)

neuron = Neuron(w)

for key in x_prod: 
  print(key, onestep(neuron.output(key)))

Результат:
[35 40 1] 1
[15 50 1] -1

Посмотрим визуально, добавив на изображение новые объекты.
Добавим функцию задания цвета в зависимости от вывода функции активации, а новые объекты покажем с увеличенным размером.

Код
Код
Код создания изображения с новыми объектами
# Функция определения цвета
def color(value):
  if value == 1: color = 'blue'
  if value == -1: color = 'red'
  return color

# Рисование линии
def draw_line(x,w,color):
    line_x = list(range(max(x[:, 0]) + 10))
    line_y = [-w[0]/w[1]*x - w[2]/w[1] for x in line_x]
    plt.plot(line_x, line_y, color=color)


x_prod = [[35,40], [15,50]]
x_prod = add_axis(x_prod, 1)

neuron = Neuron(w)

for key in x_prod:
  plt.scatter(key[0], key[1], color=color(onestep(neuron.output(key))), s=100)

plt.xlim([0, max(x_train[:, 0]) + 10])
plt.ylim([0, max(x_train[:, 1]) + 10])
plt.scatter(x_0[:, 0], x_0[:, 1], color='blue')
plt.scatter(x_1[:, 0], x_1[:, 1], color='red')

draw_line(x_train, w, 'green')

plt.ylabel("длина")
plt.xlabel("ширина")
plt.grid(True)
plt.show()

Видим, что классификация проведена корректно.
Простой искусственный нейрон справился.

Особенности

1. Правильных наборов весов может быть много
Тесты показывают, что набор весов с нулевой ошибкой с каждым запуском получается другой. Теоретически, это правильно. Действительно, первоначальный набор ��есов инициируется случайным образом, выбор объекта и выбор весов для коррекции выбирается случайным образом - получается множество вариантов того, как может пойти коррекция, и множество итоговых вариантов наборов весов. Да и визуально тоже понятно - формально, любая прямая, проведенная между двумя разноцветными множествами, будет правильной, и таких прямых может быть много, хотя они и будут ложиться в некоторый интервал схожим образом.

2. На ноль делить нельзя
Для визуализации мы проводим разделяющую прямую:
Y = kX + b, где k = -w[0]/w[1], b = - w[2]/w[1]
Но на ноль делить нельзя, а у нас есть деление на w[1], то есть вес w[1] не может быть равным нулю.

Я пока не решил, как правильно трактовать случай w[1] = 0.
Формально, в данной задаче любой вес может быть равным нулю, этому ничего не мешает.
Теоретически, это означает, что соответствующая координата не влияет на разделение. А графически это означает, что коэффициент k стремится в бесконечность и b стремится в бесконечность. То есть, наверное, разделяющая прямая становится параллельной оси Y, хотя я не уверен в этом выводе. В любом случае это нужно учесть в коде при создании изображения. Представляется, что такой случай является редким, и при наступлении такой ситуации можно просто сделать перезапуск и получить новый набор с ненулевым весом.

3. "Обратная аномалия"
При проведении тестов было замечено, что иногда все объекты являются ошибочными, хотя это в принципе невозможно, ведь даже если все объекты попадают на одну сторону от прямой, то объекты хотя бы одного класса должны быть правильными. Также ��ыло замечено, что иногда при совершении коррекции количество ошибочных объектов увеличивалось, и становилось, например, 6, или 8, хотя у нас в каждом классе по 5 объектов и такого не может быть по тем же соображениям. Все вставало на свои места, если во всем наборе поменять знаки весов. Тогда количество ошибок вместо 10 становилось нулевым, а вместо 6 становилось 4.

Я представляю это так.
Если поменять знаки весов во всем наборе, то графически сама прямая останется такой же Например, в случае весов [1.6 -1.6 0] и [-1.6 1.6 0] сама прямая визуально одна и та же, но сумматор выдает в этом случае значение с противоположным знаком, то есть вывод метки класса как бы наоборот. И получается, что "ошибочные" объекты" становятся "правильными". То есть меняя знак всех весов мы как будто крутим прямую на 180 градусов, при этом прямая остается той же, а вывод по метке класса меняет знак. Для меня это пока не совсем убедительная трактовка, но такая ситуация со знаками весов имеет место быть и я это учитываю в коде - в соответствующих случаях принудительно меняю знак всех весов и далее идет обычный процесс, как если бы инициация началась с этого набора.

Сделаем несколько запусков

Теперь с учетом выявленных особенностей сделаем циклом несколько запусков, например, 10.

Получаем 10 наборов весов:

[array([ 1.4, -0.8, 0.8]), 0] y = 1.75x + 1.0
[array([ 2.4, -1.1, -2.1]), 0] y = 2.1818x + -1.9091
[array([ 2.8, -1.2, -3.8]), 0] y = 2.3333x + -3.1667
[array([ 1. , -0.5, -3.1]), 0] y = 2.0x + -6.2
[array([ 0.4, -0.4, 6. ]), 0] y = 1.0x + 15.0
[array([ 1.8, -1. , -1.9]), 0] y = 1.8x + -1.9
[array([ 1.2, -0.8, 2.9]), 0] y = 1.5x + 3.625
[array([ 3.3, -1.9, 4.1]), 0] y = 1.7368x + 2.1579
[array([ 0.6, -0.5, 4.9]), 0] y = 1.2x + 9.8
[array([ 3.4, -2.3, 4.9]), 0] y = 1.4783x + 2.1304

Код обучения с учетом выявленных особенностей
# Код без картинок

import random
from tqdm import tqdm

# Случайный выбор для коэффициентов
def random_w():
  return round((random.random() * 10 - 5),1)

N_repeat = 10                      # количество повторов

N = 500                             # максимальное число итераций
lmd = 0.2                           # шаг изменения веса

w_massive = []                      # массив для сохранения весов

for n_repeat in tqdm(range(N_repeat)):

  # Инициируем веса случайным образом
  w = np.array([random_w(), random_w(), random_w()])
  neuron = Neuron(w)

  # Считаем количество ошибок
  Q_current = Q(w)

  if Q_current == len(x_train) and w[1]:           # то есть все наоборот, меняем знак
    w = -w
    neuron = Neuron(w)
    Q_current = Q(w)

  # проходим итерации
  for n in range(N):
    if Q_current == 0: break

    # Выбираем случайным образом объект
    random_index = random.randint(0,len(x_train)-1)
    x = x_train[random_index]
    y = y_train[random_index]

    # Если вывод не совпадает с меткой класса
    if not compare(x,y):

      # выбираем вес случайным образом
      index = random.randint(0,len(w)-1)

      # Применяем знак метки для выбора изменения и меняем вес
      w[index] = round(w[index] + y*lmd,4)
      neuron = Neuron(w)

      Q_last = Q_current                        # Запоминаем текущее количество ошибок
      Q_current = Q(w)
      if Q_current == 0: break
      if Q_current > Q_last and w[1]:           # пошли не в ту сторону, меняем знак
        w = -w
        Q_current = Q(w)
  
  w_massive.append([w, Q_current])    

print()
Код вывода результатов
for key in w_massive:
  print(key, 'y = ' + str(round(-key[0][0]/key[0][1],4)) + 'x + ' + str(round(-key[0][2]/key[0][1],4)))

Выведем 10 прямых на график

Код создания изображения с ��есколькими прямыми
# Рисование линии
def draw_line(x,w,color):
  if w[1]:
    line_x = list(range(max(x[:, 0]) + 10))
    line_y = [-w[0]/w[1]*x - w[2]/w[1] for x in line_x]
    plt.plot(line_x, line_y, color=color)
  else:
    print('w1 = 0')

plt.scatter(x_0[:, 0], x_0[:, 1], color='blue')
plt.scatter(x_1[:, 0], x_1[:, 1], color='red')

for key in w_massive:
  draw_line(x_train, key[0], 'green')

plt.title('lmd=' + str(lmd) + '\n')
plt.xlim([0, max(x_train[:, 0]) + 10])
plt.ylim([0, max(x_train[:, 1]) + 10])
plt.ylabel("длина")
plt.xlabel("ширина")
plt.grid(True)
plt.show()

Видим, что все прямые корректно разделяют объекты и находятся в схожем диапазоне.

Если задать 100 повторений, то получим просто более плотное "закрашивание" диапазона

Результат

Итак, общий итоговый результат:
1. Один простой искусственный нейрон справляется с бинарной классификацией линейно-разделимого множества объектов.
2. Есть особенности, связанные с нулевыми значениями весов и инверсией знаков.

Примечание

Если заметите какие-либо неточности или явные нестыковки - пожалуйста, отметьте это в комментариях.