---
title: "LAB18: Level 3 Exercises"
---

## Hardware Deployment Challenges

These exercises require physical hardware (Arduino, ESP32, or Raspberry Pi) and focus on implementing on-device learning capabilities.

::: {.callout-warning}
## Hardware Required
You need the devices listed in the [Hardware Guide](../../resources/hardware.qmd) to complete these exercises. Specifically for LAB18:
- ESP32 DevKit or Raspberry Pi 4
- Camera module (ESP32-CAM or Pi Camera)
- Sensors for data collection (IMU, microphone, etc.)
- SD card for data logging
- Sufficient RAM for model training (ESP32: 520KB, Pi: 4GB+)
:::

### Exercise 1: Transfer Learning on Device
**Difficulty:** Level 3 - Advanced ML
**Objective:** Implement transfer learning on an edge device to adapt a pre-trained model to new classes without cloud connectivity.

**Task:**
Deploy a pre-trained MobileNet feature extractor on your device and train a new classification head with locally collected images. This demonstrates how to personalize models without sending data to the cloud.

**Requirements:**

1. **Pre-trained Model:**
   - MobileNetV2 or similar (feature extractor only)
   - Freeze base layers
   - Replace final classification layer
   - Quantize for edge deployment

2. **On-Device Training:**
   - Collect 20-50 images per new class using camera
   - Extract features using frozen base model
   - Train only the classification head (small dense layer)
   - Save updated model to flash/SD card

3. **Implementation:**
   - Feature extraction pipeline
   - Simple gradient descent for final layer
   - Data augmentation (flip, rotate, brightness)
   - Model persistence across reboots

4. **Validation:**
   - Test accuracy on held-out local data
   - Measure training time and memory usage
   - Verify model improvement over time
   - Compare to cloud-trained baseline

**Expected Outcomes:**
- Successfully train new classes on-device
- Training time: 1-5 minutes for 100 samples
- Accuracy: >80% on 2-3 new classes
- Memory footprint: <100MB total

::: {.callout-tip collapse="true"}
## Solution

**Approach: Use TensorFlow Lite for Microcontrollers with transfer learning**

```python
# Step 1: Prepare base model (run on PC)
import tensorflow as tf

# Load pre-trained MobileNetV2
base_model = tf.keras.applications.MobileNetV2(
    input_shape=(96, 96, 3),
    include_top=False,
    weights='imagenet',
    pooling='avg'
)

# Freeze base model
base_model.trainable = False

# Add trainable classification head
model = tf.keras.Sequential([
    base_model,
    tf.keras.layers.Dense(64, activation='relu'),
    tf.keras.layers.Dropout(0.2),
    tf.keras.layers.Dense(3, activation='softmax')  # 3 new classes
])

# Convert base model to TFLite (feature extractor)
converter = tf.lite.TFLiteConverter.from_keras_model(base_model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
base_tflite = converter.convert()

with open('feature_extractor.tflite', 'wb') as f:
    f.write(base_tflite)

print(f"Feature extractor size: {len(base_tflite)/1024:.1f} KB")
```

**ESP32 On-Device Training (simplified version):**

