Skip to content

Instantly share code, notes, and snippets.

@twobob
Created September 24, 2025 01:10
Show Gist options
  • Save twobob/ebeaf968cf2ad9bd708429965ba94f1c to your computer and use it in GitHub Desktop.
Save twobob/ebeaf968cf2ad9bd708429965ba94f1c to your computer and use it in GitHub Desktop.
show 2d linear separation with first priciples. no libs (except for rendering the gif)
5.1,3.5,1.4,0.2,Iris-setosa
4.9,3.0,1.4,0.2,Iris-setosa
4.7,3.2,1.3,0.2,Iris-setosa
4.6,3.1,1.5,0.2,Iris-setosa
5.0,3.6,1.4,0.2,Iris-setosa
5.4,3.9,1.7,0.4,Iris-setosa
4.6,3.4,1.4,0.3,Iris-setosa
5.0,3.4,1.5,0.2,Iris-setosa
4.4,2.9,1.4,0.2,Iris-setosa
4.9,3.1,1.5,0.1,Iris-setosa
5.4,3.7,1.5,0.2,Iris-setosa
4.8,3.4,1.6,0.2,Iris-setosa
4.8,3.0,1.4,0.1,Iris-setosa
4.3,3.0,1.1,0.1,Iris-setosa
5.8,4.0,1.2,0.2,Iris-setosa
5.7,4.4,1.5,0.4,Iris-setosa
5.4,3.9,1.3,0.4,Iris-setosa
5.1,3.5,1.4,0.3,Iris-setosa
5.7,3.8,1.7,0.3,Iris-setosa
5.1,3.8,1.5,0.3,Iris-setosa
5.4,3.4,1.7,0.2,Iris-setosa
5.1,3.7,1.5,0.4,Iris-setosa
4.6,3.6,1.0,0.2,Iris-setosa
5.1,3.3,1.7,0.5,Iris-setosa
4.8,3.4,1.9,0.2,Iris-setosa
5.0,3.0,1.6,0.2,Iris-setosa
5.0,3.4,1.6,0.4,Iris-setosa
5.2,3.5,1.5,0.2,Iris-setosa
5.2,3.4,1.4,0.2,Iris-setosa
4.7,3.2,1.6,0.2,Iris-setosa
4.8,3.1,1.6,0.2,Iris-setosa
5.4,3.4,1.5,0.4,Iris-setosa
5.2,4.1,1.5,0.1,Iris-setosa
5.5,4.2,1.4,0.2,Iris-setosa
4.9,3.1,1.5,0.1,Iris-setosa
5.0,3.2,1.2,0.2,Iris-setosa
5.5,3.5,1.3,0.2,Iris-setosa
4.9,3.1,1.5,0.1,Iris-setosa
4.4,3.0,1.3,0.2,Iris-setosa
5.1,3.4,1.5,0.2,Iris-setosa
5.0,3.5,1.3,0.3,Iris-setosa
4.5,2.3,1.3,0.3,Iris-setosa
4.4,3.2,1.3,0.2,Iris-setosa
5.0,3.5,1.6,0.6,Iris-setosa
5.1,3.8,1.9,0.4,Iris-setosa
4.8,3.0,1.4,0.3,Iris-setosa
5.1,3.8,1.6,0.2,Iris-setosa
4.6,3.2,1.4,0.2,Iris-setosa
5.3,3.7,1.5,0.2,Iris-setosa
5.0,3.3,1.4,0.2,Iris-setosa
7.0,3.2,4.7,1.4,Iris-versicolor
6.4,3.2,4.5,1.5,Iris-versicolor
6.9,3.1,4.9,1.5,Iris-versicolor
5.5,2.3,4.0,1.3,Iris-versicolor
6.5,2.8,4.6,1.5,Iris-versicolor
5.7,2.8,4.5,1.3,Iris-versicolor
6.3,3.3,4.7,1.6,Iris-versicolor
4.9,2.4,3.3,1.0,Iris-versicolor
6.6,2.9,4.6,1.3,Iris-versicolor
5.2,2.7,3.9,1.4,Iris-versicolor
5.0,2.0,3.5,1.0,Iris-versicolor
5.9,3.0,4.2,1.5,Iris-versicolor
6.0,2.2,4.0,1.0,Iris-versicolor
6.1,2.9,4.7,1.4,Iris-versicolor
5.6,2.9,3.6,1.3,Iris-versicolor
6.7,3.1,4.4,1.4,Iris-versicolor
5.6,3.0,4.5,1.5,Iris-versicolor
5.8,2.7,4.1,1.0,Iris-versicolor
6.2,2.2,4.5,1.5,Iris-versicolor
5.6,2.5,3.9,1.1,Iris-versicolor
5.9,3.2,4.8,1.8,Iris-versicolor
6.1,2.8,4.0,1.3,Iris-versicolor
6.3,2.5,4.9,1.5,Iris-versicolor
6.1,2.8,4.7,1.2,Iris-versicolor
6.4,2.9,4.3,1.3,Iris-versicolor
6.6,3.0,4.4,1.4,Iris-versicolor
6.8,2.8,4.8,1.4,Iris-versicolor
6.7,3.0,5.0,1.7,Iris-versicolor
6.0,2.9,4.5,1.5,Iris-versicolor
5.7,2.6,3.5,1.0,Iris-versicolor
5.5,2.4,3.8,1.1,Iris-versicolor
5.5,2.4,3.7,1.0,Iris-versicolor
5.8,2.7,3.9,1.2,Iris-versicolor
6.0,2.7,5.1,1.6,Iris-versicolor
5.4,3.0,4.5,1.5,Iris-versicolor
6.0,3.4,4.5,1.6,Iris-versicolor
6.7,3.1,4.7,1.5,Iris-versicolor
6.3,2.3,4.4,1.3,Iris-versicolor
5.6,3.0,4.1,1.3,Iris-versicolor
5.5,2.5,4.0,1.3,Iris-versicolor
5.5,2.6,4.4,1.2,Iris-versicolor
6.1,3.0,4.6,1.4,Iris-versicolor
5.8,2.6,4.0,1.2,Iris-versicolor
5.0,2.3,3.3,1.0,Iris-versicolor
5.6,2.7,4.2,1.3,Iris-versicolor
5.7,3.0,4.2,1.2,Iris-versicolor
5.7,2.9,4.2,1.3,Iris-versicolor
6.2,2.9,4.3,1.3,Iris-versicolor
5.1,2.5,3.0,1.1,Iris-versicolor
5.7,2.8,4.1,1.3,Iris-versicolor
6.3,3.3,6.0,2.5,Iris-virginica
5.8,2.7,5.1,1.9,Iris-virginica
7.1,3.0,5.9,2.1,Iris-virginica
6.3,2.9,5.6,1.8,Iris-virginica
6.5,3.0,5.8,2.2,Iris-virginica
7.6,3.0,6.6,2.1,Iris-virginica
4.9,2.5,4.5,1.7,Iris-virginica
7.3,2.9,6.3,1.8,Iris-virginica
6.7,2.5,5.8,1.8,Iris-virginica
7.2,3.6,6.1,2.5,Iris-virginica
6.5,3.2,5.1,2.0,Iris-virginica
6.4,2.7,5.3,1.9,Iris-virginica
6.8,3.0,5.5,2.1,Iris-virginica
5.7,2.5,5.0,2.0,Iris-virginica
5.8,2.8,5.1,2.4,Iris-virginica
6.4,3.2,5.3,2.3,Iris-virginica
6.5,3.0,5.5,1.8,Iris-virginica
7.7,3.8,6.7,2.2,Iris-virginica
7.7,2.6,6.9,2.3,Iris-virginica
6.0,2.2,5.0,1.5,Iris-virginica
6.9,3.2,5.7,2.3,Iris-virginica
5.6,2.8,4.9,2.0,Iris-virginica
7.7,2.8,6.7,2.0,Iris-virginica
6.3,2.7,4.9,1.8,Iris-virginica
6.7,3.3,5.7,2.1,Iris-virginica
7.2,3.2,6.0,1.8,Iris-virginica
6.2,2.8,4.8,1.8,Iris-virginica
6.1,3.0,4.9,1.8,Iris-virginica
6.4,2.8,5.6,2.1,Iris-virginica
7.2,3.0,5.8,1.6,Iris-virginica
7.4,2.8,6.1,1.9,Iris-virginica
7.9,3.8,6.4,2.0,Iris-virginica
6.4,2.8,5.6,2.2,Iris-virginica
6.3,2.8,5.1,1.5,Iris-virginica
6.1,2.6,5.6,1.4,Iris-virginica
7.7,3.0,6.1,2.3,Iris-virginica
6.3,3.4,5.6,2.4,Iris-virginica
6.4,3.1,5.5,1.8,Iris-virginica
6.0,3.0,4.8,1.8,Iris-virginica
6.9,3.1,5.4,2.1,Iris-virginica
6.7,3.1,5.6,2.4,Iris-virginica
6.9,3.1,5.1,2.3,Iris-virginica
5.8,2.7,5.1,1.9,Iris-virginica
6.8,3.2,5.9,2.3,Iris-virginica
6.7,3.3,5.7,2.5,Iris-virginica
6.7,3.0,5.2,2.3,Iris-virginica
6.3,2.5,5.0,1.9,Iris-virginica
6.5,3.0,5.2,2.0,Iris-virginica
6.2,3.4,5.4,2.3,Iris-virginica
5.9,3.0,5.1,1.8,Iris-virginica
import random
import struct
import os
import statistics
from PIL import Image, ImageSequence
# ---------------------------
# Perceptron (2 features)
# ---------------------------
class SimplePerceptron:
def __init__(self, learning_rate=0.1, epochs=100, seed=1337):
self.learning_rate = learning_rate
self.epochs = epochs
self.weights = [0.0, 0.0]
self.bias = 0.0
self.errors = []
random.seed(seed)
def _step(self, x):
return 1 if x >= 0 else 0
def fit(self, X, y):
self.weights = [random.uniform(-1, 1), random.uniform(-1, 1)]
self.bias = random.uniform(-1, 1)
for epoch in range(self.epochs):
idx = list(range(len(X)))
random.shuffle(idx)
total_error = 0
for i in idx:
x1, x2 = X[i]
s = self.weights[0]*x1 + self.weights[1]*x2 + self.bias
pred = self._step(s)
err = y[i] - pred
if err != 0:
self.weights[0] += self.learning_rate * err * x1
self.weights[1] += self.learning_rate * err * x2
self.bias += self.learning_rate * err
total_error += 1
self.errors.append(total_error)
if total_error == 0:
break
def predict(self, X):
out = []
for x1, x2 in X:
s = self.weights[0]*x1 + self.weights[1]*x2 + self.bias
out.append(self._step(s))
return out
# ---------------------------
# Data
# ---------------------------
def load_iris_binary(filename="iris.data"):
X4, y = [], []
with open(filename, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(",")
feats = list(map(float, parts[:4]))
label = 1 if parts[4] == "Iris-setosa" else 0
X4.append(feats)
y.append(label)
return X4, y
def select_2d_features(X4, i1, i2):
return [[row[i1], row[i2]] for row in X4]
def minmax_normalize_2d(X2):
xs = [p[0] for p in X2]
ys = [p[1] for p in X2]
minx, maxx = min(xs), max(xs)
miny, maxy = min(ys), max(ys)
out = []
for x, y in X2:
nx = (x - minx) / (maxx - minx) if maxx > minx else 0.0
ny = (y - miny) / (maxy - miny) if maxy > miny else 0.0
out.append([nx, ny])
return out, (minx, maxx, miny, maxy)
def train_test_split(X, y, test_ratio=0.2, seed=1337):
random.seed(seed)
idx = list(range(len(X)))
random.shuffle(idx)
split = int(len(idx)*(1-test_ratio))
tr = idx[:split]
te = idx[split:]
Xtr = [X[i] for i in tr]
ytr = [y[i] for i in tr]
Xte = [X[i] for i in te]
yte = [y[i] for i in te]
return Xtr, ytr, Xte, yte
# ---------------------------
# BMP writer (writes rows bottom-up)
# ---------------------------
def save_bmp_image(filename, pixel_data, width, height):
row_padding = (4 - (width * 3) % 4) % 4
row_size = width * 3 + row_padding
pixel_array_size = row_size * height
file_size = 54 + pixel_array_size
header = bytearray(54)
header[0:2] = b'BM'
header[2:6] = struct.pack('<I', file_size)
header[6:10] = b'\x00\x00\x00\x00'
header[10:14] = struct.pack('<I', 54)
header[14:18] = struct.pack('<I', 40)
header[18:22] = struct.pack('<I', width)
header[22:26] = struct.pack('<I', height)
header[26:28] = struct.pack('<H', 1)
header[28:30] = struct.pack('<H', 24)
header[30:34] = b'\x00\x00\x00\x00'
header[34:38] = struct.pack('<I', pixel_array_size)
header[38:54] = b'\x00' * 16
with open(filename, "wb") as f:
f.write(header)
for row in reversed(pixel_data):
for r, g, b in row:
f.write(bytes([b, g, r]))
f.write(b'\x00' * row_padding)
# ---------------------------
# Decision map (2D normalized)
# ---------------------------
def generate_prediction_grid_2d(model, X2_norm, y, width=200, height=200):
light_green = (144, 238, 144)
light_red = (255, 182, 193)
pixel_data = []
for j in range(height):
row = []
ny = j / (height - 1)
for i in range(width):
nx = i / (width - 1)
pred = model.predict([[nx, ny]])[0]
row.append(light_green if pred == 1 else light_red)
pixel_data.append(row)
for (nx, ny), label in zip(X2_norm, y):
px = int(round(nx * (width - 1)))
py = int(round(ny * (height - 1)))
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
xx = px + dx
yy = py + dy
if 0 <= xx < width and 0 <= yy < height:
pixel_data[yy][xx] = (0, 0, 255) if label == 1 else (0, 0, 0)
return pixel_data
# ---------------------------
# GIF Creation
# ---------------------------
def create_gif(image_files, gif_filename, duration=100):
if not image_files:
print("No images to create GIF.")
return
frames = [Image.open(f) for f in image_files]
frames[0].save(
gif_filename,
save_all=True,
append_images=frames[1:],
duration=duration,
loop=0
)
print(f"\n✔ GIF created: {gif_filename}")
# ---------------------------
# Main
# ---------------------------
if __name__ == "__main__":
NUM_RUNS = 100
F1, F2 = 2, 3
accuracies = []
epochs_trained = []
image_filenames = []
X4, y = load_iris_binary("iris.data")
X2 = select_2d_features(X4, F1, F2)
X2_norm, _ = minmax_normalize_2d(X2)
try:
for i in range(NUM_RUNS):
new_seed = random.randint(1, 999999)
Xtr, ytr, Xte, yte = train_test_split(X2_norm, y, test_ratio=0.2, seed=new_seed)
model = SimplePerceptron(learning_rate=0.1, epochs=100, seed=new_seed)
model.fit(Xtr, ytr)
preds = model.predict(Xte)
correct = sum(1 for p, a in zip(preds, yte) if p == a)
acc = 100.0 * correct / len(Xte)
accuracies.append(acc)
epochs_trained.append(len(model.errors))
pixels = generate_prediction_grid_2d(model, X2_norm, y)
img_filename = f"decision_map_{i:03d}.bmp"
save_bmp_image(img_filename, pixels, width=200, height=200)
image_filenames.append(img_filename)
create_gif(image_filenames, "perceptron_training.gif")
finally:
for f in image_filenames:
if os.path.exists(f):
os.remove(f)
# Output statistical summary
print("--- Perceptron Training Statistics (100 Runs) ---")
print("\nEpochs to Convergence:")
print(f" Average: {statistics.mean(epochs_trained):.2f}")
print(f" Min: {min(epochs_trained)}")
print(f" Max: {max(epochs_trained)}")
print(f" Std Dev: {statistics.stdev(epochs_trained):.2f}" if len(epochs_trained) > 1 else " Std Dev: N/A")
print("\nTest Set Accuracy:")
print(f" Average: {statistics.mean(accuracies):.2f}%")
print(f" Min: {min(accuracies):.2f}%")
print(f" Max: {max(accuracies):.2f}%")
print(f" Std Dev: {statistics.stdev(accuracies):.2f}%" if len(accuracies) > 1 else " Std Dev: N/A")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment