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

Код для исходных данных
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. Есть особенности, связанные с нулевыми значениями весов и инверсией знаков.
Примечание
Если заметите какие-либо неточности или явные нестыковки - пожалуйста, отметьте это в комментариях.