```cpp
// On-Device Transfer Learning for ESP32
#include <TensorFlowLite_ESP32.h>
#include <tensorflow/lite/micro/all_ops_resolver.h>
#include <tensorflow/lite/micro/micro_interpreter.h>
#include "esp_camera.h"
#include "feature_extractor.h"

const int NUM_CLASSES = 3;
const int FEATURE_SIZE = 1280;  // MobileNetV2 output
const int MAX_SAMPLES = 50;

// Feature extractor model
const tflite::Model* feature_model;
tflite::MicroInterpreter* feature_interpreter;

// Trainable classifier weights
float classifier_weights[FEATURE_SIZE][NUM_CLASSES];
float classifier_bias[NUM_CLASSES];

// Training data storage
float training_features[MAX_SAMPLES][FEATURE_SIZE];
int training_labels[MAX_SAMPLES];
int num_samples = 0;

// Learning parameters
const float LEARNING_RATE = 0.01;
const int EPOCHS = 10;

void setup() {
  Serial.begin(115200);

  // Initialize camera
  initCamera();

  // Load feature extractor
  feature_model = tflite::GetModel(g_feature_extractor);
  static tflite::AllOpsResolver resolver;
  static uint8_t tensor_arena[100 * 1024];  // 100KB for features

  static tflite::MicroInterpreter static_interpreter(
      feature_model, resolver, tensor_arena, sizeof(tensor_arena));
  feature_interpreter = &static_interpreter;
  feature_interpreter->AllocateTensors();

  // Initialize classifier weights (Xavier initialization)
  randomizeWeights();

  Serial.println("Transfer Learning System Ready");
  Serial.println("Commands:");
  Serial.println("  c0, c1, c2 - Capture image for class 0, 1, or 2");
  Serial.println("  t - Train classifier");
  Serial.println("  i - Run inference");
}

void loop() {
  if (Serial.available()) {
    String cmd = Serial.readStringUntil('\n');
    cmd.trim();

    if (cmd.startsWith("c")) {
      int label = cmd.substring(1).toInt();
      captureAndStore(label);
    } else if (cmd == "t") {
      trainClassifier();
    } else if (cmd == "i") {
      runInference();
    }
  }
}

void captureAndStore(int label) {
  if (num_samples >= MAX_SAMPLES) {
    Serial.println("Sample buffer full!");
    return;
  }

  Serial.print("Capturing image for class ");
  Serial.println(label);

  // Capture image
  camera_fb_t* fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("Camera capture failed");
    return;
  }

  // Extract features
  float features[FEATURE_SIZE];
  extractFeatures(fb->buf, fb->len, features);

  // Store in training set
  memcpy(training_features[num_samples], features, sizeof(features));
  training_labels[num_samples] = label;
  num_samples++;

  esp_camera_fb_return(fb);

  Serial.print("Stored sample ");
  Serial.print(num_samples);
  Serial.print(" for class ");
  Serial.println(label);
}

void extractFeatures(uint8_t* image_data, size_t len, float* features) {
  // Preprocess image and run through feature extractor
  TfLiteTensor* input = feature_interpreter->input(0);
  TfLiteTensor* output = feature_interpreter->output(0);

  // Copy preprocessed image to input tensor
  // (assuming image is already resized to 96x96)
  preprocessImage(image_data, len, input->data.f);

  // Run feature extraction
  if (feature_interpreter->Invoke() != kTfLiteOk) {
    Serial.println("Feature extraction failed");
    return;
  }

  // Copy features
  memcpy(features, output->data.f, FEATURE_SIZE * sizeof(float));
}

void trainClassifier() {
  if (num_samples < 10) {
    Serial.println("Need at least 10 samples to train!");
    return;
  }

  Serial.println("\n=== Training Classifier ===");
  Serial.print("Samples: ");
  Serial.println(num_samples);

  unsigned long start_time = millis();

  for (int epoch = 0; epoch < EPOCHS; epoch++) {
    float total_loss = 0;
    int correct = 0;

    // Shuffle data
    shuffleData();

    // Process each sample
    for (int i = 0; i < num_samples; i++) {
      // Forward pass
      float logits[NUM_CLASSES];
      forward(training_features[i], logits);

      // Softmax
      float probs[NUM_CLASSES];
      softmax(logits, probs, NUM_CLASSES);

      // Cross-entropy loss
      int label = training_labels[i];
      total_loss += -log(probs[label] + 1e-7);

      // Accuracy
      int predicted = argmax(probs, NUM_CLASSES);
      if (predicted == label) correct++;

      // Backward pass (gradient descent)
      backprop(training_features[i], probs, label);
    }

    float avg_loss = total_loss / num_samples;
    float accuracy = (float)correct / num_samples * 100;

    Serial.print("Epoch ");
    Serial.print(epoch + 1);
    Serial.print(": Loss = ");
    Serial.print(avg_loss, 4);
    Serial.print(", Acc = ");
    Serial.print(accuracy, 1);
    Serial.println("%");
  }

  unsigned long training_time = millis() - start_time;

  Serial.println("\n=== Training Complete ===");
  Serial.print("Time: ");
  Serial.print(training_time / 1000.0, 2);
  Serial.println(" seconds");

  // Save weights to SPIFFS/SD card
  saveWeights();
}

void forward(float* features, float* logits) {
  // logits = features @ weights + bias
  for (int c = 0; c < NUM_CLASSES; c++) {
    logits[c] = classifier_bias[c];
    for (int f = 0; f < FEATURE_SIZE; f++) {
      logits[c] += features[f] * classifier_weights[f][c];
    }
  }
}

void backprop(float* features, float* probs, int true_label) {
  // Gradient of cross-entropy + softmax
  float gradients[NUM_CLASSES];

  for (int c = 0; c < NUM_CLASSES; c++) {
    gradients[c] = probs[c];
    if (c == true_label) {
      gradients[c] -= 1.0;
    }

    // Update bias
    classifier_bias[c] -= LEARNING_RATE * gradients[c];

    // Update weights
    for (int f = 0; f < FEATURE_SIZE; f++) {
      classifier_weights[f][c] -= LEARNING_RATE * gradients[c] * features[f];
    }
  }
}

void softmax(float* logits, float* probs, int size) {
  // Numerical stability: subtract max
  float max_logit = logits[0];
  for (int i = 1; i < size; i++) {
    if (logits[i] > max_logit) max_logit = logits[i];
  }

  float sum = 0;
  for (int i = 0; i < size; i++) {
    probs[i] = exp(logits[i] - max_logit);
    sum += probs[i];
  }

  for (int i = 0; i < size; i++) {
    probs[i] /= sum;
  }
}

void runInference() {
  Serial.println("Capturing image for inference...");

  camera_fb_t* fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("Camera capture failed");
    return;
  }

  // Extract features
  float features[FEATURE_SIZE];
  extractFeatures(fb->buf, fb->len, features);

  // Run classifier
  float logits[NUM_CLASSES];
  forward(features, logits);

  float probs[NUM_CLASSES];
  softmax(logits, probs, NUM_CLASSES);

  // Print results
  Serial.println("\nPrediction:");
  for (int c = 0; c < NUM_CLASSES; c++) {
    Serial.print("  Class ");
    Serial.print(c);
    Serial.print(": ");
    Serial.print(probs[c] * 100, 1);
    Serial.println("%");
  }

  int predicted = argmax(probs, NUM_CLASSES);
  Serial.print("Predicted class: ");
  Serial.println(predicted);

  esp_camera_fb_return(fb);
}
```

