|
| 1 | +^{:kindly/hide-code true |
| 2 | + :clay {:title "Machine learning using Clojure, libpython-clj2, and Pytorch" |
| 3 | + :external-requirements [] |
| 4 | + :quarto {:author [:janwedekind] |
| 5 | + :draft true |
| 6 | + :description "Machine learning explained using the parabola example" |
| 7 | + :image "parabola.png" |
| 8 | + :type :post |
| 9 | + :date "2026-05-24" |
| 10 | + :category :ml |
| 11 | + :tags [:machine-learning]}}} |
| 12 | + |
| 13 | +(ns mlp.main |
| 14 | + (:require [clojure.math :refer (PI cos sin exp to-radians)] |
| 15 | + [tablecloth.api :as tc] |
| 16 | + [scicloj.tableplot.v1.plotly :as plotly] |
| 17 | + [libpython-clj2.require :refer (require-python)] |
| 18 | + [libpython-clj2.python :refer (py.) :as py])) |
| 19 | + |
| 20 | + |
| 21 | +(require-python '[torch :as torch] |
| 22 | + '[torch.nn :as nn] |
| 23 | + '[torch.utils.data :as data] |
| 24 | + '[torch.nn.functional :as F] |
| 25 | + '[torch.optim :as optim]) |
| 26 | + |
| 27 | +(torch/manual_seed 42) |
| 28 | + |
| 29 | +(def ParabolaNet |
| 30 | + (py/create-class |
| 31 | + "ParabolaNet" [nn/Module] |
| 32 | + {"__init__" |
| 33 | + (py/make-instance-fn |
| 34 | + (fn [self n-hidden] |
| 35 | + (py. nn/Module __init__ self) |
| 36 | + (py/set-attrs! |
| 37 | + self |
| 38 | + {"fc1" (nn/Linear 1 n-hidden) |
| 39 | + "fc2" (nn/Linear n-hidden n-hidden) |
| 40 | + "fc3" (nn/Linear n-hidden n-hidden) |
| 41 | + "fc4" (nn/Linear n-hidden 1)}) |
| 42 | + nil)) |
| 43 | + "forward" |
| 44 | + (py/make-instance-fn |
| 45 | + (fn [self x] |
| 46 | + (let [x (py. self fc1 x) |
| 47 | + x (F/sigmoid x) |
| 48 | + x (py. self fc2 x) |
| 49 | + x (F/sigmoid x) |
| 50 | + x (py. self fc3 x) |
| 51 | + x (F/sigmoid x) |
| 52 | + x (py. self fc4 x)] |
| 53 | + x)))})) |
| 54 | + |
| 55 | +(defmacro without-gradient |
| 56 | + [& body] |
| 57 | + `(let [no-grad# (torch/no_grad)] |
| 58 | + (try |
| 59 | + (py. no-grad# ~'__enter__) |
| 60 | + ~@body |
| 61 | + (finally |
| 62 | + (py. no-grad# ~'__exit__ nil nil nil))))) |
| 63 | + |
| 64 | +(def extent 6.0) |
| 65 | +(def n 32) |
| 66 | +(def noise 1.0) |
| 67 | +(def features (torch/sub (torch/mul (torch/rand [n 1]) (* 2 extent)) extent)) |
| 68 | +(def labels (torch/add (torch/mul features features) (torch/mul noise (torch/randn [n 1])))) |
| 69 | + |
| 70 | +(def dataset (data/TensorDataset features labels)) |
| 71 | + |
| 72 | +(def train-size (int (* 0.8 n))) |
| 73 | +(def dev-size (int (* 0.1 n))) |
| 74 | +(def test-size (- n train-size dev-size)) |
| 75 | + |
| 76 | +(def splits (data/random_split dataset [train-size dev-size test-size])) |
| 77 | +(def train-ds (nth splits 0)) |
| 78 | +(def dev-ds (nth splits 1)) |
| 79 | +(def test-ds (nth splits 2)) |
| 80 | + |
| 81 | +(def train-data-loader (data/DataLoader train-ds :batch_size 4 :shuffle true)) |
| 82 | +(def dev-data-loader (data/DataLoader dev-ds :batch_size 4 :shuffle true)) |
| 83 | + |
| 84 | +(defn average [numbers] |
| 85 | + (/ (reduce + numbers) (count numbers))) |
| 86 | + |
| 87 | +(defn train-epoch |
| 88 | + [train-data-loader criterion model optimizer] |
| 89 | + (py. model train) |
| 90 | + (for [[features labels] train-data-loader] |
| 91 | + (do |
| 92 | + (py. optimizer zero_grad) |
| 93 | + (let [prediction (py. model __call__ features) |
| 94 | + loss (py. criterion __call__ prediction labels)] |
| 95 | + (py. loss backward) |
| 96 | + (py. optimizer step) |
| 97 | + (py. loss item))))) |
| 98 | + |
| 99 | +(defn dev-epoch |
| 100 | + [dev-data-loader criterion model] |
| 101 | + (py. model eval) |
| 102 | + (without-gradient |
| 103 | + (for [[features labels] dev-data-loader] |
| 104 | + (let [prediction (py. model __call__ features) |
| 105 | + loss (py. criterion __call__ prediction labels)] |
| 106 | + (py. loss item))))) |
| 107 | + |
| 108 | +(defn training-run |
| 109 | + [train-data-loader dev-data-loader epochs n-hidden lr] |
| 110 | + (let [model (ParabolaNet n-hidden) |
| 111 | + optimizer (optim/SGD (py. model "parameters") :lr lr :weight_decay 0.0) |
| 112 | + criterion (nn/MSELoss)] |
| 113 | + (loop [epoch 1 train-losses [] dev-losses []] |
| 114 | + (let [train-loss (average (train-epoch train-data-loader criterion model optimizer)) |
| 115 | + dev-loss (average (dev-epoch dev-data-loader criterion model))] |
| 116 | + (if (< epoch epochs) |
| 117 | + (recur (inc epoch) (conj train-losses train-loss) (conj dev-losses dev-loss)) |
| 118 | + {:model model :train-losses (conj train-losses train-loss) :dev-losses (conj dev-losses dev-loss)}))))) |
| 119 | + |
| 120 | +(def result (training-run train-data-loader dev-data-loader 5000 200 0.01)) |
| 121 | + |
| 122 | +(defn plot-model |
| 123 | + [features labels {:keys [model]}] |
| 124 | + (without-gradient |
| 125 | + (let [x (range (- extent) (+ extent 0.01) 0.01) |
| 126 | + y (map (fn [x] (py. (first (py. model __call__ (torch/tensor [x]))) item)) x) |
| 127 | + ds (tc/dataset {:x x :y y}) |
| 128 | + pts (tc/dataset {:x (map first (py/->jvm (py. features tolist))) |
| 129 | + :y (map first (py/->jvm (py. labels tolist)))})] |
| 130 | + (-> ds |
| 131 | + (plotly/base {:=title "Model"}) |
| 132 | + (plotly/layer-point {:=dataset pts :=x :x :=y :y :=name "data"}) |
| 133 | + (plotly/layer-line {:=x :x :=y :y :=name "prediction"}))))) |
| 134 | + |
| 135 | + |
| 136 | + |
| 137 | +(defn smoothing |
| 138 | + [alpha] |
| 139 | + (fn [coll] |
| 140 | + (reductions (fn [prev-avg current] (+ (* alpha prev-avg) (* (- 1 alpha) current))) |
| 141 | + (first coll) |
| 142 | + (rest coll)))) |
| 143 | + |
| 144 | + |
| 145 | +(plot-model features labels result) |
| 146 | + |
| 147 | +(defn plot-losses |
| 148 | + [{:keys [train-losses dev-losses]} smoothing-fn] |
| 149 | + (-> (tc/dataset {:x (range 1 (count train-losses)) :y (smoothing-fn train-losses)}) |
| 150 | + (plotly/base {:=title "Losses"}) |
| 151 | + (plotly/layer-line {:=x :x :=y :y :=name "training loss"}) |
| 152 | + (plotly/layer-line {:=dataset (tc/dataset {:x (range 1 (count dev-losses)) :y (smoothing-fn dev-losses)}) |
| 153 | + :=x :x :=y :y :=name "dev loss"}))) |
| 154 | + |
| 155 | +(plot-losses result (smoothing 0.99)) |
0 commit comments