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

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