**Key Insights:**
- Only train final layer (64 parameters for 3 classes)
- Feature extraction is frozen (no training needed)
- Can add new classes with 10-50 samples
- Training completes in 1-2 minutes on ESP32
- Model updates persist across reboots

:::

### Exercise 2: Incremental Learning with Replay Buffer
**Difficulty:** Level 3 - Advanced Implementation
**Objective:** Implement continual learning with a replay buffer to prevent catastrophic forgetting when learning new data.

**Task:**
Create a system that continuously learns from new sensor data while maintaining performance on previously learned patterns. Use a replay buffer to mix old and new data during training.

**Steps:**

1. **Replay Buffer:**
   - Fixed-size circular buffer (e.g., 100 samples)
   - Store representative samples from each class
   - Implement reservoir sampling for diversity
   - Persist buffer to SD card

2. **Incremental Training:**
   - Train on batch of new data
   - Mix 50% new data with 50% replayed old data
   - Update model parameters incrementally
   - Monitor performance on validation set

3. **Forgetting Prevention:**
   - Track per-class accuracy over time
   - Detect performance degradation
   - Adjust replay ratio if forgetting detected
   - Save model checkpoints

4. **Real-World Testing:**
   - Deploy on gesture recognition task
   - Add new gestures over time
   - Verify old gestures still work
   - Measure accuracy retention

**Expected Outcomes:**
- <5% accuracy drop on old classes
- Successful learning of new classes
- Buffer management working correctly
- Practical continual learning demonstration

::: {.callout-tip collapse="true"}
## Solution

