|
| 1 | +^{:kindly/hide-code true |
| 2 | + :clay {:title "Learn linear least squares with me" |
| 3 | + :quarto {:author [:ameinel] |
| 4 | + :type :post |
| 5 | + :date "2026-08-26" |
| 6 | + :category :clojure |
| 7 | + :tags [:clojure.math]}}} |
| 8 | + |
1 | 9 | (ns math.stats.regression.linear |
| 10 | + |
2 | 11 | (:require |
3 | 12 | [fastmath.random :as rand] |
4 | 13 | [fastmath.ml.regression :as reg] |
5 | | - [math.stats.regression.mat :as mat] |
6 | 14 | [scicloj.tableplot.v1.plotly :as plotly] |
7 | 15 | [tablecloth.api :as tc])) |
8 | 16 |
|
9 | | -;; # Intro: The OG machine learning algorithm: Linear regression aka Ordinary least squares |
| 17 | + |
| 18 | +;; # Intro |
10 | 19 |
|
11 | 20 | ;; > This is my attempt at refreshing my knowledge of pretty basic concepts by creating educational posts about them. Feynman style! |
12 | 21 |
|
13 | 22 | ;; Linear regression is a solved problem. If you want to use it to solve a real world problem, |
14 | 23 | ;; you probably want to look at [this article](https://scicloj.github.io/noj/noj_book.linear_regression_intro.html). |
15 | 24 | ;; The code you see here will be optimized for readability and understanding rather than efficiency and stability. |
16 | 25 | ;; Pre-requisites: |
17 | | -;; - Basic linear algebra and calculus |
| 26 | +;; - Linear algebra and calculus, roughly high-school level |
18 | 27 | ;; - A basic understanding of why you would want to draw a straight line through a bunch of points |
19 | | -;; - Basic clojure fluency (nothing fancy really, I promise) |
| 28 | +;; - Clojure fluency (nothing fancy really, I promise) |
20 | 29 |
|
21 | 30 | ;; In this post I want to help people, including me, to bridge the gap between theory and practice. I tend to forget how exactly linear regression works and, |
22 | 31 | ;; given that it is supposed to be simple, this bugs me. What I often do in such a moment is look at the [Wikipedia page](https://en.wikipedia.org/wiki/Linear_regression). |
23 | 32 | ;; This often helps to give me a rough idea, but if I had to explain to a five-year-old (that happens to have a basic mathematical education) how exactly it works, I would probably be in trouble. This is my attempt to remedy that - with the added pressure of a very public audience, so I don't quit halfway. |
24 | 33 |
|
25 | 34 | ;; > NOTE: There is some debate about whether linear regression and ordinary (linear) least squares are truly the same. |
26 | | -;; > Comments are always welcome, I am happy to learn. However, in both cases, the concept seems to revolve about minimising some error, so if you don't like the naming I chose, |
| 35 | +;; > Comments are always welcome, I am happy to learn. However, in both cases, the concept seems to revolve around minimising some error, so if you don't like the naming I chose, |
27 | 36 | ;; > you may just as well imagine this post was called "minimising the error" or something. |
28 | 37 |
|
29 | 38 | ;; # Let's get started |
30 | 39 | ;; We're going to ignore the statistical interpretation entirely and just look at this from the perspective of basic high-school math. |
31 | 40 | ;; |
32 | 41 | ;; We start with a linear equation system: |
33 | | -;; $$\mathbf{y} = \mathbf{X}\boldsymbol{\beta} + \boldsymbol{\epsilon}$$ |
| 42 | +;; $$\mathbf{y} = \mathbf{X}\boldsymbol{\beta}$$ |
34 | 43 | ;; where $y$ is our |
35 | 44 | ;; dependent variable (the stuff that goes on the vertical axis) and $X$ is our |
36 | 45 | ;; independent data (the "inputs", the stuff that goes on the x-axis). |
37 | | -;; $\beta$ is our parameters, fancy people nowadays call this the "model weights", |
38 | | -;; $\epsilon$ is an error term. We ignore entirely what that means, except for |
39 | | -;; acknowledging that we want to get rid of it. Since that doesn't always work |
40 | | -;; with real data (because X is not always invertible), we want to at least make |
41 | | -;; it as small as possible, i.e. we want to find $$\hat{\boldsymbol{\beta}} = |
42 | | -;; \arg\min_{\boldsymbol{\beta}} \|\mathbf{y} - \mathbf{X}\boldsymbol{\beta}\|^2 |
| 46 | +;; $\beta$ is our parameters, fancy people nowadays call this the "model weights". |
| 47 | + |
| 48 | +;; In our simple 1D case, this expands to: |
| 49 | +;; |
| 50 | +;; $$ |
| 51 | +;; \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_n \end{bmatrix} |
| 52 | +;; = |
| 53 | +;; \begin{bmatrix} 1 & x_1 \\ 1 & x_2 \\ \vdots & \vdots \\ 1 & x_n \end{bmatrix} |
| 54 | +;; \begin{bmatrix} \beta_0 \\ \beta_1 \end{bmatrix} |
| 55 | +;; $$ |
| 56 | + |
| 57 | +;; The column of ones gives us the intercept $\beta_0$. Without it, we would be forcing the line through the origin, which is rarely what we want. |
| 58 | + |
| 59 | +;; This can't always be solved with real data in X. |
| 60 | + |
| 61 | +;; So the best we can do, if we must produce something close to a solution, |
| 62 | +;; is to minimize the error. We will call that $S$ here. |
| 63 | +;; The error is the distance between the left hand side and the right hand side of our equation system, |
| 64 | +;; so $$ S(\beta) =\|\mathbf{y} - \mathbf{X}\boldsymbol{\beta}\|^2 $$ |
| 65 | +;; To make it as small as possible, we want to find $$\hat{\boldsymbol{\beta}} = |
| 66 | +;; \arg\min_{\boldsymbol{\beta}} S(\beta) |
43 | 67 | ;; $$ |
44 | | -;; TODO: Where did $\epsilon$ go? |
45 | 68 |
|
46 | 69 | ;; > In case you don't remember: The double-pipe-symbol stands for the norm of a |
47 | 70 | ;; > vector, which is a fancy word for "thing that behaves a bit like a length". |
48 | 71 | ;; > In our simple case we use this one here, which behaves pretty much exactly |
49 | | -;; > like a lenghth as a human would imagine it visually. For a vector $v\in |
| 72 | +;; > like a length as a human would imagine it visually. For a vector $v\in |
50 | 73 | ;; > \mathbb{R}^n$, it is defined like this: ;; $$\|\mathbf{v}\|^2 = |
51 | 74 | ;; > \sum_{i=1}^{n} |v_i|^2 = \sum_{i=1}^{n} v_i^2$$ |
52 | 75 |
|
53 | | - |
54 | 76 | ;; From school, we know that an extreme point of a function can be found where |
55 | 77 | ;; its slope is 0. To find the slope of a function, we calculate its derivative. |
56 | 78 | ;; We can then set that to zero, which will help us find an extreme point |
|
64 | 86 | ;; are allowed to assume that that we will find a minimum by taking the |
65 | 87 | ;; derivative of our error function and setting that to zero. |
66 | 88 |
|
67 | | - |
68 | 89 | ;; Now, how do we take the derivative of a function that takes a vector as an argument and returns a scalar? |
69 | 90 | ;; We calculate the derivative of each entry! |
70 | 91 | ;; To do that with the error function, let's first take a look at what entry k looks like: |
71 | 92 | ;;$$ S(\beta_1, \beta_2, \ldots, \beta_p) = \sum_{i=1}^{n} \left( y_i - \sum_{j=1}^{p} X_{ij} \beta_j \right)^2$$ |
72 | 93 |
|
| 94 | +;; > I deliberately switch between a single $\beta$ as an argument to S and several indexed $\beta_k$. Mathematically this doesn't make a huge difference and I use whichever fits what I want to illustrate best in this article. Usually I prefer to put as much information as possible into one function argument, but for this illustration this would be a bit unwieldy. |
| 95 | +;; > If we write the full function signature, with domain and range, we see that in both cases $S:\mathbb{R}^p \longrightarrow \mathbb{R} |
| 96 | + |
73 | 97 | ;; Now we calculate the derivative by just one of the components of the vector $\vec{\beta}$ |
74 | 98 | ;;$$\frac{\partial S}{\partial \beta_k} = -2 \sum_{i=1}^{n} X_{ik} \left( y_i - \sum_{j=1}^{p} X_{ij} \beta_j \right)$$ |
75 | 99 |
|
|
80 | 104 | ;; To be able to zoom out again, I am going to turn this back into matrix-vector notation (trust me, this is going to be more readable than this index-madness): |
81 | 105 | ;; $$ X^T y = X^TX\bf{\beta}$$ |
82 | 106 |
|
83 | | -;; Which, conceptionally means we get $\beta$ via inverting $X^TX$: |
| 107 | +;; > We arrive at the matrix notation by just writing all the equations (every $k$, from 1 to $p$) down. Look up matrix multiplication on wikipedia if you are lost here. |
| 108 | +;; > If you wonder where the transposition ($X^T$) comes from: No worries, this trips me up all the time as well. If you write everything out by hand as mentioned before, you will see it, because it is the only way the dimensions match. But if you want to just read on, it is ok to trust me on this for now. |
| 109 | + |
| 110 | +;; Which, conceptually means we get $\beta$ via inverting $X^TX$: |
84 | 111 | ;; $$ (X^TX)^{-1}X^Ty=\beta$$ |
85 | 112 |
|
86 | | -;;> I say conceptionally because for production usage you wouldn't want to invert the matrix to solve this - you'd much rather solve the equation system. |
87 | | -;;> For educational purposes I wont dive deeper into computational details. We indeed commit the felony of assuming that math works on a computer exactly as it does on paper to aid our learning journey. |
| 113 | +;;> I say conceptually because for production usage you wouldn't want to invert the matrix to solve this - you'd much rather solve the equation system. |
| 114 | +;;> For educational purposes I won't dive deeper into computational details. We indeed commit the felony of assuming that math works on a computer exactly as it does on paper to aid our learning journey. If you need to solve equation systems like these in production, look into QR-Decomposition, SVD or similar - and probably don't build it yourself. |
88 | 115 | ;; |
89 | 116 | ;; |
90 | 117 |
|
91 | | - |
92 | | - |
93 | | - |
94 | | - |
95 | | - |
96 | | - |
97 | | - |
98 | 118 | ;; # Enough maths, show me code! |
99 | 119 |
|
100 | | - |
101 | | - |
102 | 120 | ;; To prove, or at least make it plausible, that the above theory works in principle, we need some kind of benchmark, |
103 | 121 | ;; For that purpose, we are going to try and reproduce the results from [this scicloj docs page](https://scicloj.github.io/noj/noj_book.linear_regression_intro.html). |
104 | 122 | ;; So lets just copy some code from there to generate data: |
105 | 123 |
|
106 | 124 |
|
107 | 125 |
|
| 126 | + |
| 127 | + |
108 | 128 | (def simple-linear-data |
109 | 129 | (let [rng (rand/rng 1234) |
110 | 130 | n 50 |
|
123 | 143 | plotly/layer-point) |
124 | 144 |
|
125 | 145 |
|
| 146 | + |
126 | 147 | ;; We don't care for all the fancy stuff that gives us, we want plain clojure data structures! |
127 | 148 |
|
128 | 149 |
|
|
135 | 156 | (def ydata (-> simple-linear-data |
136 | 157 | :y |
137 | 158 | vec)) |
138 | | -;; Since we just have one independent variable, it is just a vector (a 1xm Matrix). |
139 | | - |
140 | | - ;; TODO But for some reason , its allowed (and necessary) to put a column of ones before that. (In fact TODO: that can be mathematically justified) |
141 | | - |
142 | | -;; So actually we want |
| 159 | +;; Since we just have one independent variable, x is just a vector (a 1xm Matrix). |
| 160 | +;; That is pretty inconvenient, because then $X^TX$ would just be a number. |
| 161 | +;; Luckily, to put a line through our data, we need to find the intercept too, as explained above. |
| 162 | +;; That means we need to put a column of ones in front of our data: |
143 | 163 |
|
144 | 164 | (def X (mapv #(vector 1.0 %) xdata)) |
145 | 165 |
|
146 | | -(count X) |
| 166 | +(take 5 X) |
| 167 | +;; Phew, back in matrix-land. |
147 | 168 |
|
148 | | -;; We need to build $X^TX$ first. |
| 169 | +;; We are going to build $X^TX$ now. |
149 | 170 | ;; I am making quite a few simplifications here, that matter a lot, but for not turning this too much into a math lecture I will just mention them: |
150 | 171 | ;; - Unit vectors as basis |
151 | 172 | ;; - Inner product is $x\cdot y= \sum_{i=1}^{n}(x_i*y_i)$ |
|
155 | 176 | ;; matrix computation library: |
156 | 177 |
|
157 | 178 |
|
| 179 | +;; We're going to do quite a bit of transposition, so a function that does that for us will come in handy: |
158 | 180 | (defn transpose [matrix] |
159 | 181 | (apply mapv vector matrix)) |
160 | 182 |
|
161 | 183 | (transpose [[1 2 3] [4 5 6]]) |
| 184 | +;; Great, transposition works. |
162 | 185 |
|
| 186 | +;; Now the inner product. |
163 | 187 | (defn inner |
164 | 188 | "Inner product of two vectors. |
165 | 189 |
|
|
210 | 234 | (apply-matrix-to-vector [[1 0] [0 1]] [3 5]) |
211 | 235 |
|
212 | 236 | ;; Finally we can get that $\hat{\beta}$. |
213 | | -(let [y ydata |
214 | | - xtx (mult (transpose X) X) |
215 | | - xtxinv (invert xtx) |
216 | | - xtxinvxt (mult xtxinv (transpose X)) |
217 | | - betahat (apply-matrix-to-vector xtxinvxt y)] |
218 | | - betahat) |
219 | | - |
220 | | -;; Of course those two numbers alone are a bit hard to judge. |
221 | | -;; Lets plot against the benchmark: |
222 | | -(def simple-linear-data-model |
223 | | - (reg/lm |
224 | | - ;; ys - a "column" sequence of `y` values: |
225 | | - (simple-linear-data :y) |
226 | | - ;; xss - a sequence of "rows", each containing `x` values: |
227 | | - ;; (one `x` per row, in our case): |
228 | | - (-> simple-linear-data |
229 | | - (tc/select-columns [:x]) |
230 | | - tc/rows) |
231 | | - ;; options |
232 | | - {:names ["x"]})) |
| 237 | +(def betahat |
| 238 | + (let [y ydata |
| 239 | + xtx (mult (transpose X) X) |
| 240 | + xtxinv (invert xtx) |
| 241 | + xtxinvxt (mult xtxinv (transpose X))] |
| 242 | + (apply-matrix-to-vector xtxinvxt y))) |
| 243 | + |
| 244 | + |
| 245 | +;; Now let's see how our handmade OLS stacks up against the production model. |
| 246 | +;; We compute predictions from our $\hat{\beta}$ and overlay both fits. |
| 247 | + |
| 248 | +(def my-predictions |
| 249 | + (let [[b0 b1] betahat] |
| 250 | + (tc/dataset {:x xdata |
| 251 | + :y-hat (mapv #(+ b0 (* b1 %)) xdata)}))) |
233 | 252 |
|
| 253 | +;; For contrast, a deliberately wrong model — just to prove the plot is real: |
| 254 | +(def wrong-predictions |
| 255 | + (tc/dataset {:x xdata |
| 256 | + :y-hat (mapv #(+ 0 (* -1 %)) xdata)})) |
234 | 257 |
|
235 | 258 | (-> simple-linear-data |
236 | 259 | (plotly/layer-point {:=name "data"}) |
237 | | - (plotly/layer-smooth {:=name "prediction"})) |
| 260 | + (plotly/layer-smooth {:=name "fastmath"}) |
| 261 | + (plotly/layer-line {:=dataset my-predictions |
| 262 | + :=x :x |
| 263 | + :=y :y-hat |
| 264 | + :=name "handmade OLS" |
| 265 | + :=mark-color "red" |
| 266 | + :=mark-opacity 0.7}) |
| 267 | + (plotly/layer-line {:=dataset wrong-predictions |
| 268 | + :=x :x |
| 269 | + :=y :y-hat |
| 270 | + :=name "obviously wrong" |
| 271 | + :=mark-color "orange" |
| 272 | + :=mark-opacity 0.7})) |
| 273 | + |
| 274 | +;; As we can see from the plot, in this case my naive implementation matches the serious implementation pretty closely. |
| 275 | +;; I couldn't believe it myself, so I included a totally wrong line, just to make sure that there's no error in the visualization. |
| 276 | +;; Since the data is created on the fly, ymmv. If the random number generator creates weird data, this might break my algorithm, while fastmath might be able to handle it. (Challenge: Can you create data that will make the lines look visibly different?) |
| 277 | +;;That's it for today. You should now no longer have to wonder how to go from data and functions to vectors and matrices - in this simple case at least. |
| 278 | +;; # Follow-up ideas |
| 279 | +;; Writing this was fun. I might do it again, with slight variations, such as: |
| 280 | +;; - Weaker assumptions -> transition to nonlinear problems (with the someday-perspective of looking at neural networks and other "modern" stuff) |
| 281 | +;; - Extensions of the method: Regularization, uncertainty quantification. |
| 282 | +;; - More abstract explanation: This one basically already started with finite-dimensional vector spaces. In my opinion, the whole topic looks much prettier as orthogonal projection to a subspace of a Hilbert space. |
| 283 | +;; - Naive FEM in clojure,to not lock ourselves too much into the data science perspective and explore more of an engineering/computational science perspective. |
| 284 | + |
| 285 | +;; Feel free to reach out to me on the Clojurians Slack (@Snuffles) if you want to discuss this post or suggest a follow-up topic. |
| 286 | + |
| 287 | +;; # More resources |
| 288 | +;; * https://dragan.rocks/articles/17/Clojure-Numerics-1-Use-Matrices-Efficiently - If you want to do this properly, computation-wise |
| 289 | +;; * https://linear.axler.net/LADR4e.pdf - My favourite linear algebra book. Available free online. |
| 290 | +;; * https://scicloj.github.io/noj/ - Another great library (that I seemingly used here for benchmarking, even though I did not truly know what I was doing) |
0 commit comments