diff --git a/csharp_package/brainflow/brainflow/ml_module_library.cs b/csharp_package/brainflow/brainflow/ml_module_library.cs index cb6de268c..a0542397b 100644 --- a/csharp_package/brainflow/brainflow/ml_module_library.cs +++ b/csharp_package/brainflow/brainflow/ml_module_library.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; diff --git a/matlab_package/brainflow/BrainFlowClassifiers.m b/matlab_package/brainflow/BrainFlowClassifiers.m index ee4028d91..b70e4202a 100644 --- a/matlab_package/brainflow/BrainFlowClassifiers.m +++ b/matlab_package/brainflow/BrainFlowClassifiers.m @@ -5,4 +5,4 @@ DYN_LIB_CLASSIFIER(1) ONNX_CLASSIFIER(2) end -end \ No newline at end of file +end diff --git a/python_package/brainflow/ml_model.py b/python_package/brainflow/ml_model.py index fe9480e33..0119f3de3 100644 --- a/python_package/brainflow/ml_model.py +++ b/python_package/brainflow/ml_model.py @@ -38,7 +38,7 @@ class BrainFlowModelParams(object): :type classifier: int :param file: file to load model :type file: str - :param other_info: additional information + :param other_info: additional information or configuration (e.g. moving average smoothing via `{"window_len": 5}` or `moving_average=5`). Note: moving average options in other_info are handled uniformly across all classifiers. :type other_info: str :param output_name: output node name :type output_name: str diff --git a/python_package/examples/tests/moving_average_classifier.py b/python_package/examples/tests/moving_average_classifier.py new file mode 100644 index 000000000..55aa30e98 --- /dev/null +++ b/python_package/examples/tests/moving_average_classifier.py @@ -0,0 +1,215 @@ +import numpy as np +import sys +import os + +# add python_package to sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from brainflow.ml_model import MLModel, BrainFlowMetrics, BrainFlowClassifiers, BrainFlowModelParams +from brainflow.exit_codes import BrainFlowError, BrainFlowExitCodes + + +def test_moving_average_classifier(): + print("Testing moving average option on ML classifiers...") + + v1 = np.array([0.1, 0.2, 0.3, 0.2, 0.2], dtype=np.float64) + v2 = np.array([0.5, 0.1, 0.1, 0.1, 0.2], dtype=np.float64) + + # 1. Baseline: MINDFULNESS + DEFAULT_CLASSIFIER without moving average + raw_params = BrainFlowModelParams( + BrainFlowMetrics.MINDFULNESS.value, + BrainFlowClassifiers.DEFAULT_CLASSIFIER.value + ) + raw_model = MLModel(raw_params) + raw_model.prepare() + raw_score_1 = raw_model.predict(v1)[0] + raw_score_2 = raw_model.predict(v2)[0] + raw_model.release() + + print(f"Raw scores: v1={raw_score_1:.6f}, v2={raw_score_2:.6f}") + assert raw_score_1 != raw_score_2 + + # 2. Test JSON config with explicit window_len: '{"window_len": 3}' + mf_params = BrainFlowModelParams( + BrainFlowMetrics.MINDFULNESS.value, + BrainFlowClassifiers.DEFAULT_CLASSIFIER.value + ) + mf_params.other_info = '{"window_len": 3}' + mf_model = MLModel(mf_params) + mf_model.prepare() + + # Step 1: In=v1 -> out1 = raw_score_1 + out1 = mf_model.predict(v1)[0] + print(f"Step 1 (v1): out={out1:.6f}, expected={raw_score_1:.6f}") + assert np.isclose(out1, raw_score_1) + + # Step 2: In=v2 -> out2 = (raw1 + raw2) / 2 + out2 = mf_model.predict(v2)[0] + expected_2 = (raw_score_1 + raw_score_2) / 2.0 + print(f"Step 2 (v2): out={out2:.6f}, expected={expected_2:.6f}") + assert np.isclose(out2, expected_2) + + # Step 3: In=v2 -> out3 = (raw1 + raw2 + raw2) / 3 + out3 = mf_model.predict(v2)[0] + expected_3 = (raw_score_1 + 2.0 * raw_score_2) / 3.0 + print(f"Step 3 (v2): out={out3:.6f}, expected={expected_3:.6f}") + assert np.isclose(out3, expected_3) + + # Step 4: In=v2 -> out4 = (raw2 + raw2 + raw2) / 3 = raw2 (oldest raw1 popped!) + out4 = mf_model.predict(v2)[0] + expected_4 = raw_score_2 + print(f"Step 4 (v2): out={out4:.6f}, expected={expected_4:.6f}") + assert np.isclose(out4, expected_4) + + mf_model.release() + + # 3. Test RESTFULNESS metric with moving average: '{"moving_average": true, "window_len": 2}' + rf_params = BrainFlowModelParams( + BrainFlowMetrics.RESTFULNESS.value, + BrainFlowClassifiers.DEFAULT_CLASSIFIER.value + ) + rf_params.other_info = '{"moving_average": true, "window_len": 2}' + rf_model = MLModel(rf_params) + rf_model.prepare() + + raw_rf_1 = 1.0 - raw_score_1 + raw_rf_2 = 1.0 - raw_score_2 + + rf_out1 = rf_model.predict(v1)[0] + assert np.isclose(rf_out1, raw_rf_1) + + rf_out2 = rf_model.predict(v2)[0] + assert np.isclose(rf_out2, (raw_rf_1 + raw_rf_2) / 2.0) + + rf_model.release() + + # 4. Test default window fallback: '{"moving_average": true}' (default window_len = 5) + def_params = BrainFlowModelParams( + BrainFlowMetrics.MINDFULNESS.value, + BrainFlowClassifiers.DEFAULT_CLASSIFIER.value + ) + def_params.other_info = '{"moving_average": true}' + def_model = MLModel(def_params) + def_model.prepare() + + # Feed 5 identical samples, then a 6th different sample + for _ in range(5): + def_model.predict(v1) + # The 6th prediction should be (4 * raw1 + 1 * raw2) / 5 + def_out6 = def_model.predict(v2)[0] + assert np.isclose(def_out6, (4.0 * raw_score_1 + raw_score_2) / 5.0) + + def_model.release() + + # 5. Test key-value string format: 'moving_average=3' + kv_params = BrainFlowModelParams( + BrainFlowMetrics.MINDFULNESS.value, + BrainFlowClassifiers.DEFAULT_CLASSIFIER.value + ) + kv_params.other_info = "moving_average=3" + kv_model = MLModel(kv_params) + kv_model.prepare() + + kv_out1 = kv_model.predict(v1)[0] + assert np.isclose(kv_out1, raw_score_1) + kv_out2 = kv_model.predict(v2)[0] + assert np.isclose(kv_out2, (raw_score_1 + raw_score_2) / 2.0) + kv_model.release() + + # 6. Test integer string format: '3' + num_params = BrainFlowModelParams( + BrainFlowMetrics.MINDFULNESS.value, + BrainFlowClassifiers.DEFAULT_CLASSIFIER.value + ) + num_params.other_info = "3" + num_model = MLModel(num_params) + num_model.prepare() + + num_out1 = num_model.predict(v1)[0] + assert np.isclose(num_out1, raw_score_1) + num_out2 = num_model.predict(v2)[0] + assert np.isclose(num_out2, (raw_score_1 + raw_score_2) / 2.0) + num_model.release() + + # 7. Negative test: Unrelated other_info string does NOT activate moving average + neg_params = BrainFlowModelParams( + BrainFlowMetrics.MINDFULNESS.value, + BrainFlowClassifiers.DEFAULT_CLASSIFIER.value + ) + neg_params.other_info = '{"unrelated_key": "some_value"}' + neg_model = MLModel(neg_params) + neg_model.prepare() + + neg_out1 = neg_model.predict(v1)[0] + assert np.isclose(neg_out1, raw_score_1) + neg_out2 = neg_model.predict(v2)[0] + # Since moving average is NOT enabled, out2 should be raw_score_2, NOT an average + assert np.isclose(neg_out2, raw_score_2) + neg_model.release() + + # 8. Test multi-element (vector) output moving average with DYN_LIB_CLASSIFIER + import platform + import shutil + import subprocess + import tempfile + + cc = shutil.which('clang') or shutil.which('gcc') or shutil.which('cc') + if cc: + with tempfile.TemporaryDirectory() as tmpdir: + c_code = """ +#if defined(_WIN32) +#define EXPORT __declspec(dllexport) +#else +#define EXPORT __attribute__((visibility("default"))) +#endif + +EXPORT int prepare(void *cls, void *params) { return 0; } +EXPORT int predict(double *data, int data_len, double *output, int *output_len, void *params) { + output[0] = (data_len > 0) ? data[0] : 1.0; + output[1] = (data_len > 1) ? data[1] * 2.0 : 2.0; + output[2] = (data_len > 2) ? data[2] * 3.0 : 3.0; + *output_len = 3; + return 0; +} +EXPORT int release(void *params) { return 0; } +""" + c_file = os.path.join(tmpdir, 'plugin.c') + ext = '.dylib' if platform.system() == 'Darwin' else ('.dll' if platform.system() == 'Windows' else '.so') + so_file = os.path.join(tmpdir, 'libplugin' + ext) + with open(c_file, 'w') as f: + f.write(c_code) + cmd = [cc, '-shared', '-fPIC', c_file, '-o', so_file] + if subprocess.call(cmd) == 0: + vec_params = BrainFlowModelParams( + BrainFlowMetrics.USER_DEFINED.value, + BrainFlowClassifiers.DYN_LIB_CLASSIFIER.value + ) + vec_params.file = so_file + vec_params.other_info = '{"window_len": 2}' + vec_model = MLModel(vec_params) + vec_model.prepare() + + # Feed sample 1: [10, 10, 10] -> plugin returns [10, 20, 30] + vec_out1 = vec_model.predict(np.array([10.0, 10.0, 10.0], dtype=np.float64)) + assert len(vec_out1) == 3 + assert np.allclose(vec_out1, [10.0, 20.0, 30.0]) + + # Feed sample 2: [20, 20, 20] -> plugin returns [20, 40, 60] -> window avg = [15, 30, 45] + vec_out2 = vec_model.predict(np.array([20.0, 20.0, 20.0], dtype=np.float64)) + assert len(vec_out2) == 3 + assert np.allclose(vec_out2, [15.0, 30.0, 45.0]) + + # Feed sample 3: [20, 20, 20] -> window pops sample 1 -> avg = [20, 40, 60] + vec_out3 = vec_model.predict(np.array([20.0, 20.0, 20.0], dtype=np.float64)) + assert len(vec_out3) == 3 + assert np.allclose(vec_out3, [20.0, 40.0, 60.0]) + + vec_model.release() + print("Vector output moving average test passed successfully!") + + print("All moving average classifier tests passed successfully!") + + +if __name__ == '__main__': + test_moving_average_classifier() + diff --git a/src/ml/base_classifier.cpp b/src/ml/base_classifier.cpp index 7d227ac6d..8d7d4651f 100644 --- a/src/ml/base_classifier.cpp +++ b/src/ml/base_classifier.cpp @@ -1,7 +1,14 @@ +#include +#include +#include +#include + #include "base_classifier.h" #include "brainflow_constants.h" +#include "json.hpp" #include "spdlog/sinks/null_sink.h" +using json = nlohmann::json; #define LOGGER_NAME "ml_logger" @@ -60,3 +67,213 @@ int BaseClassifier::set_log_file (const char *log_file) return (int)BrainFlowExitCodes::STATUS_OK; #endif } + +void BaseClassifier::parse_moving_average_params () +{ + use_moving_average = false; + moving_average_window = 0; + + if (params.other_info.empty ()) + { + return; + } + + std::string info = params.other_info; + try + { + if (info.find ('{') != std::string::npos) + { + json j = json::parse (info); + if (j.contains ("moving_average")) + { + if (j["moving_average"].is_boolean ()) + { + use_moving_average = j["moving_average"].get (); + if (use_moving_average) + { + moving_average_window = DEFAULT_MOVING_AVERAGE_WINDOW; + } + } + else if (j["moving_average"].is_number_integer ()) + { + moving_average_window = j["moving_average"].get (); + use_moving_average = (moving_average_window > 0); + } + } + if (j.contains ("window_len")) + { + moving_average_window = j["window_len"].get (); + use_moving_average = true; + } + else if (j.contains ("period")) + { + moving_average_window = j["period"].get (); + use_moving_average = true; + } + } + else if (info.find ('=') != std::string::npos) + { + std::stringstream ss (info); + std::string token; + while (std::getline (ss, token, ';')) + { + size_t eq = token.find ('='); + if (eq != std::string::npos) + { + std::string k = token.substr (0, eq); + std::string v = token.substr (eq + 1); + k.erase (0, k.find_first_not_of (" \t\r\n")); + k.erase (k.find_last_not_of (" \t\r\n") + 1); + v.erase (0, v.find_first_not_of (" \t\r\n")); + v.erase (v.find_last_not_of (" \t\r\n") + 1); + std::transform (k.begin (), k.end (), k.begin (), ::tolower); + std::transform (v.begin (), v.end (), v.begin (), ::tolower); + + if (k == "moving_average") + { + if (v == "true" || v == "1") + { + use_moving_average = true; + if (moving_average_window <= 0) + { + moving_average_window = DEFAULT_MOVING_AVERAGE_WINDOW; + } + } + else if (v == "false" || v == "0") + { + use_moving_average = false; + } + else + { + try + { + moving_average_window = std::stoi (v); + use_moving_average = (moving_average_window > 0); + } + catch (...) + { + } + } + } + else if (k == "window_len" || k == "period") + { + try + { + moving_average_window = std::stoi (v); + use_moving_average = true; + } + catch (...) + { + } + } + } + } + } + else + { + try + { + size_t idx = 0; + int val = std::stoi (info, &idx); + if ((idx == info.length ()) && (val > 0)) + { + moving_average_window = val; + use_moving_average = true; + } + } + catch (...) + { + std::string lower_info = info; + lower_info.erase (0, lower_info.find_first_not_of (" \t\r\n")); + lower_info.erase (lower_info.find_last_not_of (" \t\r\n") + 1); + std::transform ( + lower_info.begin (), lower_info.end (), lower_info.begin (), ::tolower); + if ((lower_info == "moving_average") || (lower_info == "true")) + { + use_moving_average = true; + moving_average_window = DEFAULT_MOVING_AVERAGE_WINDOW; + } + } + } + } + catch (std::exception &e) + { + safe_logger (spdlog::level::warn, + "Failed to parse moving average from other_info: {}. Moving average disabled.", + e.what ()); + use_moving_average = false; + moving_average_window = 0; + } + + if (use_moving_average) + { + if (moving_average_window <= 0) + { + safe_logger (spdlog::level::warn, + "Invalid moving average window ({}), using default {}.", moving_average_window, + DEFAULT_MOVING_AVERAGE_WINDOW); + moving_average_window = DEFAULT_MOVING_AVERAGE_WINDOW; + } + } +} + +void BaseClassifier::reset_moving_average () +{ + window_data.clear (); +} + +void BaseClassifier::apply_moving_average (double *output, int *output_len) +{ + if ((output == NULL) || (output_len == NULL) || (*output_len <= 0)) + { + return; + } + + if (window_data.empty () || ((int)window_data.front ().size () != *output_len)) + { + window_data.clear (); + } + + window_data.push_back (std::vector (output, output + *output_len)); + while ((int)window_data.size () > moving_average_window) + { + window_data.pop_front (); + } + + for (int i = 0; i < *output_len; i++) + { + double sum = 0.0; + for (size_t w = 0; w < window_data.size (); w++) + { + sum += window_data[w][i]; + } + output[i] = sum / window_data.size (); + } +} + +int BaseClassifier::prepare () +{ + parse_moving_average_params (); + reset_moving_average (); + return prepare_classifier (); +} + +int BaseClassifier::predict (double *data, int data_len, double *output, int *output_len) +{ + int res = calculate (data, data_len, output, output_len); + if (res != (int)BrainFlowExitCodes::STATUS_OK) + { + return res; + } + if (use_moving_average) + { + apply_moving_average (output, output_len); + } + return (int)BrainFlowExitCodes::STATUS_OK; +} + +int BaseClassifier::release () +{ + reset_moving_average (); + return release_classifier (); +} diff --git a/src/ml/dyn_lib_classifier.cpp b/src/ml/dyn_lib_classifier.cpp index 269afbd0b..5578efd12 100644 --- a/src/ml/dyn_lib_classifier.cpp +++ b/src/ml/dyn_lib_classifier.cpp @@ -2,7 +2,7 @@ #include "brainflow_constants.h" -int DynLibClassifier::prepare () +int DynLibClassifier::prepare_classifier () { if (dll_loader != NULL) { @@ -33,7 +33,7 @@ int DynLibClassifier::prepare () return func ((void *)this, ¶ms); } -int DynLibClassifier::predict (double *data, int data_len, double *output, int *output_len) +int DynLibClassifier::calculate (double *data, int data_len, double *output, int *output_len) { if (dll_loader == NULL) { @@ -50,7 +50,7 @@ int DynLibClassifier::predict (double *data, int data_len, double *output, int * return func (data, data_len, output, output_len, ¶ms); } -int DynLibClassifier::release () +int DynLibClassifier::release_classifier () { if (dll_loader == NULL) { diff --git a/src/ml/inc/base_classifier.h b/src/ml/inc/base_classifier.h index ccd7abd33..b287481ae 100644 --- a/src/ml/inc/base_classifier.h +++ b/src/ml/inc/base_classifier.h @@ -1,8 +1,15 @@ #pragma once +#include +#include +#include + +#include "brainflow_constants.h" #include "brainflow_model_params.h" #include "spdlog/spdlog.h" +#define DEFAULT_MOVING_AVERAGE_WINDOW 5 + class BaseClassifier { public: @@ -16,12 +23,15 @@ class BaseClassifier BaseClassifier (struct BrainFlowModelParams model_params) : params (model_params) { skip_logs = false; + use_moving_average = false; + moving_average_window = 0; } virtual ~BaseClassifier () { skip_logs = true; } + // Classifier ml_logger should not be called from destructors, to ensure that there are safe log // methods Classifierml_logger still available but should be used only outside destructors template @@ -45,7 +55,26 @@ class BaseClassifier } } - virtual int prepare () = 0; - virtual int predict (double *data, int data_len, double *output, int *output_len) = 0; - virtual int release () = 0; + int prepare (); + int predict (double *data, int data_len, double *output, int *output_len); + int release (); + +protected: + bool use_moving_average; + int moving_average_window; + std::deque> window_data; + + void parse_moving_average_params (); + void reset_moving_average (); + void apply_moving_average (double *output, int *output_len); + + virtual int prepare_classifier () + { + return (int)BrainFlowExitCodes::STATUS_OK; + } + virtual int calculate (double *data, int data_len, double *output, int *output_len) = 0; + virtual int release_classifier () + { + return (int)BrainFlowExitCodes::STATUS_OK; + } }; diff --git a/src/ml/inc/dyn_lib_classifier.h b/src/ml/inc/dyn_lib_classifier.h index 28a075eff..c5fd95aa1 100644 --- a/src/ml/inc/dyn_lib_classifier.h +++ b/src/ml/inc/dyn_lib_classifier.h @@ -6,6 +6,12 @@ #include "runtime_dll_loader.h" +// DynLibClassifier loads user-provided shared libraries (.so / .dll / .dylib) exporting +// "prepare", "predict", and "release" C functions. +// Note for plugin authors: BaseClassifier inspects params.other_info for moving average +// configuration ("moving_average", "window_len", "period", or bare positive integer / "true"). +// If enabled, moving average smoothing is applied to the plugin's output. To avoid unintended +// smoothing, custom plugins using other_info should use unique JSON or key-value keys. class DynLibClassifier : public BaseClassifier { public: @@ -20,11 +26,11 @@ class DynLibClassifier : public BaseClassifier release (); } - int prepare () override; - int predict (double *data, int data_len, double *output, int *output_len) override; - int release () override; - protected: + int prepare_classifier () override; + int calculate (double *data, int data_len, double *output, int *output_len) override; + int release_classifier () override; + virtual std::string get_dyn_lib_path () { return params.file; diff --git a/src/ml/inc/mindfulness_classifier.h b/src/ml/inc/mindfulness_classifier.h index 0f6007be5..4958d09c8 100644 --- a/src/ml/inc/mindfulness_classifier.h +++ b/src/ml/inc/mindfulness_classifier.h @@ -16,7 +16,6 @@ class MindfulnessClassifier : public BaseClassifier release (); } - int prepare () override; - int predict (double *data, int data_len, double *output, int *output_len) override; - int release () override; +protected: + int calculate (double *data, int data_len, double *output, int *output_len) override; }; diff --git a/src/ml/inc/restfulness_classifier.h b/src/ml/inc/restfulness_classifier.h index dcfa9252f..6c97ce3b2 100644 --- a/src/ml/inc/restfulness_classifier.h +++ b/src/ml/inc/restfulness_classifier.h @@ -11,9 +11,10 @@ class RestfulnessClassifier : public MindfulnessClassifier { } - int predict (double *data, int data_len, double *output, int *output_len) override +protected: + int calculate (double *data, int data_len, double *output, int *output_len) override { - int res = MindfulnessClassifier::predict (data, data_len, output, output_len); + int res = MindfulnessClassifier::calculate (data, data_len, output, output_len); if (res != (int)BrainFlowExitCodes::STATUS_OK) { return res; diff --git a/src/ml/mindfulness_classifier.cpp b/src/ml/mindfulness_classifier.cpp index 62c52c611..6f1ea55ac 100644 --- a/src/ml/mindfulness_classifier.cpp +++ b/src/ml/mindfulness_classifier.cpp @@ -7,12 +7,8 @@ #include "mindfulness_model.h" -int MindfulnessClassifier::prepare () -{ - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int MindfulnessClassifier::predict (double *data, int data_len, double *output, int *output_len) +int MindfulnessClassifier::calculate ( + double *data, int data_len, double *output, int *output_len) { if ((data_len < 5) || (data == NULL) || (output == NULL)) { @@ -30,8 +26,3 @@ int MindfulnessClassifier::predict (double *data, int data_len, double *output, *output_len = 1; return (int)BrainFlowExitCodes::STATUS_OK; } - -int MindfulnessClassifier::release () -{ - return (int)BrainFlowExitCodes::STATUS_OK; -} \ No newline at end of file diff --git a/src/ml/onnx/inc/onnx_classifier.h b/src/ml/onnx/inc/onnx_classifier.h index 18b8cc216..525769220 100644 --- a/src/ml/onnx/inc/onnx_classifier.h +++ b/src/ml/onnx/inc/onnx_classifier.h @@ -51,7 +51,8 @@ class OnnxClassifier : public BaseClassifier release (); } - int prepare () override; - int predict (double *data, int data_len, double *output, int *output_len) override; - int release () override; +protected: + int prepare_classifier () override; + int calculate (double *data, int data_len, double *output, int *output_len) override; + int release_classifier () override; }; diff --git a/src/ml/onnx/onnx_classifier.cpp b/src/ml/onnx/onnx_classifier.cpp index 02d99724b..eb2678eb5 100644 --- a/src/ml/onnx/onnx_classifier.cpp +++ b/src/ml/onnx/onnx_classifier.cpp @@ -14,7 +14,7 @@ void log_onnx_msg (void *param, OrtLoggingLevel severity, const char *category, } } -int OnnxClassifier::prepare () +int OnnxClassifier::prepare_classifier () { if (dll_loader != NULL) { @@ -62,7 +62,7 @@ int OnnxClassifier::prepare () } -int OnnxClassifier::predict (double *data, int data_len, double *output, int *output_len) +int OnnxClassifier::calculate (double *data, int data_len, double *output, int *output_len) { int res = (int)BrainFlowExitCodes::STATUS_OK; if (ort == NULL) @@ -390,7 +390,7 @@ int OnnxClassifier::predict (double *data, int data_len, double *output, int *ou return res; } -int OnnxClassifier::release () +int OnnxClassifier::release_classifier () { if ((allocator != NULL) && (ort != NULL)) {