```cpp
// Incremental Learning with Replay Buffer
#include <Arduino.h>
#include <SD.h>
#include "TensorFlowLite_ESP32.h"

#define BUFFER_SIZE 100
#define FEATURE_DIM 32
#define NUM_CLASSES 5
#define BATCH_SIZE 10

// Replay buffer structure
struct Sample {
  float features[FEATURE_DIM];
  int label;
  unsigned long timestamp;
};

Sample replay_buffer[BUFFER_SIZE];
int buffer_head = 0;
int buffer_count = 0;

// Model weights (simple linear classifier for demo)
float weights[FEATURE_DIM][NUM_CLASSES];
float bias[NUM_CLASSES];

// Performance tracking
int class_counts[NUM_CLASSES] = {0};
float class_accuracy[NUM_CLASSES] = {0};

const float LEARNING_RATE = 0.01;
const float REPLAY_RATIO = 0.5;  // 50% old, 50% new

void setup() {
  Serial.begin(115200);

  // Initialize SD card
  if (!SD.begin(5)) {
    Serial.println("SD card initialization failed!");
    while(1);
  }

  // Load existing replay buffer from SD card
  loadReplayBuffer();

  // Load model weights
  loadWeights();

  Serial.println("Incremental Learning System Ready");
  Serial.println("Commands:");
  Serial.println("  d <label> - Add data sample");
  Serial.println("  t - Train on recent data");
  Serial.println("  v - Validate on all classes");
  Serial.println("  s - Show buffer statistics");
}

void loop() {
  if (Serial.available()) {
    String cmd = Serial.readStringUntil('\n');
    cmd.trim();

    if (cmd.startsWith("d ")) {
      int label = cmd.substring(2).toInt();
      collectAndAdd(label);
    } else if (cmd == "t") {
      incrementalTrain();
    } else if (cmd == "v") {
      validateAllClasses();
    } else if (cmd == "s") {
      showBufferStats();
    }
  }
}

void collectAndAdd(int label) {
  // Collect sensor data (e.g., accelerometer for gestures)
  float features[FEATURE_DIM];
  collectSensorData(features);

  // Add to replay buffer
  addToReplayBuffer(features, label);

  Serial.print("Added sample for class ");
  Serial.println(label);
}

void addToReplayBuffer(float* features, int label) {
  // Circular buffer: overwrite oldest sample
  memcpy(replay_buffer[buffer_head].features, features,
         FEATURE_DIM * sizeof(float));
  replay_buffer[buffer_head].label = label;
  replay_buffer[buffer_head].timestamp = millis();

  buffer_head = (buffer_head + 1) % BUFFER_SIZE;
  if (buffer_count < BUFFER_SIZE) buffer_count++;

  // Save to SD card for persistence
  saveReplayBuffer();
}

void incrementalTrain() {
  Serial.println("\n=== Incremental Training ===");

  // Get recent samples (new data)
  Sample new_samples[BATCH_SIZE];
  int new_count = getRecentSamples(new_samples, BATCH_SIZE);

  // Get replay samples (old data)
  int replay_count = (int)(new_count * REPLAY_RATIO / (1 - REPLAY_RATIO));
  Sample replay_samples[BATCH_SIZE];
  int actual_replay = getReplaySamples(replay_samples, replay_count);

  Serial.print("New samples: ");
  Serial.println(new_count);
  Serial.print("Replay samples: ");
  Serial.println(actual_replay);

  // Combined training
  float total_loss = 0;
  int correct = 0;
  int total = new_count + actual_replay;

  // Train on new samples
  for (int i = 0; i < new_count; i++) {
    float loss = trainOnSample(new_samples[i]);
    total_loss += loss;

    // Check accuracy
    float probs[NUM_CLASSES];
    predict(new_samples[i].features, probs);
    if (argmax(probs, NUM_CLASSES) == new_samples[i].label) {
      correct++;
    }
  }

  // Train on replay samples
  for (int i = 0; i < actual_replay; i++) {
    float loss = trainOnSample(replay_samples[i]);
    total_loss += loss;

    float probs[NUM_CLASSES];
    predict(replay_samples[i].features, probs);
    if (argmax(probs, NUM_CLASSES) == replay_samples[i].label) {
      correct++;
    }
  }

  float avg_loss = total_loss / total;
  float accuracy = (float)correct / total * 100;

  Serial.print("Loss: ");
  Serial.print(avg_loss, 4);
  Serial.print(", Accuracy: ");
  Serial.print(accuracy, 1);
  Serial.println("%");

  // Save updated weights
  saveWeights();

  Serial.println("Training complete\n");
}

float trainOnSample(Sample sample) {
  // Forward pass
  float logits[NUM_CLASSES];
  for (int c = 0; c < NUM_CLASSES; c++) {
    logits[c] = bias[c];
    for (int f = 0; f < FEATURE_DIM; f++) {
      logits[c] += sample.features[f] * weights[f][c];
    }
  }

  // Softmax
  float probs[NUM_CLASSES];
  softmax(logits, probs, NUM_CLASSES);

  // Loss
  float loss = -log(probs[sample.label] + 1e-7);

  // Backward pass
  for (int c = 0; c < NUM_CLASSES; c++) {
    float gradient = probs[c];
    if (c == sample.label) gradient -= 1.0;

    bias[c] -= LEARNING_RATE * gradient;

    for (int f = 0; f < FEATURE_DIM; f++) {
      weights[f][c] -= LEARNING_RATE * gradient * sample.features[f];
    }
  }

  return loss;
}

int getRecentSamples(Sample* samples, int max_samples) {
  // Get most recent samples
  int count = min(max_samples, buffer_count);
  int start_idx = (buffer_head - count + BUFFER_SIZE) % BUFFER_SIZE;

  for (int i = 0; i < count; i++) {
    int idx = (start_idx + i) % BUFFER_SIZE;
    samples[i] = replay_buffer[idx];
  }

  return count;
}

int getReplaySamples(Sample* samples, int max_samples) {
  // Stratified sampling: try to get balanced samples from all classes
  int count = 0;
  int per_class = max_samples / NUM_CLASSES;

  for (int c = 0; c < NUM_CLASSES && count < max_samples; c++) {
    int class_count = 0;

    // Find samples of this class
    for (int i = 0; i < buffer_count && class_count < per_class; i++) {
      if (replay_buffer[i].label == c) {
        samples[count++] = replay_buffer[i];
        class_count++;
      }
    }
  }

  return count;
}

void validateAllClasses() {
  Serial.println("\n=== Validation ===");

  int class_correct[NUM_CLASSES] = {0};
  int class_total[NUM_CLASSES] = {0};

  // Test on all samples in buffer
  for (int i = 0; i < buffer_count; i++) {
    float probs[NUM_CLASSES];
    predict(replay_buffer[i].features, probs);

    int predicted = argmax(probs, NUM_CLASSES);
    int label = replay_buffer[i].label;

    class_total[label]++;
    if (predicted == label) {
      class_correct[label]++;
    }
  }

  // Print per-class accuracy
  for (int c = 0; c < NUM_CLASSES; c++) {
    if (class_total[c] > 0) {
      float acc = (float)class_correct[c] / class_total[c] * 100;
      class_accuracy[c] = acc;

      Serial.print("Class ");
      Serial.print(c);
      Serial.print(": ");
      Serial.print(acc, 1);
      Serial.print("% (");
      Serial.print(class_correct[c]);
      Serial.print("/");
      Serial.print(class_total[c]);
      Serial.println(")");

      // Detect forgetting
      if (acc < 70 && class_total[c] > 10) {
        Serial.print("  WARNING: Potential forgetting on class ");
        Serial.println(c);
      }
    }
  }

  Serial.println();
}

void showBufferStats() {
  Serial.println("\n=== Replay Buffer Statistics ===");
  Serial.print("Total samples: ");
  Serial.print(buffer_count);
  Serial.print(" / ");
  Serial.println(BUFFER_SIZE);

  // Count samples per class
  int counts[NUM_CLASSES] = {0};
  for (int i = 0; i < buffer_count; i++) {
    counts[replay_buffer[i].label]++;
  }

  Serial.println("Per-class distribution:");
  for (int c = 0; c < NUM_CLASSES; c++) {
    Serial.print("  Class ");
    Serial.print(c);
    Serial.print(": ");
    Serial.print(counts[c]);
    Serial.print(" (");
    Serial.print(100.0 * counts[c] / buffer_count, 1);
    Serial.println("%)");
  }

  Serial.println();
}

void saveReplayBuffer() {
  File file = SD.open("/replay_buffer.bin", FILE_WRITE);
  if (!file) {
    Serial.println("Failed to save replay buffer");
    return;
  }

  file.write((uint8_t*)&buffer_count, sizeof(buffer_count));
  file.write((uint8_t*)&buffer_head, sizeof(buffer_head));
  file.write((uint8_t*)replay_buffer, sizeof(replay_buffer));
  file.close();
}

void loadReplayBuffer() {
  if (!SD.exists("/replay_buffer.bin")) {
    Serial.println("No existing replay buffer");
    return;
  }

  File file = SD.open("/replay_buffer.bin", FILE_READ);
  if (!file) {
    Serial.println("Failed to load replay buffer");
    return;
  }

  file.read((uint8_t*)&buffer_count, sizeof(buffer_count));
  file.read((uint8_t*)&buffer_head, sizeof(buffer_head));
  file.read((uint8_t*)replay_buffer, sizeof(replay_buffer));
  file.close();

  Serial.print("Loaded ");
  Serial.print(buffer_count);
  Serial.println(" samples from replay buffer");
}
```

