|
| 1 | +import thumby |
| 2 | +import time |
| 3 | +import math |
| 4 | +import random |
| 5 | +import gc |
| 6 | + |
| 7 | +# |
| 8 | +# -------------------------------------------------------------------- |
| 9 | +# FONT & SCREEN SETUP |
| 10 | +# -------------------------------------------------------------------- |
| 11 | +# Thumby includes a 72x40 pixel display. The 3x5 font in "/lib/font3x5.bin" |
| 12 | +# saves space when drawing text on screen. |
| 13 | +thumby.display.setFont("/lib/font3x5.bin", 3, 5, 1) |
| 14 | +SCREEN_W = 72 |
| 15 | +SCREEN_H = 40 |
| 16 | + |
| 17 | +# Each 3x5 character occupies about 4 pixels in width (3 for the glyph + 1 spacing), |
| 18 | +# so a single line can hold ~18 characters in 72 pixels of width. |
| 19 | +MAX_CHARS_PER_LINE = 18 |
| 20 | + |
| 21 | +# |
| 22 | +# -------------------------------------------------------------------- |
| 23 | +# DATASET: CELSIUS -> FAHRENHEIT (21 SAMPLES) |
| 24 | +# -------------------------------------------------------------------- |
| 25 | +# Celsius values range from 0 to 100 in steps of 5, producing a list of 21 total |
| 26 | +# samples as inputs for training. |
| 27 | +# |
| 28 | +# Fahrenheit is computed by F = (9/5)*C + 32, producing a corresponding list of 21 |
| 29 | +# samples as targets for training. |
| 30 | +# |
| 31 | +# The network sees only the numeric training pairs. |
| 32 | +c_values = list(range(0, 101, 5)) |
| 33 | +f_values = [(9/5)*c + 32 for c in c_values] |
| 34 | + |
| 35 | +# Normalization is required because the hidden layer uses sigmoid, |
| 36 | +# which expects inputs and outputs in or near [0..1]. |
| 37 | +def normalize_c(c): |
| 38 | + return c / 100.0 |
| 39 | + |
| 40 | +def normalize_f(f): |
| 41 | + return (f - 32.0) / 180.0 |
| 42 | + |
| 43 | +def denormalize_f(fn): |
| 44 | + return fn * 180.0 + 32.0 |
| 45 | + |
| 46 | +# Build the training set by normalizing c_values and f_values. |
| 47 | +training_inputs = [[normalize_c(c)] for c in c_values] |
| 48 | +training_targets = [[normalize_f(f)] for f in f_values] |
| 49 | + |
| 50 | +# |
| 51 | +# -------------------------------------------------------------------- |
| 52 | +# NEURAL NETWORK: 1 -> 5 -> 1 (LINEAR OUTPUT) |
| 53 | +# -------------------------------------------------------------------- |
| 54 | +# This network has: |
| 55 | +# - 1 input neuron (normalized C) |
| 56 | +# - 5 hidden neurons (using sigmoid) |
| 57 | +# - 1 output neuron (using a linear activation) |
| 58 | +# The final output is unconstrained, which helps a linear mapping like C->F. |
| 59 | +INPUT_SIZE = 1 |
| 60 | +HIDDEN_SIZE = 5 |
| 61 | +OUTPUT_SIZE = 1 |
| 62 | + |
| 63 | +W1 = [] |
| 64 | +B1 = [] |
| 65 | +W2 = [] |
| 66 | +B2 = [] |
| 67 | + |
| 68 | +# Training parameters |
| 69 | +LEARNING_RATE = 0.2 # Lower rate for stability |
| 70 | +EPOCHS = 500 # Increase to improve convergence |
| 71 | + |
| 72 | +def init_network(): |
| 73 | + # Initializes all weights/biases randomly in [-1, 1]. |
| 74 | + # Runs garbage collection to free memory on the Thumby. |
| 75 | + global W1, B1, W2, B2 |
| 76 | + gc.collect() |
| 77 | + |
| 78 | + def rand_weight(): |
| 79 | + return random.uniform(-1, 1) |
| 80 | + |
| 81 | + # W1 is (HIDDEN_SIZE x INPUT_SIZE), B1 is (HIDDEN_SIZE,) |
| 82 | + W1 = [[rand_weight() for _ in range(INPUT_SIZE)] for _ in range(HIDDEN_SIZE)] |
| 83 | + B1 = [rand_weight() for _ in range(HIDDEN_SIZE)] |
| 84 | + |
| 85 | + # W2 is (OUTPUT_SIZE x HIDDEN_SIZE), B2 is (OUTPUT_SIZE,) |
| 86 | + W2 = [[rand_weight() for _ in range(HIDDEN_SIZE)] for _ in range(OUTPUT_SIZE)] |
| 87 | + B2 = [rand_weight() for _ in range(OUTPUT_SIZE)] |
| 88 | + |
| 89 | + gc.collect() |
| 90 | + |
| 91 | +def sigmoid(x): |
| 92 | + # Applies a sigmoid clamp to avoid overflow on exponent. |
| 93 | + if x > 10: |
| 94 | + x = 10 |
| 95 | + if x < -10: |
| 96 | + x = -10 |
| 97 | + return 1.0 / (1.0 + math.e**(-x)) |
| 98 | + |
| 99 | +def dsigmoid(y): |
| 100 | + # Computes derivative of sigmoid if y=sigmoid(x). |
| 101 | + return y * (1.0 - y) |
| 102 | + |
| 103 | +# |
| 104 | +# -------------------------------------------------------------------- |
| 105 | +# FORWARD PASS (HIDDEN: SIGMOID, OUTPUT: LINEAR) |
| 106 | +# -------------------------------------------------------------------- |
| 107 | +def forward_pass(x): |
| 108 | + # Passes input x through the hidden layer using sigmoid, |
| 109 | + # then computes a linear output with no final sigmoid. |
| 110 | + global W1, B1, W2, B2 |
| 111 | + |
| 112 | + # Hidden layer |
| 113 | + h = [] |
| 114 | + for i in range(HIDDEN_SIZE): |
| 115 | + sum_h = B1[i] |
| 116 | + for j in range(INPUT_SIZE): |
| 117 | + sum_h += W1[i][j] * x[j] |
| 118 | + h.append(sigmoid(sum_h)) |
| 119 | + |
| 120 | + # Output layer (linear) |
| 121 | + o = [] |
| 122 | + for i in range(OUTPUT_SIZE): |
| 123 | + sum_o = B2[i] |
| 124 | + for j in range(HIDDEN_SIZE): |
| 125 | + sum_o += W2[i][j] * h[j] |
| 126 | + o.append(sum_o) # raw linear output |
| 127 | + return h, o |
| 128 | + |
| 129 | +# |
| 130 | +# -------------------------------------------------------------------- |
| 131 | +# BACKPROP (FINAL LAYER IS LINEAR) |
| 132 | +# -------------------------------------------------------------------- |
| 133 | +def backprop(x, h, o, t): |
| 134 | + # Adjusts weights/biases in two stages: |
| 135 | + # 1) Linear output error |
| 136 | + # 2) Sigmoid hidden layer error |
| 137 | + global W1, B1, W2, B2 |
| 138 | + |
| 139 | + # Output layer error: (t - o) for a linear final neuron |
| 140 | + output_errors = [] |
| 141 | + for i in range(OUTPUT_SIZE): |
| 142 | + error = t[i] - o[i] |
| 143 | + output_errors.append(error) |
| 144 | + |
| 145 | + # Hidden layer errors (uses dsigmoid on the hidden output) |
| 146 | + hidden_errors = [] |
| 147 | + for i in range(HIDDEN_SIZE): |
| 148 | + err_sum = 0.0 |
| 149 | + for j in range(OUTPUT_SIZE): |
| 150 | + err_sum += output_errors[j] * W2[j][i] |
| 151 | + hidden_errors.append(err_sum * dsigmoid(h[i])) |
| 152 | + |
| 153 | + # Update W2, B2 (linear output layer) |
| 154 | + for i in range(OUTPUT_SIZE): |
| 155 | + for j in range(HIDDEN_SIZE): |
| 156 | + W2[i][j] += LEARNING_RATE * output_errors[i] * h[j] |
| 157 | + B2[i] += LEARNING_RATE * output_errors[i] |
| 158 | + |
| 159 | + # Update W1, B1 (sigmoid hidden layer) |
| 160 | + for i in range(HIDDEN_SIZE): |
| 161 | + for j in range(INPUT_SIZE): |
| 162 | + W1[i][j] += LEARNING_RATE * hidden_errors[i] * x[j] |
| 163 | + B1[i] += LEARNING_RATE * hidden_errors[i] |
| 164 | + |
| 165 | +# |
| 166 | +# -------------------------------------------------------------------- |
| 167 | +# TRAIN NETWORK |
| 168 | +# -------------------------------------------------------------------- |
| 169 | +def train_network(): |
| 170 | + # Runs EPOCHS times through the entire dataset. |
| 171 | + for epoch in range(EPOCHS): |
| 172 | + for i, inp in enumerate(training_inputs): |
| 173 | + t = training_targets[i] |
| 174 | + h, o = forward_pass(inp) |
| 175 | + backprop(inp, h, o, t) |
| 176 | + |
| 177 | +# |
| 178 | +# -------------------------------------------------------------------- |
| 179 | +# TRAINING SCREEN |
| 180 | +# -------------------------------------------------------------------- |
| 181 | +def display_training_screen(): |
| 182 | + # Clears the screen, draws "TRAINING..." centered, then updates. |
| 183 | + thumby.display.fill(0) |
| 184 | + text_str = "TRAINING..." |
| 185 | + text_str_2 = "Please Wait" |
| 186 | + text_width = len(text_str) * 4 |
| 187 | + x = (SCREEN_W - text_width) // 2 |
| 188 | + y = (SCREEN_H - 7) // 2 |
| 189 | + thumby.display.drawText(text_str, x, y - 2, 1) |
| 190 | + thumby.display.drawText(text_str_2, x, y + 6, 1) |
| 191 | + thumby.display.update() |
| 192 | + |
| 193 | +# |
| 194 | +# -------------------------------------------------------------------- |
| 195 | +# WRAPPING FUNCTIONS FOR TUTORIAL |
| 196 | +# -------------------------------------------------------------------- |
| 197 | +def wrap_text(line, max_chars=18): |
| 198 | + wrapped = [] |
| 199 | + start = 0 |
| 200 | + while start < len(line): |
| 201 | + wrapped.append(line[start:start+max_chars]) |
| 202 | + start += max_chars |
| 203 | + return wrapped |
| 204 | + |
| 205 | +def wrap_tutorial_lines(lines, max_chars=18): |
| 206 | + final = [] |
| 207 | + for l in lines: |
| 208 | + if len(l) > max_chars: |
| 209 | + sub_lines = wrap_text(l, max_chars) |
| 210 | + final.extend(sub_lines) |
| 211 | + else: |
| 212 | + final.append(l) |
| 213 | + return final |
| 214 | + |
| 215 | +# |
| 216 | +# -------------------------------------------------------------------- |
| 217 | +# SCROLLABLE TUTORIAL SCREEN |
| 218 | +# -------------------------------------------------------------------- |
| 219 | +def display_tutorial(): |
| 220 | + raw_tutorial_lines = [ |
| 221 | + "C->F", |
| 222 | + "", |
| 223 | + "1->5->1 network,", |
| 224 | + "sigmoid hidden,", |
| 225 | + "linear output.", |
| 226 | + "Steps of 5C from", |
| 227 | + "0 to 100.", |
| 228 | + "500 epochs,", |
| 229 | + "LR=0.2.", |
| 230 | + "Should predict", |
| 231 | + "100C near 212F.", |
| 232 | + "", |
| 233 | + "Press Up/Down to", |
| 234 | + "scroll. Press B to exit." |
| 235 | + ] |
| 236 | + |
| 237 | + tutorial_lines = wrap_tutorial_lines(raw_tutorial_lines, MAX_CHARS_PER_LINE) |
| 238 | + scroll_offset = 0 |
| 239 | + |
| 240 | + while True: |
| 241 | + thumby.display.fill(0) |
| 242 | + for i in range(5): |
| 243 | + line_index = scroll_offset + i |
| 244 | + if line_index < len(tutorial_lines): |
| 245 | + thumby.display.drawText(tutorial_lines[line_index], 0, i*7, 1) |
| 246 | + thumby.display.update() |
| 247 | + |
| 248 | + if thumby.buttonD.justPressed(): |
| 249 | + if scroll_offset < (len(tutorial_lines) - 5): |
| 250 | + scroll_offset += 1 |
| 251 | + time.sleep(0.1) |
| 252 | + if thumby.buttonU.justPressed(): |
| 253 | + if scroll_offset > 0: |
| 254 | + scroll_offset -= 1 |
| 255 | + time.sleep(0.1) |
| 256 | + if thumby.buttonB.justPressed(): |
| 257 | + break |
| 258 | + time.sleep(0.05) |
| 259 | + |
| 260 | +# |
| 261 | +# -------------------------------------------------------------------- |
| 262 | +# SCROLLABLE RESULTS SCREEN WITH WEIGHTS |
| 263 | +# -------------------------------------------------------------------- |
| 264 | +def display_results(): |
| 265 | + # Displays final predictions and then shows final weights/biases. |
| 266 | + global W1, B1, W2, B2 |
| 267 | + |
| 268 | + result_lines = [] |
| 269 | + result_lines.append("TRAINED NN RESULTS") |
| 270 | + |
| 271 | + # Predict F for each Celsius in c_values |
| 272 | + for c_val in c_values: |
| 273 | + c_norm = [normalize_c(c_val)] |
| 274 | + _, out = forward_pass(c_norm) |
| 275 | + f_pred = denormalize_f(out[0]) |
| 276 | + line = f"{c_val}C => {f_pred:.1f}F" |
| 277 | + result_lines.append(line) |
| 278 | + |
| 279 | + # Show final weights and biases. Round to 2 decimals to fit display space. |
| 280 | + # W1: shape (5,1) since there is 1 input and 5 hidden |
| 281 | + # B1: shape (5,) |
| 282 | + # W2: shape (1,5) since there is 1 output and 5 hidden |
| 283 | + # B2: shape (1,) |
| 284 | + |
| 285 | + # For W1, gather each row's single value (since input=1) |
| 286 | + w1_values = [round(W1[i][0], 2) for i in range(HIDDEN_SIZE)] |
| 287 | + b1_values = [round(B1[i], 2) for i in range(HIDDEN_SIZE)] |
| 288 | + w2_values = [round(W2[0][i], 2) for i in range(HIDDEN_SIZE)] # row 0, col i |
| 289 | + b2_value = round(B2[0], 2) |
| 290 | + |
| 291 | + result_lines.append("----------") |
| 292 | + result_lines.append("W1:"+str(w1_values)) |
| 293 | + result_lines.append("B1:"+str(b1_values)) |
| 294 | + result_lines.append("W2:"+str(w2_values)) |
| 295 | + result_lines.append("B2:"+str([b2_value])) |
| 296 | + result_lines.append("PRESS B TO EXIT") |
| 297 | + |
| 298 | + scroll_offset = 0 |
| 299 | + while True: |
| 300 | + thumby.display.fill(0) |
| 301 | + for i in range(5): |
| 302 | + line_index = scroll_offset + i |
| 303 | + if line_index < len(result_lines): |
| 304 | + thumby.display.drawText(result_lines[line_index], 0, i*7, 1) |
| 305 | + thumby.display.update() |
| 306 | + |
| 307 | + if thumby.buttonD.justPressed(): |
| 308 | + if scroll_offset < (len(result_lines) - 5): |
| 309 | + scroll_offset += 1 |
| 310 | + time.sleep(0.1) |
| 311 | + if thumby.buttonU.justPressed(): |
| 312 | + if scroll_offset > 0: |
| 313 | + scroll_offset -= 1 |
| 314 | + time.sleep(0.1) |
| 315 | + if thumby.buttonB.justPressed(): |
| 316 | + break |
| 317 | + time.sleep(0.05) |
| 318 | + |
| 319 | +# |
| 320 | +# -------------------------------------------------------------------- |
| 321 | +# MAIN MENU |
| 322 | +# -------------------------------------------------------------------- |
| 323 | +menu_items = ["Train NN", "Tutorial", "Exit"] |
| 324 | +selected_index = 0 |
| 325 | + |
| 326 | +def draw_menu(): |
| 327 | + thumby.display.fill(0) |
| 328 | + thumby.display.drawText("C->F AI DEMO", 0, 0, 1) |
| 329 | + y = 10 |
| 330 | + for i, item in enumerate(menu_items): |
| 331 | + marker = ">" if i == selected_index else " " |
| 332 | + line = marker + " " + item |
| 333 | + thumby.display.drawText(line, 0, y, 1) |
| 334 | + y += 8 |
| 335 | + thumby.display.update() |
| 336 | + |
| 337 | +def main_menu(): |
| 338 | + global selected_index |
| 339 | + while True: |
| 340 | + draw_menu() |
| 341 | + if thumby.buttonU.justPressed(): |
| 342 | + selected_index = max(0, selected_index - 1) |
| 343 | + time.sleep(0.1) |
| 344 | + if thumby.buttonD.justPressed(): |
| 345 | + selected_index = min(len(menu_items)-1, selected_index + 1) |
| 346 | + time.sleep(0.1) |
| 347 | + |
| 348 | + if thumby.buttonA.justPressed(): |
| 349 | + choice = menu_items[selected_index] |
| 350 | + if choice == "Train NN": |
| 351 | + init_network() |
| 352 | + display_training_screen() # Shows TRAINING... in the center |
| 353 | + train_network() |
| 354 | + display_results() |
| 355 | + elif choice == "Tutorial": |
| 356 | + display_tutorial() |
| 357 | + elif choice == "Exit": |
| 358 | + break |
| 359 | + time.sleep(0.1) |
| 360 | + |
| 361 | +# |
| 362 | +# -------------------------------------------------------------------- |
| 363 | +# START PROGRAM |
| 364 | +# -------------------------------------------------------------------- |
| 365 | +main_menu() |
0 commit comments