Skip to content
Denna sida är ännu inte tillgänglig på svenska.
← Tillbaka till projekten

TinyML Arduino Posture Classifier/ Sida 3 av 4

Model Training and Inference

Training the Echo State Network with online gradient descent, softmax classification, and real-time inference on the Arduino, all within 256KB of RAM.

#tinyml#embedded#machine-learning#arduino#edge-computing#federated-learning

Reservoir State Update

The reservoir state is updated at each timestep in esn.cpp:

void updateReservoir(const FeatureVector &fv) {
  float new_state[RESERVOIR_SIZE];
  
  for (uint8_t i = 0; i < RESERVOIR_SIZE; i++) {
    float sum = 0.0f;
    
    // Input contribution
    for (uint8_t j = 0; j < INPUT_SIZE; j++) {
      sum += esn.W_in[i][j] * fv.features[j];
    }
    
    // Reservoir recurrent contribution
    for (uint8_t j = 0; j < RESERVOIR_SIZE; j++) {
      sum += esn.W_res[i][j] * esn.reservoir[j];
    }
    
    // Leaky integration with tanh activation
    new_state[i] = (1 - LEAKY) * esn.reservoir[i] + LEAKY * tanh(sum);
  }
  
  // Update reservoir state
  for (uint8_t i = 0; i < RESERVOIR_SIZE; i++) {
    esn.reservoir[i] = new_state[i];
  }
}

The leaky parameter (LEAKY = 0.3) controls how much the new state blends with the old state. A value of 0.3 means 30% new input and 70% previous state, giving the reservoir memory of past inputs while still responding to new data.

Training the Output Layer

We train the output layer using online gradient descent with softmax:

void trainOutputLayer(const FeatureVector *X, const uint8_t *y,
                      uint16_t n_samples, float learning_rate) {
  for (uint16_t s = 0; s < n_samples; s++) {
    updateReservoir(X[s]);
    
    // Compute output (linear)
    float out[OUTPUT_SIZE];
    for (uint8_t i = 0; i < OUTPUT_SIZE; i++) {
      out[i] = 0.0f;
      for (uint8_t j = 0; j < RESERVOIR_SIZE; j++) {
        out[i] += esn.W_out[i][j] * esn.reservoir[j];
      }
    }
    
    // Softmax
    float max_val = out[0];
    for (uint8_t i = 1; i < OUTPUT_SIZE; i++)
      if (out[i] > max_val)
        max_val = out[i];
    float sum_exp = 0.0f;
    for (uint8_t i = 0; i < OUTPUT_SIZE; i++) {
      out[i] = exp(out[i] - max_val);
      sum_exp += out[i];
    }
    for (uint8_t i = 0; i < OUTPUT_SIZE; i++)
      out[i] /= sum_exp;
    
    // Compute error (one-hot target)
    for (uint8_t i = 0; i < OUTPUT_SIZE; i++) {
      float target = (i == y[s]) ? 1.0f : 0.0f;
      float error = target - out[i];
      
      // Gradient descent update for W_out
      for (uint8_t j = 0; j < RESERVOIR_SIZE; j++) {
        esn.W_out[i][j] += learning_rate * error * esn.reservoir[j];
      }
    }
  }
}

For each sample in the batch:

  1. Update the reservoir state with the input features
  2. Compute the linear output (3 values)
  3. Apply softmax to get probabilities
  4. Compute error against one-hot target
  5. Update output weights with gradient descent

The learning rate is 0.01. The softmax ensures the outputs are valid probabilities that sum to 1.

Inference

Prediction is straightforward, update the reservoir and compute the output:

uint8_t predict() {
  float out[OUTPUT_SIZE] = {0};
  
  for (uint8_t i = 0; i < OUTPUT_SIZE; i++) {
    for (uint8_t j = 0; j < RESERVOIR_SIZE; j++) {
      out[i] += esn.W_out[i][j] * esn.reservoir[j];
    }
  }
  
  // Return argmax
  uint8_t max_idx = 0;
  float max_val = out[0];
  for (uint8_t i = 1; i < OUTPUT_SIZE; i++) {
    if (out[i] > max_val) {
      max_val = out[i];
      max_idx = i;
    }
  }
  return max_idx;
}

No softmax needed for inference, just return the class with the highest output value.

Evaluation

The evaluation module in eval.cpp computes a confusion matrix and macro-averaged metrics:

uint16_t CONFUSION_MATRIX[OUTPUT_SIZE][OUTPUT_SIZE] = {0};

void updateConfusionMatrix(const FeatureVector *testWindow,
                           const uint8_t *testLabels, uint16_t n_samples) {
  for (int i = 0; i < n_samples; i++) {
    updateReservoir(testWindow[i]);
    uint8_t prediction = predict();
    if (prediction == testLabels[i]) {
      correct++;
    }
    if (testLabels[i] < 3 && prediction < 3)
      CONFUSION_MATRIX[testLabels[i]][prediction]++;
  }
}

After collecting test data, we compute per-class precision, recall, and F1, then macro-average them:

void printMultiClassMetrics() {
  // For each class, compute TP, FP, FN
  // Then precision = TP / (TP + FP)
  // Then recall = TP / (TP + FN)
  // Macro-average across all 3 classes
  // F1 = 2 * (precision * recall) / (precision + recall)
}

The evaluation loop in engine.cpp handles the CMD_VAL command:

case CMD_VAL: {
  evaluateLoop();
  break;
}
case CMD_VAL_DONE: {
  printResults();
  resetMetrics();
  break;
}

Persistence

After training, the CMD_PERSIST command saves the model state:

case CMD_PERSIST: {
  Coms.send("[CMD=PERSIST]: INIT");
  persistOutputWeights();
  Coms.send("[CMD=PERSIST]: WEIGHTS PERSISTED");
  persistEMA();
  Coms.send("[CMD=PERSIST]: EMAs PERSISTED");
  
  // Track number of batches processed
  if (incNProcessedBatches(nBatchesProcessed)) {
    nBatchesProcessed = 0;
    Coms.send("[CMD=PERSIST]:");
    uint16_t total;
    getNProcessedBatches(&total);
    Coms.send(String(total));
    Coms.send("BATCHES PROCESSED");
  }
  
  Coms.send("[CMD=PERSIST]: DONE");
  break;
}

This saves:

  • Output weights (W_out) to KVStore
  • EMA values to KVStore
  • Batch count to KVStore (used for federated learning weighting)

The CMD_RESET command clears all persisted state, useful for starting fresh.