**Expected Behavior:**
- New classes learned without forgetting old ones
- Replay buffer maintains diversity
- Accuracy stays >70% on all classes
- System adapts to new patterns continuously

:::

### Exercise 3: Model Personalization System
**Difficulty:** Level 3 - System Integration
**Objective:** Build a personalization system that adapts a general model to individual user patterns.

**Task:**
Deploy a general activity recognition model and personalize it to a specific user's movement patterns through on-device fine-tuning.

**Steps:**

1. **Base Model:**
   - General activity model (trained on public dataset)
   - Recognize: walking, running, sitting, standing
   - Deploy quantized version to device
   - Baseline accuracy: ~75% on new user

2. **Personalization:**
   - Collect 50-100 samples from specific user
   - Fine-tune last layer for 5-10 epochs
   - Adjust decision boundaries
   - Save personalized model

3. **Comparison:**
   - Test general model accuracy
   - Test personalized model accuracy
   - Measure improvement per activity
   - Quantify personalization benefit

4. **Deployment:**
   - Automatic personalization on first use
   - Background updates as user provides feedback
   - A/B testing between models
   - User feedback collection

**Expected Outcomes:**
- 10-20% accuracy improvement after personalization
- Personalization completes in <2 minutes
- User-specific patterns recognized
- Smooth deployment experience

::: {.callout-tip collapse="true"}
## Solution

