Created
September 24, 2025 01:10
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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