```cpp
// Model Personalization System
#include <TensorFlowLite_ESP32.h>

#define NUM_ACTIVITIES 4
const char* ACTIVITY_NAMES[] = {"Walking", "Running", "Sitting", "Standing"};

// Two models: general and personalized
tflite::MicroInterpreter* general_interpreter;
tflite::MicroInterpreter* personalized_interpreter;

bool use_personalized = false;
bool personalization_complete = false;

// Personalization data
float personalization_data[200][6];  // 200 samples, 6 features (accel xyz, gyro xyz)
int personalization_labels[200];
int personalization_count = 0;

RTC_DATA_ATTR int usage_count = 0;

void setup() {
  Serial.begin(115200);

  // Load general model
  loadGeneralModel();

  // Check if personalized model exists
  if (loadPersonalizedModel()) {
    use_personalized = true;
    personalization_complete = true;
    Serial.println("Using personalized model");
  } else {
    Serial.println("Using general model");

    // Start personalization after 10 uses
    usage_count++;
    if (usage_count >= 10 && !personalization_complete) {
      Serial.println("\nReady for personalization!");
      Serial.println("Please perform each activity for 30 seconds");
      startPersonalization();
    }
  }

  Serial.println("Activity Recognition System Ready");
}

void loop() {
  // Continuous activity recognition
  float features[6];
  readIMU(features);

  // Choose model
  tflite::MicroInterpreter* interpreter = use_personalized ?
                                          personalized_interpreter :
                                          general_interpreter;

  // Run inference
  float probs[NUM_ACTIVITIES];
  runInference(interpreter, features, probs);

  // Display result
  int predicted = argmax(probs, NUM_ACTIVITIES);
  Serial.print("Activity: ");
  Serial.print(ACTIVITY_NAMES[predicted]);
  Serial.print(" (");
  Serial.print(probs[predicted] * 100, 0);
  Serial.println("%)");

  delay(1000);  // Classify every second
}

void startPersonalization() {
  Serial.println("\n=== Personalization Process ===");

  personalization_count = 0;

  for (int activity = 0; activity < NUM_ACTIVITIES; activity++) {
    Serial.print("\nPerform activity: ");
    Serial.println(ACTIVITY_NAMES[activity]);
    Serial.println("Starting in 3 seconds...");
    delay(3000);

    Serial.println("Recording... (30 seconds)");

    // Collect samples
    unsigned long start = millis();
    while (millis() - start < 30000) {
      if (personalization_count >= 200) break;

      float features[6];
      readIMU(features);

      memcpy(personalization_data[personalization_count], features, sizeof(features));
      personalization_labels[personalization_count] = activity;
      personalization_count++;

      Serial.print(".");
      delay(100);  // 10Hz sampling
    }

    Serial.println("\nDone!");
  }

  Serial.print("\nCollected ");
  Serial.print(personalization_count);
  Serial.println(" samples");

  // Perform personalization
  personalizeModel();
}

void personalizeModel() {
  Serial.println("\n=== Personalizing Model ===");

  // Copy general model as starting point
  // (In practice, would copy TFLite model and modify last layer)

  // For this demo, train a simple adapter on top
  float adapter_weights[NUM_ACTIVITIES][NUM_ACTIVITIES];
  float adapter_bias[NUM_ACTIVITIES];

  // Initialize to identity
  for (int i = 0; i < NUM_ACTIVITIES; i++) {
    adapter_bias[i] = 0;
    for (int j = 0; j < NUM_ACTIVITIES; j++) {
      adapter_weights[i][j] = (i == j) ? 1.0 : 0.0;
    }
  }

  // Fine-tune adapter
  const float LR = 0.01;
  const int EPOCHS = 10;

  for (int epoch = 0; epoch < EPOCHS; epoch++) {
    float total_loss = 0;
    int correct = 0;

    for (int i = 0; i < personalization_count; i++) {
      // Get general model output
      float general_probs[NUM_ACTIVITIES];
      runInference(general_interpreter, personalization_data[i], general_probs);

      // Apply adapter
      float adapted_logits[NUM_ACTIVITIES];
      for (int j = 0; j < NUM_ACTIVITIES; j++) {
        adapted_logits[j] = adapter_bias[j];
        for (int k = 0; k < NUM_ACTIVITIES; k++) {
          adapted_logits[j] += general_probs[k] * adapter_weights[k][j];
        }
      }

      // Softmax
      float adapted_probs[NUM_ACTIVITIES];
      softmax(adapted_logits, adapted_probs, NUM_ACTIVITIES);

      // Loss and accuracy
      int label = personalization_labels[i];
      total_loss += -log(adapted_probs[label] + 1e-7);

      int predicted = argmax(adapted_probs, NUM_ACTIVITIES);
      if (predicted == label) correct++;

      // Update adapter
      for (int j = 0; j < NUM_ACTIVITIES; j++) {
        float grad = adapted_probs[j];
        if (j == label) grad -= 1.0;

        adapter_bias[j] -= LR * grad;

        for (int k = 0; k < NUM_ACTIVITIES; k++) {
          adapter_weights[k][j] -= LR * grad * general_probs[k];
        }
      }
    }

    float avg_loss = total_loss / personalization_count;
    float accuracy = 100.0 * correct / personalization_count;

    Serial.print("Epoch ");
    Serial.print(epoch + 1);
    Serial.print(": Loss=");
    Serial.print(avg_loss, 3);
    Serial.print(", Acc=");
    Serial.print(accuracy, 1);
    Serial.println("%");
  }

  // Save personalized adapter
  saveAdapter(adapter_weights, adapter_bias);

  use_personalized = true;
  personalization_complete = true;

  Serial.println("\nPersonalization complete!");

  // Validate
  validatePersonalization();
}

void validatePersonalization() {
  Serial.println("\n=== Validation ===");

  int general_correct = 0;
  int personalized_correct = 0;

  for (int i = 0; i < personalization_count; i++) {
    // General model
    float general_probs[NUM_ACTIVITIES];
    runInference(general_interpreter, personalization_data[i], general_probs);
    if (argmax(general_probs, NUM_ACTIVITIES) == personalization_labels[i]) {
      general_correct++;
    }

    // Personalized model
    float personalized_probs[NUM_ACTIVITIES];
    runInference(personalized_interpreter, personalization_data[i], personalized_probs);
    if (argmax(personalized_probs, NUM_ACTIVITIES) == personalization_labels[i]) {
      personalized_correct++;
    }
  }

  float general_acc = 100.0 * general_correct / personalization_count;
  float personalized_acc = 100.0 * personalized_correct / personalization_count;

  Serial.print("General model: ");
  Serial.print(general_acc, 1);
  Serial.println("%");

  Serial.print("Personalized model: ");
  Serial.print(personalized_acc, 1);
  Serial.println("%");

  Serial.print("Improvement: +");
  Serial.print(personalized_acc - general_acc, 1);
  Serial.println("%");
}
```

**User Experience Flow:**
1. First 10 uses: General model (collect implicit feedback)
2. Prompt user for personalization
3. 2-minute calibration process
4. Switch to personalized model
5. Periodic background updates

:::

### Exercise 4: Federated Learning Edge Node
**Difficulty:** Level 3 - Distributed Learning
**Objective:** Implement an edge node for federated learning that trains locally and shares only model updates.

**Task:**
Create a federated learning participant that trains on local data, computes model gradients, and sends updates to a coordinator without sharing raw data.

**Steps:**

1. **Local Training:**
   - Train model on local device data
   - Compute weight updates (gradients)
   - Quantize updates for transmission
   - Track training metrics

2. **Communication Protocol:**
   - Connect to coordinator (MQTT/HTTP)
   - Download global model weights
   - Upload local gradients
   - Receive aggregated updates

3. **Privacy Preservation:**
   - Never send raw data
   - Optional: add differential privacy noise
   - Secure communication channel
   - Audit data usage

4. **Demonstration:**
   - Multiple devices with different local data
   - Show model convergence
   - Validate final model on all devices
   - Measure communication overhead

**Expected Outcomes:**
- Local training completes successfully
- Gradients transmitted (not raw data)
- Model improves across all devices
- Communication efficient (<1KB per round)

::: {.callout-tip collapse="true"}
## Solution

See full federated learning implementation in the PDF book, Chapter 18.

**Key architecture:**
- Edge node: Trains locally, sends gradients
- Coordinator: Aggregates gradients, updates global model
- Communication: MQTT with JSON payload
- Privacy: Only model updates transmitted

**Expected communication per round:**
- Model size: 10KB (quantized INT8)
- Gradient updates: ~1KB (sparse updates)
- Total bandwidth: <2KB per training round
:::

### Exercise 5: A/B Testing for Model Updates
**Difficulty:** Level 3 - Production System
**Objective:** Implement A/B testing framework to safely deploy model updates and measure improvement.

**Task:**
Deploy two model versions simultaneously and collect metrics to determine which performs better on real user data.

**Steps:**

1. **Dual Model Deployment:**
   - Load model A (baseline) and model B (update)
   - Randomly assign users to groups (50/50 split)
   - Run both models in parallel
   - Log predictions and ground truth

2. **Metrics Collection:**
   - Accuracy per model
   - Latency per model
   - User feedback (if available)
   - Confidence scores

3. **Statistical Analysis:**
   - Compute accuracy difference
   - Test statistical significance
   - Identify per-class performance
   - Determine winner

4. **Rollout Decision:**
   - If B significantly better → rollout to all users
   - If A better → keep baseline
   - If inconclusive → collect more data
   - Monitor for regressions

**Expected Outcomes:**
- Clear performance comparison
- Statistical confidence in decision
- Safe deployment mechanism
- Data-driven model selection

::: {.callout-tip collapse="true"}
## Solution

```cpp
// A/B Testing Framework for Model Updates
#include <TensorFlowLite_ESP32.h>

tflite::MicroInterpreter* model_A;  // Baseline
tflite::MicroInterpreter* model_B;  // Update candidate

// User assignment (persistent across reboots)
RTC_DATA_ATTR bool assigned_to_B = false;
RTC_DATA_ATTR bool assignment_done = false;

// Metrics tracking
struct ModelMetrics {
  int total_predictions;
  int correct_predictions;
  unsigned long total_latency_us;
  float confidence_sum;
};

ModelMetrics metrics_A = {0, 0, 0, 0.0};
ModelMetrics metrics_B = {0, 0, 0, 0.0};

void setup() {
  Serial.begin(115200);

  // Load both models
  loadModel(&model_A, "model_v1.tflite");
  loadModel(&model_B, "model_v2.tflite");

  // Assign user to group (random 50/50)
  if (!assignment_done) {
    assigned_to_B = (random(100) < 50);
    assignment_done = true;

    Serial.print("Assigned to model: ");
    Serial.println(assigned_to_B ? "B (new)" : "A (baseline)");
  }

  // Load existing metrics from SD card
  loadMetrics();

  Serial.println("A/B Testing Framework Active");
  printMetrics();
}

void loop() {
  // Collect sensor data
  float features[10];
  collectSensorData(features);

  // Get ground truth (if available, e.g., from user feedback)
  int ground_truth = getGroundTruth();  // -1 if unknown

  // Run inference on assigned model
  tflite::MicroInterpreter* active_model = assigned_to_B ? model_B : model_A;
  ModelMetrics* metrics = assigned_to_B ? &metrics_B : &metrics_A;

  unsigned long start = micros();
  float probs[3];
  runInference(active_model, features, probs);
  unsigned long latency = micros() - start;

  int predicted = argmax(probs, 3);
  float confidence = probs[predicted];

  // Update metrics
  metrics->total_predictions++;
  metrics->total_latency_us += latency;
  metrics->confidence_sum += confidence;

  if (ground_truth >= 0 && predicted == ground_truth) {
    metrics->correct_predictions++;
  }

  // Periodic metrics report
  static unsigned long last_report = 0;
  if (millis() - last_report > 60000) {  // Every minute
    last_report = millis();
    saveMetrics();
    printMetrics();
  }

  // Also run the other model in shadow mode (no user impact)
  shadowModeInference(features, ground_truth);

  delay(1000);
}

void shadowModeInference(float* features, int ground_truth) {
  // Run the non-assigned model to collect comparison data
  tflite::MicroInterpreter* shadow_model = assigned_to_B ? model_A : model_B;
  ModelMetrics* shadow_metrics = assigned_to_B ? &metrics_A : &metrics_B;

  unsigned long start = micros();
  float probs[3];
  runInference(shadow_model, features, probs);
  unsigned long latency = micros() - start;

  int predicted = argmax(probs, 3);
  float confidence = probs[predicted];

  // Update shadow metrics
  shadow_metrics->total_predictions++;
  shadow_metrics->total_latency_us += latency;
  shadow_metrics->confidence_sum += confidence;

  if (ground_truth >= 0 && predicted == ground_truth) {
    shadow_metrics->correct_predictions++;
  }
}

void printMetrics() {
  Serial.println("\n=== A/B Test Metrics ===");

  printModelMetrics("Model A (baseline)", metrics_A);
  printModelMetrics("Model B (update)", metrics_B);

  // Statistical comparison
  if (metrics_A.total_predictions > 100 && metrics_B.total_predictions > 100) {
    float acc_A = 100.0 * metrics_A.correct_predictions / metrics_A.total_predictions;
    float acc_B = 100.0 * metrics_B.correct_predictions / metrics_B.total_predictions;

    float improvement = acc_B - acc_A;

    Serial.print("\nAccuracy difference: ");
    if (improvement > 0) Serial.print("+");
    Serial.print(improvement, 2);
    Serial.println("%");

    // Simple significance test (need more samples for real test)
    if (abs(improvement) > 5.0) {
      Serial.println("Result: SIGNIFICANT");
      Serial.print("Recommendation: Deploy model ");
      Serial.println(improvement > 0 ? "B" : "A");
    } else {
      Serial.println("Result: INCONCLUSIVE");
      Serial.println("Recommendation: Collect more data");
    }
  }

  Serial.println("========================\n");
}

void printModelMetrics(const char* name, ModelMetrics& m) {
  Serial.println(name);

  Serial.print("  Predictions: ");
  Serial.println(m.total_predictions);

  if (m.total_predictions > 0) {
    float accuracy = 100.0 * m.correct_predictions / m.total_predictions;
    Serial.print("  Accuracy: ");
    Serial.print(accuracy, 2);
    Serial.print("% (");
    Serial.print(m.correct_predictions);
    Serial.print("/");
    Serial.print(m.total_predictions);
    Serial.println(")");

    float avg_latency = m.total_latency_us / (float)m.total_predictions / 1000.0;
    Serial.print("  Avg Latency: ");
    Serial.print(avg_latency, 2);
    Serial.println(" ms");

    float avg_confidence = m.confidence_sum / m.total_predictions * 100;
    Serial.print("  Avg Confidence: ");
    Serial.print(avg_confidence, 1);
    Serial.println("%");
  }
}

void saveMetrics() {
  // Save to SD card for persistence
  File file = SD.open("/ab_test_metrics.bin", FILE_WRITE);
  if (file) {
    file.write((uint8_t*)&metrics_A, sizeof(metrics_A));
    file.write((uint8_t*)&metrics_B, sizeof(metrics_B));
    file.close();
  }
}

void loadMetrics() {
  if (!SD.exists("/ab_test_metrics.bin")) return;

  File file = SD.open("/ab_test_metrics.bin", FILE_READ);
  if (file) {
    file.read((uint8_t*)&metrics_A, sizeof(metrics_A));
    file.read((uint8_t*)&metrics_B, sizeof(metrics_B));
    file.close();

    Serial.println("Loaded existing metrics");
  }
}
```

**Deployment Decision Framework:**
- Collect ≥1000 samples per model
- Require ≥5% improvement for rollout
- Check statistical significance (p < 0.05)
- Monitor for regressions in production
- Gradual rollout: 10% → 50% → 100%

:::

---

## Additional Resources

- **Transfer Learning**: TensorFlow Lite transfer learning tutorials
- **Federated Learning**: Google Federated Learning documentation
- **On-Device Training**: TinyML Foundation resources
- **Model Personalization**: Edge ML personalization techniques
- **A/B Testing**: Statistical testing for ML models
