# Cloud API Authentication
Source: https://docs.nimbusbci.com/cloud-api/authentication
Authenticate Nimbus Cloud API requests with API keys, license validation, and secure usage patterns for Julia SDK integrations.
The Nimbus API uses API keys for authentication and license management. All requests to the API must include a valid API key.
**Python SDK Users:** The Python SDK (`nimbus-bci`) does **not require authentication** or API keys. It runs entirely locally on your machine. This authentication guide is for **Julia SDK users only**.
For Python SDK installation, see [Python SDK Installation](/python-sdk/installation).
## Getting Your API Key
Contact **[hello@nimbusbci.com](mailto:hello@nimbusbci.com)** to request your API key and discuss your BCI use case.
API keys are currently issued with a license tier based on your use case. Please include details about your intended application, expected volume, and timeline.
## API Key Format
* **Production keys**: `nbci_live_` + 48 hexadecimal characters
* **Test keys**: `nbci_test_` + 48 hexadecimal characters
Example: `nbci_live_a1b2c3d4e5f678901234567890abcdef12345678901234567890`
## License Tiers
| Tier | Monthly Quota | Features | Use Case |
| -------------- | ------------------ | ------------------------------- | ----------------------------- |
| **Free** | 10,000 inferences | Basic models, batch inference | Personal projects, evaluation |
| **Research** | 50,000 inferences | All models, streaming, training | Academic research |
| **Commercial** | 500,000 inferences | Priority support, custom models | Production applications |
| **Enterprise** | Unlimited | Dedicated support, on-premise | Large-scale deployments |
## Authentication in Julia SDK
The NimbusSDK.jl package uses a two-step approach:
### One-Time Setup
Install the proprietary core with your API key (one-time setup):
```julia theme={null}
using NimbusSDK
# One-time setup: Downloads core and validates API key
NimbusSDK.install_core("nbci_live_your_key_here")
# SDK is now ready to use
# The core and credentials are cached locally
```
**What happens during install\_core:**
1. Validates your API key with the Nimbus API
2. Downloads the proprietary NimbusSDKCore package
3. Caches your credentials locally for offline use
4. You only need to do this once per machine
### Environment Variables
For security, store your API key in an environment variable:
```julia theme={null}
using NimbusSDK
# Set in shell: export NIMBUS_API_KEY="nbci_live_..."
api_key = ENV["NIMBUS_API_KEY"]
NimbusSDK.install_core(api_key)
```
### After Installation
Once the core is installed, you can use the SDK in any project without re-authenticating:
```julia theme={null}
using NimbusSDK
# No authentication needed - already installed!
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
results = predict_batch(model, data)
```
## API Endpoints
### POST /v1/auth/validate
Validate an API key and return user permissions, quota, and license information.
**Base URL**: `https://api.nimbusbci.com`
**Endpoint**: `/v1/auth/validate`
**Request:**
```json theme={null}
{
"api_key": "nbci_live_a1b2c3..."
}
```
**Response (Success):**
```json theme={null}
{
"valid": true,
"user_id": "usr_1234567890",
"email": "researcher@university.edu",
"name": "Dr. Jane Smith",
"organization": "Neuroscience Lab",
"license_type": "research",
"features": [
"batch_inference",
"streaming",
"training",
"model_export"
],
"quota": {
"monthly_limit": 50000,
"used": 1234,
"remaining": 48766,
"resets_at": "2025-11-01T00:00:00Z"
},
"expires_at": "2026-01-01T00:00:00Z"
}
```
**Response (Invalid Key):**
```json theme={null}
{
"error": "Invalid or inactive API key",
"code": "INVALID_KEY"
}
```
**Response (Quota Exceeded):**
```json theme={null}
{
"error": "Monthly quota exceeded",
"code": "QUOTA_EXCEEDED"
}
```
**cURL Example:**
```bash theme={null}
curl -X POST https://api.nimbusbci.com/v1/auth/validate \
-H "Content-Type: application/json" \
-d '{"api_key": "nbci_live_your_key_here"}'
```
### POST /v1/auth/refresh
Refresh API key credentials and get updated quota information.
**Request:**
```json theme={null}
{
"api_key": "nbci_live_a1b2c3..."
}
```
**Response:**
```json theme={null}
{
"valid": true,
"license_type": "research",
"features": [
"batch_inference",
"streaming",
"training",
"model_export"
],
"quota": {
"monthly_limit": 50000,
"used": 1250,
"remaining": 48750,
"resets_at": "2025-11-01T00:00:00Z"
},
"expires_at": "2026-01-01T00:00:00Z"
}
```
### POST /v1/auth/revoke
Revoke an API key, making it permanently inactive.
**This action is irreversible**. Once revoked, the API key cannot be reactivated. Contact support to generate a new key.
**Request:**
```json theme={null}
{
"api_key": "nbci_live_a1b2c3...",
"reason": "No longer needed"
}
```
**Request Fields:**
* `api_key` (required): The API key to revoke
* `reason` (optional): Reason for revocation (logged for audit purposes)
**Response:**
```json theme={null}
{
"revoked": true,
"message": "API key successfully revoked"
}
```
**Error Response:**
```json theme={null}
{
"error": "API key not found or already revoked",
"code": "KEY_NOT_FOUND"
}
```
## SDK Installation
### GET /v1/installer/github-token
Get a GitHub access token for installing the proprietary NimbusSDKCore package. This endpoint is used by the `install_core()` function in NimbusSDK.jl.
**Authentication**: Required (API key)
**Request Methods**: GET or POST
**Recommended Authentication (GET):**
```bash theme={null}
curl https://api.nimbusbci.com/v1/installer/github-token \
-H "X-API-Key: nbci_live_your_key"
```
**Request Body (POST):**
```json theme={null}
{
"api_key": "nbci_live_a1b2c3..."
}
```
**Compatibility Option (GET query parameter):**
```bash theme={null}
curl "https://api.nimbusbci.com/v1/installer/github-token?api_key=nbci_live_your_key"
```
Avoid query-string keys in production when possible because URLs are often captured in logs, browser history, proxies, and referrer headers.
**Response (Success):**
```json theme={null}
{
"success": true,
"github_token": "ghp_xxxxxxxxxxxxxxxxxxxx",
"expires_at": null,
"message": "GitHub token retrieved successfully"
}
```
**Response Fields:**
* `success` (boolean): Whether the request was successful
* `github_token` (string): Temporary GitHub Personal Access Token for package installation
* `expires_at` (string|null): Token expiration time (null for non-expiring tokens)
* `message` (string): Success message
**Error Responses:**
```json theme={null}
{
"error": "API key is required",
"code": "MISSING_KEY"
}
```
```json theme={null}
{
"error": "Invalid or inactive API key",
"code": "INVALID_KEY"
}
```
```json theme={null}
{
"error": "GitHub token not configured",
"code": "NOT_CONFIGURED",
"message": "GitHub token is not configured on the server. Please contact support."
}
```
**Usage in Julia SDK:**
This endpoint is called automatically by `NimbusSDK.install_core()`:
```julia theme={null}
using NimbusSDK
# This internally calls /v1/installer/github-token
NimbusSDK.install_core("nbci_live_your_key")
```
**Security**: The GitHub token is temporary and scoped only to read access for the private NimbusSDKCore repository. It cannot be used to access other repositories or perform write operations.
**Do not share or store the GitHub token**. It is provided temporarily for installation purposes only. The token may be rotated or revoked at any time.
## Error Codes
| Code | HTTP Status | Description |
| --------------------- | ----------- | -------------------------------------------- |
| `MISSING_KEY` | 400 | API key not provided |
| `INVALID_FORMAT` | 400 | API key format is invalid |
| `MISSING_EVENT_TYPE` | 400 | Event type not provided (analytics endpoint) |
| `INVALID_KEY` | 401 | API key is invalid or inactive |
| `KEY_EXPIRED` | 401 | API key has expired |
| `KEY_NOT_FOUND` | 404 | API key not found (revoke endpoint) |
| `METHOD_NOT_ALLOWED` | 405 | HTTP method not allowed for this endpoint |
| `QUOTA_EXCEEDED` | 429 | Monthly usage quota exceeded |
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests in short time |
| `SERVER_ERROR` | 500 | Internal server error |
| `NOT_CONFIGURED` | 503 | Server configuration issue |
## Security Best Practices
### Protect Your API Key
**Never expose your API key in:**
* Client-side code (browsers, mobile apps)
* Public repositories (GitHub, GitLab)
* Logs or error messages
* Shared environments
API keys should only be used in secure server environments or local development machines.
### Best Practices
**✅ Do:**
* Store API keys in environment variables
* Use separate keys for development/production
* Rotate keys regularly (every 90 days recommended)
* Monitor API usage for anomalies
* Use offline mode after initial validation for security
* Keep keys in `.gitignore` and `.env` files
**❌ Don't:**
* Hardcode keys in source code
* Share keys via email or messaging apps
* Use the same key across multiple projects
* Commit keys to version control
* Log API keys in application logs
### AuthSession Structure
The `authenticate()` function returns an `AuthSession` object:
```julia theme={null}
struct AuthSession
api_key::String # Your API key
user_id::String # Unique user identifier
license_type::Symbol # :trial, :research, :commercial, :enterprise
expires_at::DateTime # When credentials expire
features_enabled::Vector{Symbol} # Enabled features
usage_quota::Union{Int, Nothing} # Remaining calls (nothing = unlimited)
usage_quota_max::Union{Int, Nothing} # Monthly quota limit (nothing = unlimited)
refresh_token::Union{String, Nothing} # Token for refreshing credentials
end
```
### Julia Example with Environment Variables
**.env file:**
```bash theme={null}
# Store in .env (add to .gitignore!)
NIMBUS_API_KEY=nbci_live_your_key_here
```
**Julia code:**
```julia theme={null}
using NimbusSDK
api_key = ENV["NIMBUS_API_KEY"]
# One-time installation and authentication
NimbusSDK.install_core(api_key)
# Advanced: inspect the core authentication session directly
using NimbusSDKCore
session = NimbusSDKCore.authenticate(api_key)
println("License type: $(session.license_type)")
println("Features: $(session.features_enabled)")
println("Expires: $(session.expires_at)")
```
## Rate Limiting
The Nimbus API implements rate limiting to prevent abuse:
* **Validate endpoint**: 100 requests per minute
* **Refresh endpoint**: 10 requests per minute
* **Other endpoints**: 1000 requests per minute
If you exceed the rate limit, you'll receive a `429` response with a `Retry-After` header.
## Monitoring Usage
Track your API usage through the SDK:
```julia theme={null}
using NimbusSDK
# One-time installation and authentication
NimbusSDK.install_core(api_key)
# Usage information is available through the core quota helpers
using NimbusSDKCore
status = NimbusSDKCore.get_quota_status()
println("Quota remaining: $(status.remaining)")
println("Monthly limit: $(status.monthly_limit)")
println("Usage: $(round(status.usage_percentage, digits=1))%")
```
## License Features
Different license tiers unlock different SDK features:
| Feature | Free | Research | Commercial | Enterprise |
| ------------------------- | ----- | -------- | ---------- | ------------ |
| **Batch Inference** | ✅ | ✅ | ✅ | ✅ |
| **Streaming Inference** | ❌ | ✅ | ✅ | ✅ |
| **Model Training** | ❌ | ✅ | ✅ | ✅ |
| **Model Calibration** | ❌ | ✅ | ✅ | ✅ |
| **Pre-trained Models** | Basic | All | All | All + Custom |
| **Priority Support** | ❌ | ❌ | ✅ | ✅ |
| **On-premise Deployment** | ❌ | ❌ | ❌ | ✅ |
| **Custom Models** | ❌ | ❌ | ✅ | ✅ |
## Upgrading Your License
To upgrade your license tier, contact **[hello@nimbusbci.com](mailto:hello@nimbusbci.com)** with:
* Current license tier
* Desired tier
* Use case details
* Expected monthly volume
## Authentication FAQ
No. The Python SDK runs locally and does not require API authentication. Cloud API authentication is primarily for Julia SDK installation, licensing, and model registry workflows.
`NimbusSDK.install_core()` is typically a one-time setup per machine. After the core is installed and credentials are cached, normal SDK usage does not require repeated setup.
Common causes are invalid key format, expired or revoked keys, quota/rate limits, and missing environment-variable configuration in deployment environments.
## Related Pages
Endpoint overview, base URL, and service scope.
Model registry and analytics endpoint details.
End-to-end install and first-inference setup path.
Local-only Python workflow without API authentication.
## Troubleshooting
### "Invalid API key" Error
**Causes:**
* API key is incorrectly formatted
* API key has been revoked
* API key has expired
**Solution:** Verify the API key is correct and contact support if needed.
### "Quota exceeded" Error
**Causes:**
* Monthly usage limit reached
* Burst usage exceeded threshold
**Solution:** Wait for quota reset or contact support to upgrade your tier.
### "Rate limit exceeded" Error
**Causes:**
* Too many requests in short time period
**Solution:** Implement exponential backoff and retry logic.
```julia theme={null}
using NimbusSDK
function authenticate_with_retry(api_key; max_retries=3)
for attempt in 1:max_retries
try
return NimbusSDK.install_core(api_key)
catch e
if attempt == max_retries
rethrow(e)
end
sleep(2^attempt) # Exponential backoff
end
end
end
session = authenticate_with_retry(api_key)
```
## Next Read
Complete SDK reference
Get started with NimbusSDK (Julia)
Browse available models
Working code examples
## Support
For authentication issues or license inquiries:
* **Email**: [hello@nimbusbci.com](mailto:hello@nimbusbci.com)
* **Website**: [https://www.nimbusbci.com](https://www.nimbusbci.com)
* **API Status**: [https://api.nimbusbci.com/api/health](https://api.nimbusbci.com/api/health)
# Cloud API Endpoints
Source: https://docs.nimbusbci.com/cloud-api/inference-endpoints
Reference for Nimbus Cloud API endpoints covering model registry access, inference operations, analytics, and usage workflows.
The Nimbus API provides backend services for model management and analytics. **Note**: Inference is performed locally by NimbusSDK.jl, not via API calls.
## Start Here
Set up API keys, license checks, and secure access patterns.
Review API scope, base URL, and service architecture.
Run authenticated setup and load your first pre-trained model.
## Base URL
```
https://api.nimbusbci.com
```
## Model Registry
### GET /v1/models/list
List available pre-trained models based on your license tier.
**Authentication**: Required (API key)
**Query Parameters:**
| Parameter | Type | Description |
| ---------- | ------ | -------------------------------------------------------------------------------- |
| `api_key` | string | Your Nimbus API key (supported for compatibility; prefer the `X-API-Key` header) |
| `paradigm` | string | Filter by BCI paradigm: `motor_imagery`, `p300`, `ssvep` (optional) |
| `type` | string | Filter by model type: `NimbusLDA`, `NimbusQDA`, `NimbusProbit` (optional) |
The model registry is used by the **Julia SDK** and currently includes `NimbusLDA`, `NimbusQDA`, and `NimbusProbit`.\
`NimbusSTS` / Bayesian STS is **Python SDK only** and does not appear in the model registry.
**Request:**
```bash theme={null}
curl https://api.nimbusbci.com/v1/models/list \
-H "X-API-Key: nbci_live_your_key"
```
**Response:**
```json theme={null}
{
"models": [
{
"name": "motor_imagery_4class_v1",
"version": "1.0.0",
"type": "NimbusLDA",
"paradigm": "motor_imagery",
"n_features": 16,
"n_classes": 4,
"size_mb": 2.3,
"download_url": "https://models.nimbusbci.com/rxlda/motor_imagery_4class_v1.jld2",
"checksum": "sha256:abc123...",
"requires_license": "free",
"metadata": {
"description": "4-class motor imagery classifier (left/right/feet/tongue)",
"training_subjects": 50,
"accuracy": "72-88%",
"feature_type": "csp",
"recommended_use": "Motor imagery BCI applications"
}
},
{
"name": "p300_binary_v1",
"version": "1.0.0",
"type": "NimbusQDA",
"paradigm": "p300",
"n_features": 8,
"n_classes": 2,
"size_mb": 1.8,
"download_url": "https://models.nimbusbci.com/qda/p300_binary_v1.jld2",
"checksum": "sha256:def456...",
"requires_license": "research",
"metadata": {
"description": "P300 target/non-target classifier",
"training_subjects": 30,
"accuracy": "85-92%",
"feature_type": "erp",
"recommended_use": "P300 speller applications"
}
}
],
"total": 2
}
```
**Response Fields:**
* `name` (string): Unique model identifier
* `version` (string): Model version
* `type` (string): Model type (`NimbusLDA`, `NimbusQDA`, or `NimbusProbit`)
* `paradigm` (string): BCI paradigm (`motor_imagery`, `p300`, `ssvep`, etc.)
* `n_features` (number): Expected number of input features
* `n_classes` (number): Number of output classes
* `size_mb` (number): Model file size in megabytes
* `download_url` (string): Direct download URL for model file
* `checksum` (string): SHA-256 checksum for verification
* `requires_license` (string): Minimum license tier required
* `metadata` (object): Additional model information
**Filtering Examples:**
```bash theme={null}
# Get only motor imagery models
curl "https://api.nimbusbci.com/v1/models/list?paradigm=motor_imagery" \
-H "X-API-Key: nbci_live_..."
# Get only NimbusLDA models
curl "https://api.nimbusbci.com/v1/models/list?type=NimbusLDA" \
-H "X-API-Key: nbci_live_..."
```
### Using in Julia SDK
Use this endpoint through `list_available_models()` to discover model names that your license can access:
```julia theme={null}
using NimbusSDK
# Authenticate
NimbusSDK.install_core("nbci_live_your_key")
# Discover available models
models = list_available_models(model_type="NimbusLDA")
# Load a bundled or local model by name/path
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
```
## Analytics & Logging
### POST /v1/analytics/log
Log inference events and usage metrics (optional, for analytics tracking).
**Authentication**: Required (API key)
**Request:**
```json theme={null}
{
"api_key": "nbci_live_...",
"event_type": "inference",
"model_name": "motor_imagery_4class_v1",
"inference_type": "batch",
"duration_ms": 245,
"success": true,
"metadata": {
"n_trials": 20,
"accuracy": 0.85,
"mean_confidence": 0.78
}
}
```
**Request Fields:**
| Field | Type | Required | Description |
| ---------------- | ------- | -------- | ---------------------------------------------- |
| `api_key` | string | Yes | Your API key |
| `event_type` | string | Yes | Event type: `inference`, `model_load`, `error` |
| `model_name` | string | No | Model identifier |
| `inference_type` | string | No | `batch` or `streaming` |
| `duration_ms` | number | No | Duration in milliseconds |
| `success` | boolean | No | Whether operation succeeded |
| `error_message` | string | No | Error description if failed |
| `metadata` | object | No | Additional event data |
**Response:**
```json theme={null}
{
"logged": true,
"event_id": "evt_1234567890",
"quota": {
"used": 1235,
"remaining": 48765
}
}
```
**cURL Example:**
```bash theme={null}
curl -X POST https://api.nimbusbci.com/v1/analytics/log \
-H "Content-Type: application/json" \
-d '{
"api_key": "nbci_live_...",
"event_type": "inference",
"model_name": "motor_imagery_4class_v1",
"inference_type": "batch",
"duration_ms": 245,
"metadata": {"n_trials": 20}
}'
```
Analytics logging is **optional**. The SDK works without sending analytics data. Use this endpoint if you want to track usage patterns and performance metrics.
## Health Check
### GET /api/health
Check API status and availability.
**Authentication**: Not required
**Request:**
```bash theme={null}
curl https://api.nimbusbci.com/api/health
```
**Response:**
```json theme={null}
{
"status": "healthy",
"timestamp": "2025-10-27T14:30:00Z",
"version": "v1",
"database": "connected"
}
```
**Response Fields:**
* `status` (string): API status - `"healthy"` or `"unhealthy"`
* `timestamp` (string): Current server timestamp (ISO 8601)
* `version` (string): API version
* `database` (string): Database connection status - `"connected"` or `"disconnected"`
**Unhealthy Response:**
```json theme={null}
{
"status": "unhealthy",
"timestamp": "2025-10-27T14:30:00Z",
"error": "Health check failed"
}
```
## Error Responses
All endpoints return consistent error responses:
```json theme={null}
{
"error": "Error description",
"code": "ERROR_CODE"
}
```
**Common Error Codes:**
| Code | HTTP Status | Description |
| --------------------- | ----------- | -------------------------------------------- |
| `MISSING_KEY` | 400 | API key not provided |
| `INVALID_FORMAT` | 400 | API key format invalid |
| `MISSING_EVENT_TYPE` | 400 | Event type not provided (analytics endpoint) |
| `INVALID_KEY` | 401 | API key is invalid or inactive |
| `KEY_EXPIRED` | 401 | API key has expired |
| `KEY_NOT_FOUND` | 404 | API key not found (revoke endpoint) |
| `METHOD_NOT_ALLOWED` | 405 | HTTP method not allowed |
| `QUOTA_EXCEEDED` | 429 | Monthly quota limit reached |
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests |
| `SERVER_ERROR` | 500 | Internal server error |
| `NOT_CONFIGURED` | 503 | Server configuration issue |
## Rate Limits
All API endpoints implement rate limiting to ensure fair usage and system stability.
| Endpoint | Requests per Minute |
| ---------------------------- | ------------------- |
| `/v1/auth/validate` | 100 |
| `/v1/auth/refresh` | 10 |
| `/v1/auth/revoke` | 10 |
| `/v1/installer/github-token` | 20 |
| `/v1/models/list` | 60 |
| `/v1/analytics/log` | 1000 |
| `/api/health` | Unlimited |
**Rate Limit Headers:**
When you make an API request, the response includes rate limit information in the headers:
* `X-RateLimit-Limit`: Maximum requests allowed per minute
* `X-RateLimit-Remaining`: Requests remaining in current window
* `X-RateLimit-Reset`: Unix timestamp when the limit resets
**Exceeding Limits:**
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response with a `Retry-After` header indicating when you can retry.
## Usage Examples
### Complete Workflow in Julia
```julia theme={null}
using NimbusSDK
# 1. Authenticate
NimbusSDK.install_core("nbci_live_your_key")
# 2. Load a bundled or local pre-trained model
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
# 3. Run inference (local, no API call)
data = BCIData(features, metadata, labels)
results = predict_batch(model, data)
# 4. Optional: Log analytics
# (SDK can automatically log if configured)
println("Accuracy: $(sum(results.predictions .== labels) / length(labels))")
```
### List All Available Models
```bash theme={null}
# Get all models your license allows
curl -X GET https://api.nimbusbci.com/v1/models/list \
-H "X-API-Key: nbci_live_your_key" \
| jq '.models[] | {name, type, paradigm}'
```
### Check Model Availability
```bash theme={null}
# Check if specific model is available
curl -X GET https://api.nimbusbci.com/v1/models/list \
-H "X-API-Key: nbci_live_your_key" \
| jq '.models[] | select(.name=="motor_imagery_4class_v1")'
```
## License Requirements
Different models require different license tiers:
| Model | License Required | Description |
| ------------------------- | ---------------- | -------------------------------- |
| `motor_imagery_4class_v1` | Free | Basic 4-class motor imagery |
| `motor_imagery_2class_v1` | Free | Binary motor imagery |
| `p300_binary_v1` | Research | P300 target detection |
| `ssvep_multiclass_v1` | Research | SSVEP multi-frequency |
| Custom models | Commercial+ | Customer-specific trained models |
Your API key's license tier determines which models you can access. Attempting to load a model above your tier will return a `403 Forbidden` error.
## Important Notes
### Inference is Local
**The Nimbus API does NOT perform inference.** Inference happens locally via NimbusSDK.jl using RxInfer.jl. This approach provides:
✅ **Privacy**: Your EEG data never leaves your machine\
✅ **Speed**: No network latency\
✅ **Reliability**: Works offline after initial setup\
✅ **Scalability**: No API server bottlenecks
The API only provides:
* Authentication and licensing
* Pre-trained model distribution
* Optional analytics collection
### Model Loading
`load_model()` loads bundled models or local `.jld2` files by name/path:
```julia theme={null}
# Load a bundled model by name
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
# Load a local model file
model = load_model(NimbusLDA, "my_model.jld2")
```
Use `/v1/models/list` or `list_available_models()` to discover the model catalog before loading an available model.
## Endpoints FAQ
No. Inference runs locally in NimbusSDK.jl. The API is used for authentication, model registry access, installation flows, and optional analytics logging.
Start with authentication (`/v1/auth/validate` via SDK setup), then use `/v1/models/list` to discover available models for your license tier.
`load_model(...)` reads bundled or local `.jld2` model files, so inference setup avoids network latency after SDK installation and authentication.
## Next Read
API key setup and management
Complete SDK reference
Train custom models
Working examples
## Support
For API questions or model requests:
* **Email**: [hello@nimbusbci.com](mailto:hello@nimbusbci.com)
* **API Status**: [https://api.nimbusbci.com/api/health](https://api.nimbusbci.com/api/health)
* **Documentation**: [https://docs.nimbusbci.com](https://docs.nimbusbci.com)
# Cloud API Reference
Source: https://docs.nimbusbci.com/cloud-api/introduction
Cloud API reference for Nimbus authentication, licensing, model registry, and backend endpoints used by the Julia SDK.
The Nimbus API provides backend services for the Julia SDK (NimbusSDK.jl), including:
* **Authentication**: API key validation and licensing (Julia SDK only)
* **Model Registry**: Access to pre-trained models (Julia SDK only)
* **Analytics**: Usage tracking and performance metrics
* **Quota Management**: License-based usage limits
**Python SDK Users**: The Python SDK (`nimbus-bci`) does **not** require API keys or API access. All processing is local. See the [Python SDK documentation](/python-sdk/introduction) for details.
## Start Here
Validate API keys, quotas, and license behavior.
Review model registry and analytics API routes.
Install NimbusSDK.jl and enable authenticated workflows.
## API Base URL
```
https://api.nimbusbci.com
```
## Authentication
All API requests require authentication via API key. Prefer the `X-API-Key` header for server-to-server calls:
* Header: `X-API-Key: nbci_live_...`
* JSON body parameter: `"api_key": "nbci_live_..."` for POST endpoints that accept a request body
Some GET endpoints also accept `?api_key=nbci_live_...` for compatibility. Avoid query-string keys in production because URLs are commonly captured in logs, browser history, proxies, and referrer headers.
### API Key Format
* **Production keys**: `nbci_live_` + 48 hex characters
* **Test keys**: `nbci_test_` + 48 hex characters
### License Tiers
| Tier | Monthly Quota | Features | Use Case |
| -------------- | ------------------ | ------------------------------- | ----------------------------- |
| **Free** | 10,000 inferences | Basic models, batch inference | Personal projects, evaluation |
| **Research** | 50,000 inferences | All models, streaming, training | Academic research |
| **Commercial** | 500,000 inferences | Priority support, custom models | Production applications |
| **Enterprise** | Unlimited | Dedicated support, on-premise | Large-scale deployments |
## SDK Comparison
| Feature | Python SDK | Julia SDK |
| -------------------- | ----------------------- | ------------------------- |
| **API Key Required** | ❌ No | ✅ Yes |
| **API Access** | Not needed | Required for installation |
| **Authentication** | None | Via API key |
| **Model Registry** | Not available | Pre-trained models |
| **Licensing** | Proprietary (free eval) | Commercial tiers |
## SDK Setup
This page documents the backend API scope and endpoint surface. For the Julia installation and first-inference walkthrough, use the [Julia SDK Quickstart](/julia-sdk/quickstart). For Python, no API setup is required; install `nimbus-bci` from the [Python SDK Quickstart](/python-sdk/quickstart).
## Endpoints Overview
The Nimbus API provides the following endpoints:
* **Authentication**: `/v1/auth/validate`, `/v1/auth/refresh`, `/v1/auth/revoke`
* **SDK Installation**: `/v1/installer/github-token`
* **Models**: `/v1/models/list`
* **Analytics**: `/v1/analytics/log`
* **Health**: `/api/health`
Detailed documentation for each endpoint is available in the following pages.
## Next Read
No API key needed - get started immediately
Complete Julia SDK reference (requires API key)
API key validation and management (Julia SDK)
Pre-trained models (Julia SDK)
# Message Passing Architecture
Source: https://docs.nimbusbci.com/core-concepts/message-passing
Understanding the reactive message passing architecture in Nimbus. Learn about factor graphs, belief propagation, and RxInfer.jl reactive inference.
Nimbus uses reactive message passing on factor graphs to perform efficient Bayesian inference for BCI applications. This architecture enables real-time probabilistic reasoning while maintaining scalability and flexibility. Understanding this foundation helps you build more effective BCI systems.
**SDK Implementation:**
* **Julia SDK**: Full access to RxInfer.jl's reactive message passing on factor graphs, enabling custom model development
* **Python SDK**: Packaged Bayesian heads (`NimbusLDA`, `NimbusQDA`, `NimbusSoftmax`, and `NimbusSTS`) with optimized inference engines. [`Personalizer`](/personalizer/overview) uses those heads without requiring manual graph construction.
Both SDKs provide the same probabilistic guarantees and real-time performance. Julia SDK offers more flexibility for research; Python SDK offers more ergonomics for production BCI applications.
## Factor Graphs for BCI
### What are Factor Graphs?
Factor graphs are a mathematical framework for representing probabilistic models. They consist of:
* **Variable nodes**: Represent unknown quantities (brain states, intentions, etc.)
* **Factor nodes**: Represent probabilistic relationships between variables
* **Edges**: Connect variables to factors that depend on them
### BCI Example: Motor Imagery
Consider a simple motor imagery BCI:
```
EEG Signals → Neural Features → Brain State → Motor Intention → Cursor Movement
| | | | |
[Factor] [Factor] [Factor] [Factor] [Factor]
```
Each factor represents a probabilistic relationship:
* **Signal model**: How EEG relates to neural features
* **Feature model**: How features relate to brain state
* **State model**: How brain state relates to intention
* **Motor model**: How intention relates to movement
### Advantages for BCI
Each component can be developed and tested independently
Easy to add new sensors, features, or output modalities
Uncertainty flows naturally through the entire system
Exploit sparsity and local structure for fast computation
## Message Passing Inference
### How Message Passing Works
Instead of computing the full joint probability distribution (computationally expensive), message passing computes local messages between connected nodes:
1. **Forward pass**: Messages flow from observations to hidden variables
2. **Backward pass**: Messages flow from priors to observations
3. **Marginal computation**: Combine messages to get final beliefs
4. **Reactive updates**: Only recompute affected messages when data arrives
### Real-time Updates
Traditional batch inference recomputes everything when new data arrives. RxInfer.jl's reactive message passing enables incremental updates:
**Traditional batch approach:**
* Recompute entire posterior when new data arrives
* High latency (100ms+)
* Wasteful computation
**RxInfer reactive approach:**
* Only update affected parts of the factor graph
* Low latency (10-20ms)
* Efficient incremental computation
### Reactive Programming
NimbusSDK uses RxInfer.jl reactive programming principles:
* **Event-driven**: Computation triggered by new data
* **Asynchronous**: Non-blocking message updates
* **Efficient**: Only update what changed
* **Robust**: Handle varying data rates gracefully
## BCI-Specific Optimizations
### Temporal Models
BCI signals have strong temporal structure. NimbusSDK exploits this:
**State Space Models**: Model how brain states evolve over time
```
Brain State[t-1] → Brain State[t] → Brain State[t+1]
| | |
EEG[t-1] EEG[t] EEG[t+1]
```
Benefits:
* **Prediction**: Anticipate future brain states
* **Smoothing**: Reduce noise by considering temporal context
* **Missing data**: Interpolate when signals are corrupted
**Multi-scale Dynamics**: Different neural processes operate at different timescales
* Slow Dynamics (seconds): Attention, arousal
* Medium Dynamics (100ms): Motor planning
* Fast Dynamics (10ms): Neural oscillations
NimbusLDA and NimbusQDA (both SDKs), plus the non-Gaussian static models (`NimbusSoftmax` in Python / `NimbusProbit` in Julia), handle temporal structure through proper feature aggregation and trial-level inference.
### Multi-modal Integration
Modern BCIs combine multiple signal types. Factor graphs naturally handle this:
```
EEG → Neural State ← EMG
| | |
P300 Attention Muscle
Speller Level Activity
| | |
└── Intention ──────┘
|
Final Command
```
Each modality provides complementary information that improves overall performance.
### Hierarchical Processing
Brain activity operates at multiple levels. Nimbus models this hierarchy:
```
Global Brain State (Attention, Arousal)
|
Regional Activity (Motor Cortex, Visual Cortex)
|
Local Populations (Individual Electrodes)
|
Raw Signals (EEG, EMG, EOG)
```
Higher levels provide context for lower levels, improving inference quality.
## Implementation Details
### Efficient Message Computation
RxInfer.jl optimizes message passing for BCI workloads:
* **Sparse updates**: Only compute messages for affected nodes
* **Caching**: Reuse previous computations when possible
* **Topological sorting**: Optimal message update order
* **Variational inference**: Closed-form message updates (no sampling)
### Memory Management
Real-time systems require careful memory management:
* **Message caching**: Reuse previous computations
* **Bounded memory**: Fixed memory usage regardless of runtime
* **Efficient GC**: Minimal garbage collection pressure
### Numerical Stability
BCI signals can have extreme values. RxInfer handles this robustly:
* **Log-space computation**: Avoid numerical underflow
* **Adaptive precision**: Use appropriate numerical types
* **Regularization**: Prevent degenerate solutions
## Bayesian Models in NimbusSDK
### Model Structure
NimbusLDA and NimbusQDA (both SDKs), the non-Gaussian static models (`NimbusSoftmax` in Python / `NimbusProbit` in Julia), and NimbusSTS (Python) all use factor graph representations:
**NimbusLDA**: Linear Discriminant Analysis
* Gaussian observations with shared precision matrix
* Fast inference due to shared covariance
* Optimal for well-separated classes
**NimbusQDA**: Quadratic Discriminant Analysis (QDA)
* Class-specific covariance matrices
* More flexible for overlapping distributions
* Handles complex class structures
**Non-Gaussian static models**: Multinomial classifiers with uncertainty quantification
* `NimbusSoftmax` (Python): Bayesian multinomial logistic regression with Polya-Gamma variational inference
* `NimbusProbit` (Julia): Bayesian multinomial probit regression with RxInfer
* Most flexible for complex multinomial tasks
**Bayesian STS (`NimbusSTS`)**: State-space model with temporal dynamics (Python SDK only)
### Inference Process
1. **Observation**: Neural features from preprocessed EEG
2. **Message passing**: Update beliefs using variational inference
3. **Marginalization**: Compute posterior over classes
4. **Prediction**: Select class with highest posterior probability
## Best Practices
### Model Design
Start simple and add complexity gradually. A simple model that works reliably is better than a complex model that fails unpredictably.
1. **Begin with linear models**: Add nonlinearity only when needed
2. **Use domain knowledge**: Incorporate known neural principles
3. **Validate incrementally**: Test each component separately
4. **Monitor performance**: Track inference speed and accuracy
### Scalability
1. **Exploit sparsity**: Most neural connections are sparse
2. **Use hierarchical models**: Process at multiple resolutions
3. **Cache computations**: Reuse expensive calculations
4. **Profile regularly**: Identify and fix bottlenecks
## Getting Started
Ready to build with message passing?
Build your first factor graph BCI
Explore Julia SDK functions and model APIs
See message passing in action
Build custom probabilistic models with RxInfer.jl
***
**Next**: Learn how to configure [real-time inference](/inference-configuration/real-time-setup) for your BCI application.
# Probabilistic AI & Uncertainty
Source: https://docs.nimbusbci.com/core-concepts/probabilistic-ai
Learn how Nimbus uses Bayesian inference, posterior probabilities, confidence scores, and quality checks to make BCI systems more reliable.
Brain-computer interfaces operate in an uncertain environment. Neural signals are noisy, brain states change, and calibration data is often limited. Nimbus uses Bayesian inference to expose that uncertainty instead of hiding it behind a single overconfident prediction.
## Sources Of Uncertainty
* **Signal-to-noise ratio**: Neural signals often have poor SNR, especially in non-invasive recordings
* **Biological artifacts**: Eye blinks, muscle activity, cardiac signals, and movement can contaminate recordings
* **Cognitive state**: Attention, fatigue, learning, and task engagement change signal patterns
* **Limited calibration**: Small training sets increase model uncertainty for new users and sessions
* **Temporal drift**: Brain and electrode conditions change during long sessions
* **Environmental factors**: Movement, attention, fatigue all affect neural recordings
Traditional deterministic BCI systems assume clean, consistent signals. When these assumptions break down, the systems fail catastrophically with no indication of confidence or reliability.
For a structured comparison of classical LDA, SVM, and deep learning against Bayesian inference on NimbusSDK — including benchmarked \~10× faster online updates vs. scikit-learn refit — see [Why Bayesian BCI Inference](/core-concepts/why-bayesian-bci).
## Bayesian Inference For BCI
Both SDKs use Bayesian inference to model uncertainty explicitly with production-ready heads: Bayesian LDA, Bayesian QDA, Bayesian Softmax, and Bayesian STS (Python SDK). In Python apps, prefer [`Personalizer` → `BrainState`](/personalizer/overview); use standalone heads for research diagnostics.
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64)
adapter = Personalizer(
encoder=enc,
head="lda",
classes=["left", "right"],
preset="research",
)
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test)
for state in states:
print(state.intent, state.confidence, state.uncertainty)
if state.need_more_data or state.rejected:
print("Gate: ask for more data / reject trial")
```
```python theme={null}
from nimbus_bci import NimbusLDA, compute_entropy
# Train head
clf = NimbusLDA()
clf.fit(X_train, y_train)
# Run inference - returns full posterior distribution
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test) # Full distribution
# Access predictions with confidence
for i, pred in enumerate(predictions):
confidence = probabilities[i].max()
posterior = probabilities[i] # Full distribution over classes
entropy = compute_entropy(probabilities[i:i+1])[0]
print(f"Prediction: {pred}")
print(f"Confidence: {confidence:.3f}")
print(f"Posterior distribution: {posterior}")
print(f"Entropy: {entropy:.2f} bits")
# Make confidence-based decisions
if confidence < 0.7:
print("Low confidence - consider rejecting this trial")
```
```julia theme={null}
using NimbusSDK
# Authenticate and load model
NimbusSDK.install_core("your-api-key")
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
# Run inference - returns full posterior distribution
results = predict_batch(model, bci_data)
# Access predictions with confidence
for (i, pred) in enumerate(results.predictions)
confidence = results.confidences[i]
posterior = results.posteriors[:, i] # Full distribution over classes
println("Prediction: $pred")
println("Confidence: $(round(confidence, digits=3))")
println("Posterior distribution: $(round.(posterior, digits=3))")
# Make confidence-based decisions
if confidence < 0.7
println("Low confidence - consider rejecting this trial")
end
end
```
**Key advantages:**
* Returns full posterior probability distribution, not just a single prediction
* Confidence scores for each prediction
* Can identify uncertain trials and request clarification
* Gracefully handles poor signal quality
## What You Can Do With Uncertainty
Know when the system is confident vs uncertain about predictions
Adjust behavior based on signal quality and confidence levels
Graceful degradation when conditions change or signal quality drops
Understand why the system made specific predictions
## Types Of Uncertainty
**Aleatoric uncertainty** is irreducible data uncertainty caused by noisy recordings, biological artifacts, or ambiguous signals. It is handled with quality checks, rejection thresholds, and better preprocessing.
**Epistemic uncertainty** is reducible model uncertainty caused by limited calibration data or unfamiliar sessions. It is reduced by collecting more labels, adapting online, or using active learning.
## Confidence Measures
Nimbus workflows commonly use:
* **Maximum posterior probability**: confidence of the most likely class.
* **Posterior entropy**: how spread out the class distribution is.
* **Trial quality reports**: signal and preprocessing diagnostics.
* **Calibration checks**: whether more labels are likely to improve the model.
Example confidence-based decision:
```julia theme={null}
using NimbusSDK
confidence = results.confidences[end]
posterior = results.posteriors[:, end]
if confidence > 0.9
execute_command(results.predictions[end])
elseif confidence > 0.7
show_confirmation_dialog(results.predictions[end], posterior)
else
request_better_signal()
end
```
## Thresholds By Application
* **Safety-critical control**: require very high confidence and confirmation.
* **Communication aids**: balance speed with rejection and correction flows.
* **Gaming and feedback**: lower thresholds can improve responsiveness.
* **Research**: log posterior distributions and confidence trends for analysis.
For medical and assistive applications, uncertainty logging is part of system reliability: store confidence scores, quality flags, model versions, and rejected trials alongside predictions.
## Model Families
Nimbus exposes probabilistic model families through SDK-native APIs:
* `NimbusLDA`: fast Bayesian pooled Gaussian classifier.
* `NimbusQDA`: class-specific covariance model for more complex distributions.
* `NimbusSoftmax`: Python Bayesian softmax model for non-Gaussian boundaries.
* `NimbusProbit`: Julia Bayesian multinomial probit model.
* `NimbusSTS`: Python state-space model for non-stationary data.
## How It Is Implemented
The Julia SDK uses **RxInfer.jl** for efficient Bayesian inference through reactive message passing:
1. **Factor graphs** represent probabilistic relationships between neural features and brain states
2. **Variational message passing** propagates information efficiently through the graph
3. **Reactive updates** handle streaming data in real-time with minimal latency
4. **Automatic inference** - RxInfer generates efficient inference algorithms automatically
The mathematical details are handled by SDK APIs. Use model pages for algorithm-specific details and API references for exact function signatures.
## Next Read
Classical vs. Bayesian BCI methods and online-update benchmarks.
Compare available probabilistic models.
Configure acquisition and low-latency inference.
Build confidence-aware safeguards.
See uncertainty-aware BCI recipes.
# Why Bayesian BCI Inference
Source: https://docs.nimbusbci.com/core-concepts/why-bayesian-bci
Compare classical BCI classifiers (LDA, SVM, deep learning) with Bayesian inference on NimbusSDK, including real-time performance, adaptability, robustness, and interpretability.
BCI systems are not just classifiers. They are decision systems that must work under noise, adapt during sessions, and expose confidence before closing the control loop. Classical machine learning methods each solve part of that problem — but they trade away uncertainty, online updates, or transparency in ways that matter in production BCI.
This page compares common BCI processing approaches and explains where Bayesian inference on NimbusSDK fits. On the Python product surface, that wedge is [`Personalizer`](/personalizer/overview): keep the neural trunk frozen, adapt a Bayesian head online, and ship apps on `BrainState`. For a deeper comparison with scikit-learn and pyRiemann — including benchmark methodology — see [Why Nimbus SDK? Beyond scikit-learn and pyRiemann for BCI](https://www.nimbusbci.com/blog/why-nimbus-sdk-bci-scikit-learn-pyriemann).
## How BCI Processing Methods Compare
The table below compares **classical / frequentist** approaches with **Bayesian inference on NimbusSDK**. Nimbus ships Bayesian versions of LDA and QDA (`NimbusLDA`, `NimbusQDA`); the contrast is about inference philosophy and BCI workflow support, not the Gaussian model family itself.
| Criterion |
LDA |
SVM |
DL |
Nimbus |
| Real-time performance |
High — very fast on simple linear problems |
Medium — slower with complex kernels |
Low — often GPU-bound; many pipelines stay offline |
High (reactive) — streaming APIs and incremental updates; 10–25 ms per trial |
| Adaptability |
Low — static model; full retrain for new data |
Medium — adaptation is possible but awkward |
Medium — needs large new datasets and retraining |
High (by design) — partial\_fit() on every head; NimbusSTS for drift |
| Robustness (to noise) |
Low — sensitive to outliers and noisy covariances |
Medium — more robust than LDA, still point-estimate |
Medium — can learn noise patterns but needs vast data |
High (explicit modeling) — Student's t posterior predictive and uncertainty-aware rejection |
| Interpretability |
High — linear boundaries and clear feature geometry |
Medium–low — opaque with non-linear kernels |
Very low — black-box decisions |
Very high — full posteriors, factor graphs, entropy, and calibration diagnostics |
| Online update |
Low — refit each round (\~835 µs sklearn LDA) |
Low — no native partial\_fit |
Low — retrain or fine-tune; not microsecond-scale |
\~10× faster — conjugate partial\_fit() (\~87 µs vs. sklearn refit) |
DL = deep learning.
Classical LDA and `NimbusLDA` share the same Gaussian model family. The difference is inference: plug-in MLE probabilities vs. multivariate Student's t posterior predictives, conjugate online updates, and BCI-native reports. See [Probabilistic AI & Uncertainty](/core-concepts/probabilistic-ai) for how those outputs drive rejection and control.
### When classical methods are enough
Classical LDA, SVM, or deep learning can be the right choice when:
* You need a quick offline baseline on a fixed, clean dataset.
* You will not gate actions on confidence, entropy, or rejection thresholds.
* Calibration data is abundant and sessions are short and stationary.
* You are comparing accuracy tables in a lab setting, not closing a real-time BCI loop.
### When Bayesian inference on NimbusSDK helps
Nimbus is designed for deployments where the question is not only *what class?* but also *how certain, should this trial be accepted, and how does that affect ITR?*
* **Streaming control** — `StreamingSession` and chunk-validated metadata for live EEG loops.
* **Session drift** — `partial_fit()` without full retrain; `NimbusSTS` for latent-state adaptation.
* **Label-efficient calibration** — active learning with honest BALD on Bayesian posteriors.
* **Safety and auditability** — confidence gates, trial quality checks, and structured `BatchResult` diagnostics.
## \~10× Faster Online Updates
The **Online update** row above is benchmarked on Nimbus's checked-in online-update scenario (BNCI 2014-004, with Zhou 2016 as a supporting replication):
| Strategy | Latency | Notes |
| ------------------------ | ----------- | ---------------------------- |
| **`nimbus_partial_fit`** | **\~87 µs** | Conjugate online update |
| `nimbus_refit` | \~145 µs | Full Nimbus refit |
| `sklearn_refit` | \~835 µs | sklearn LDA refit each round |
**Result:** `sklearn_refit` / `nimbus_partial_fit` ≈ **9.7× on average** across subjects (\~7.5×–10.2× per subject on the published run) — effectively **\~10× faster** online adaptation. Medians: \~83 µs vs. \~827 µs.
Reproduce these numbers from the checked-in benchmark CSV via `notebooks/s3_update_latency_head_vs_sklearn.ipynb`. Full context, plots, and the sklearn / pyRiemann workflow comparison are in the [blog post](https://www.nimbusbci.com/blog/why-nimbus-sdk-bci-scikit-learn-pyriemann).
**0.6 product cost claim (separate experiments):** head-only `partial_fit` is \~**8–10×** cheaper than sklearn batch refit; Personalizer vs EEGNet retrain wall-time ≈**131×** (n=9) and **22–34×** at n=20. Cite wall-time ratios, not accuracy wins — see [Evidence](/personalizer/evidence) and the package `CHANGELOG.md`.
This latency advantage compounds in cued-calibration loops: faster updates mean shorter sessions, more trials accepted per minute, and smoother real-time feedback without blocking the EEG stream.
## Composing With Other Tools
Nimbus is a **decision layer**, not a replacement for every ML or geometry library.
| Tool | Role |
| ---------------- | ---------------------------------------------------------- |
| **scikit-learn** | Offline baselines and frequentist comparisons |
| **pyRiemann** | Covariance geometry and tangent-space features |
| **NimbusSDK** | Bayesian heads, streaming, posteriors, and BCI diagnostics |
Typical composition:
```text theme={null}
EEG → CSP / bandpower / pyRiemann tangent vectors → NimbusLDA or NimbusQDA → confidence-aware action
```
Use pyRiemann (or CSP) for **representation**; use Nimbus for **inference and BCI reports** when uncertainty and online updates matter. In Python, `nimbus_bci.riemann.make_riemann_nimbus_pipeline(...)` packages the pyRiemann covariance/tangent workflow with a Nimbus head while keeping it a composition helper, not a separate Riemannian Bayesian model.
## Picking a Nimbus Model
Once you choose Bayesian inference, select the head that matches your signal geometry and session length:
| Model | Best for |
| --------------- | ---------------------------------------------------- |
| `NimbusLDA` | Fast baseline, well-separated classes, motor imagery |
| `NimbusQDA` | Overlapping class distributions, P300-style ERPs |
| `NimbusSoftmax` | Non-Gaussian multiclass boundaries (Python) |
| `NimbusProbit` | Julia-native multinomial probit workflows |
| `NimbusSTS` | Non-stationary sessions, latent-state drift (Python) |
See [Model Specification](/model-specification) for the full side-by-side comparison.
## Next Read
Posteriors, confidence scores, and rejection thresholds.
Why reactive inference beats batch recompute in streaming BCI.
BALD ranking and label-efficient calibration loops.
Benchmark Nimbus against sklearn LDA, SVM, and random forest in code.
# Nimbus Development Workflow
Source: https://docs.nimbusbci.com/development
Build production BCI applications with Nimbus using practical project structure, testing, debugging, and deployment guidance.
This guide covers project organization and production workflow. It does not repeat SDK installation or full examples; use the linked pages for those details.
* Python product path: [Personalizer & Middleware](/personalizer/overview)
* Python setup: [Python SDK Installation](/python-sdk/installation)
* Julia setup: [Julia SDK Quickstart](/julia-sdk/quickstart)
* Recipes: [Basic Examples](/examples/basic-examples) and [Advanced Applications](/examples/advanced-applications)
## Recommended Project Structure
Keep acquisition, preprocessing / encoding, personalization, and application logic separate. That makes it easier to test each stage and swap trunks, heads, or SDKs later.
```text theme={null}
my-bci-project/
src/
acquisition/ # LSL, BrainFlow, hardware adapters
preprocessing/ # filtering, artifact handling, feature extraction
encoders/ # frozen trunk wrappers (optional product path)
models/ # Personalizer profiles / head training, normalization
inference/ # batch and streaming inference → BrainState
app/ # UI, control, feedback, safety policy
data/
raw/
processed/
calibration/
tests/
notebooks/
README.md
```
## Development Workflow
1. **Define the BCI paradigm**: motor imagery, P300, SSVEP, or another task.
2. **Lock the input contract**: frozen encoder (`model_id`, `embedding_dim`) **or** classical feature type, sampling rate, channels, window, normalization.
3. **Fit a baseline**: start with `Personalizer(head="lda")` on embeddings, or `NimbusLDA` on classical features; compare heads only if the baseline fails.
4. **Evaluate offline**: use held-out sessions / CV before streaming; inspect `BrainState` gating.
5. **Add streaming**: process embedding or feature chunks and aggregate trial decisions.
6. **Add safety policy**: reject low confidence / high uncertainty (`need_more_data`) and log decisions.
7. **Deploy with monitoring**: track latency, confidence, rejection rate, and drift.
## Data Contracts
Document these invariants in your project:
| Field | Why It Matters |
| -------------------- | --------------------------------------------------------------------------------------------- |
| Sampling rate | Required for reproducible windows and chunk sizes. |
| Feature type | Models trained on CSP should not receive ERP features. |
| Feature count | Must match the model and metadata. |
| Label encoding | Python workflows can use sklearn-style labels; Julia examples typically use 1-indexed labels. |
| Normalization params | Must be estimated on training data and reused for deployment. |
## Model Lifecycle
Treat a deployed model as more than weights. Save:
* model object or model identifier
* SDK version
* feature extraction settings
* normalization parameters
* class labels and label mapping
* training session metadata
* validation metrics
Example metadata (Personalizer profile):
```json theme={null}
{
"surface": "Personalizer",
"head": "lda",
"model_id": "partner-encoder",
"embedding_dim": 64,
"sdk": "nimbus-bci",
"classes": ["left", "right"],
"preset": "research",
"trained_on": "2026-04-25"
}
```
Classical-head metadata can still use `"model": "NimbusLDA"` + `"feature_type": "csp"`. Persist `Personalizer.save(...)` profiles separately from the frozen trunk weights.
## Testing Strategy
Prioritize small tests around contracts and failure modes:
* Encoder / preprocessing output has expected shape and finite values.
* Labels match the number of trials.
* Normalization params are reused, not recomputed on test data.
* Personalizer rejects incompatible `embedding_dim` / `model_id` on load.
* Streaming chunks match `chunk_size`.
* Low-confidence / high-uncertainty `BrainState` triggers the expected safety path.
For numerical tests, avoid hardcoding exact posteriors unless the model is fully deterministic. Prefer shape, range, monotonicity, and threshold checks.
## Debugging Checklist
When accuracy or confidence is poor:
1. Confirm data shape and label encoding.
2. Check for NaN, Inf, constant features, logits passed as embeddings, or raw EEG as features.
3. Verify the encode contract or preprocessing band / time window match the paradigm.
4. Reuse training normalization parameters on test data.
5. Compare against `Personalizer.for_deployment` / `NimbusLDA` as a baseline.
6. Inspect `BrainState` confidence, uncertainty, and `need_more_data`, not only accuracy.
7. Run preprocessing diagnostics before tuning model hyperparameters.
## Performance Guidance
* Warm up streaming inference before a live session.
* Preallocate buffers in real-time loops.
* Keep filtering and feature extraction outside hot model code when possible.
* Prefer batch inference for offline evaluation.
* Log per-stage latency: acquisition, preprocessing, inference, and application action.
## Production Guardrails
Production BCI systems should include:
* confidence thresholds by action risk
* trial rejection and retry flows
* session-level health monitoring
* model/version audit logs
* fallback behavior when the stream drops
* clear separation between prediction and command execution
See [Error Handling](/inference-configuration/error-handling) for a focused checklist.
## Next Read
Export/import handoff from MNE, EEGLAB, OpenViBE, or MATLAB.
Cross-session scaling strategy.
Chunking, aggregation, and quality gates.
Choose the right model family.
# External Preprocessing Integration
Source: https://docs.nimbusbci.com/development/preprocessing-integration
Export preprocessed EEG features from MNE-Python, EEGLAB, OpenViBE, or MATLAB and load them into Nimbus SDK workflows.
Nimbus expects embeddings or preprocessed features, not raw EEG. This page covers the handoff between external EEG tools and Nimbus SDKs: feature shape, label conventions, export formats, and validation.
For preprocessing theory and recommended feature pipelines, start with [Preprocessing Requirements](/inference-configuration/preprocessing-requirements). For Python-native MNE workflows, use [Python SDK MNE Integration](/python-sdk/mne-integration). Encoder / Personalizer handoff: [Personalizer & Middleware](/personalizer/overview).
Exported embedding tables use the same `(n_trials, n_features)` shape as classical features — feed them to `Personalizer(encoder=None)` or attach a live encoder via `wrap`.
## What This Page Owns
Use this page when you preprocess outside the SDK language that will run inference:
* MNE-Python features exported for Julia
* EEGLAB or MATLAB features exported for Python or Julia
* OpenViBE feature streams saved to CSV or MAT files
* Cross-tool shape and label validation
## Target Data Shapes
| Target | Expected Shape | Notes |
| --------------------------- | ----------------------------------- | ------------------------------------------------------------- |
| Python Personalizer / heads | `(n_trials, n_features)` | Embeddings or classical features; sklearn-style tabular rows. |
| Python `BCIData` utilities | `(n_features, n_samples, n_trials)` | Used by lower-level batch/streaming utilities. |
| Julia `BCIData` | `(n_features, n_samples, n_trials)` | Labels are usually 1-indexed integers. |
For streaming, chunks should be shaped `(n_features, chunk_size)`.
## MNE-Python To Julia
```python theme={null}
import numpy as np
from scipy.io import savemat
# Example MNE/CSP output: (n_trials, n_components, n_times)
X_csp = csp.fit_transform(epochs.get_data(), labels)
# Julia expects: (n_features, n_samples, n_trials)
features_julia = np.transpose(X_csp, (1, 2, 0))
savemat("features_for_julia.mat", {
"features": features_julia.astype("float64"),
"labels": labels.astype("int64"),
"sampling_rate": float(epochs.info["sfreq"]),
})
```
```julia theme={null}
using MAT
using NimbusSDK
data = matread("features_for_julia.mat")
features = Float64.(data["features"])
labels = Int.(vec(data["labels"]))
metadata = BCIMetadata(
sampling_rate = Float64(data["sampling_rate"]),
paradigm = :motor_imagery,
feature_type = :csp,
n_features = size(features, 1),
n_classes = length(unique(labels)),
chunk_size = nothing
)
bci_data = BCIData(features, metadata, labels)
```
## EEGLAB Or MATLAB Export
MATLAB arrays often already use `(channels/features, samples, trials)`, which matches Julia `BCIData`.
```matlab theme={null}
% features: n_features x n_samples x n_trials
% labels: 1 x n_trials, preferably 1-indexed
save("features_for_nimbus.mat", "features", "labels", "sampling_rate")
```
```julia theme={null}
using MAT
data = matread("features_for_nimbus.mat")
features = Float64.(data["features"])
labels = Int.(vec(data["labels"]))
```
If labels come from a zero-indexed pipeline, convert them before training or evaluation:
```julia theme={null}
if minimum(labels) == 0
labels .+= 1
end
```
## OpenViBE CSV Export
OpenViBE often exports time-series rows. Segment the stream into trials before passing data to Nimbus.
```python theme={null}
import numpy as np
import pandas as pd
from scipy.io import savemat
df = pd.read_csv("openvibe_features.csv")
# Example columns: timestamp, f1, f2, ..., label, trial_id
feature_cols = [c for c in df.columns if c.startswith("f")]
trial_ids = df["trial_id"].unique()
trials = []
labels = []
for trial_id in trial_ids:
trial = df[df["trial_id"] == trial_id]
trials.append(trial[feature_cols].to_numpy().T)
labels.append(int(trial["label"].iloc[-1]))
features = np.stack(trials, axis=2) # (n_features, n_samples, n_trials)
savemat("openvibe_for_nimbus.mat", {
"features": features.astype("float64"),
"labels": np.asarray(labels, dtype="int64"),
})
```
## Handoff Validation
Run these checks before loading exported data into an SDK:
```python theme={null}
import numpy as np
def validate_export(features, labels):
assert features.ndim == 3, "Expected (features, samples, trials)"
assert features.shape[2] == len(labels), "Labels must match trials"
assert np.isfinite(features).all(), "Features contain NaN or Inf"
assert len(np.unique(labels)) >= 2, "Need at least two classes"
```
```julia theme={null}
using NimbusSDK
report = diagnose_preprocessing(bci_data)
if !isempty(report.errors)
error("Preprocessing export failed validation: $(report.errors)")
end
```
## Normalization Handoff
Estimate normalization parameters on training data only, then save them with the model or exported feature bundle.
```julia theme={null}
norm_params = estimate_normalization_params(train_features; method=:zscore)
train_norm = apply_normalization(train_features, norm_params)
test_norm = apply_normalization(test_features, norm_params)
```
See [Feature Normalization](/inference-configuration/feature-normalization) for the full workflow.
## Next Read
Feature extraction requirements and paradigm guidance.
Native Python SDK workflows with MNE.
Load exported features into Julia workflows.
Keep feature scales consistent across sessions.
# Advanced Applications
Source: https://docs.nimbusbci.com/examples/advanced-applications
Advanced Nimbus BCI patterns for Personalizer personalization, cross-subject training, hybrid systems, adaptive learning, multi-session experiments, and robust deployment.
Advanced BCI patterns without duplicating full SDK walkthroughs. Use this as an implementation checklist, then follow the linked guides for exact APIs.
Frozen-encoder personalization: [Personalizer](/personalizer/overview). Python streaming / sklearn: [Python Streaming Inference](/python-sdk/streaming-inference), [sklearn Integration](/python-sdk/sklearn-integration). Julia streaming: [Julia Streaming Inference](/julia-sdk/streaming-inference).
Under deployment stress on a frozen trunk, prefer `Personalizer` + small labeled calibration over full fine-tuning. Classical cross-subject / multi-session recipes still apply to feature pipelines and standalone heads.
## Cross-subject training
Combine calibration data from multiple users, then personalize to a new user with a small labeled set. When a frozen trunk already transfers, prefer [`Personalizer`](/personalizer/overview) + per-user calibration over full fine-tuning.
Core workflow:
1. Extract the same feature type (or freeze the same encoder) for every subject.
2. Normalize using training subjects only.
3. Train a conservative baseline (`Personalizer.for_features(..., head="lda")` or `NimbusLDA`).
4. Evaluate with subject-wise splits, not random trial splits.
5. Personalize with a small calibration set for the target subject.
```python theme={null}
from nimbus_bci import Personalizer, wrap, estimate_normalization_params, apply_normalization
from sklearn.model_selection import LeaveOneGroupOut
# Frozen trunk path — same encoder for all subjects
enc = wrap(model.encode, model_id="shared-trunk", embedding_dim=64)
X, y, groups = load_multi_subject_features() # or embeddings from enc
logo = LeaveOneGroupOut()
for train_idx, test_idx in logo.split(X, y, groups):
norm = estimate_normalization_params(X[train_idx], method="zscore")
X_train = apply_normalization(X[train_idx], norm)
X_test = apply_normalization(X[test_idx], norm)
adapter = Personalizer(
encoder=None, # features/embeddings already prepared
head="lda",
classes=["left", "right"],
preset="research",
)
adapter.fit(X_train, y[train_idx])
states = adapter.predict(X_test)
# score from labels when available; gate on states[*].confidence in apps
```
```julia theme={null}
using NimbusSDK
features, labels, subject_ids = load_multi_subject_features()
for subject in unique(subject_ids)
train_mask = subject_ids .!= subject
test_mask = subject_ids .== subject
norm = estimate_normalization_params(features[:, :, train_mask]; method=:zscore)
train_features = apply_normalization(features[:, :, train_mask], norm)
test_features = apply_normalization(features[:, :, test_mask], norm)
train_data = BCIData(train_features, metadata, labels[train_mask])
model = train_model(NimbusLDA, train_data; iterations=50)
results = predict_batch(model, BCIData(test_features, metadata, labels[test_mask]))
end
```
## Hybrid BCI
Hybrid BCIs combine evidence from multiple paradigms, such as motor imagery plus P300.
Recommended pattern:
* Keep one Personalizer / head per paradigm (or one frozen trunk + head).
* Normalize each feature family separately.
* Combine posterior probabilities or confidence-weighted decisions.
* Log disagreement between paradigms for later review.
```python theme={null}
mi_states = mi_adapter.predict(mi_features)
p300_states = p300_adapter.predict(p300_features)
# Or combine head posteriors for research fusion
mi_proba = mi_adapter.head.predict_proba(mi_features)
p300_proba = p300_adapter.head.predict_proba(p300_features)
combined = 0.6 * mi_proba + 0.4 * p300_proba
```
Use conservative thresholds for high-stakes actions and require confirmation when paradigms disagree. App gates: [BrainState](/personalizer/brain-state).
## Continuous control
Continuous control maps repeated predictions into a smoothed command stream.
```python theme={null}
alpha = 0.3
smoothed = None
for state in brain_state_stream: # or posterior_stream from a head
command = posterior_to_velocity(state.posterior)
smoothed = command if smoothed is None else alpha * command + (1 - alpha) * smoothed
if not state.rejected:
send_control(smoothed)
```
Keep smoothing outside the classifier. The model reports uncertainty; the control layer decides how aggressively to act.
## Adaptive learning
Adaptation is useful when signal distributions drift during long sessions or across days.
| Signal | Pattern |
| ------------------------ | --------------------------------------------------------------------- |
| Which trials to label | [Active Learning](/python-sdk/active-learning) / `CalibrationSession` |
| Whether to stream-update | `Personalizer.recommend_adapt(..., tau=...)` then `partial_fit` |
| Trial accept / reject | `BrainState.rejected` / `need_more_data` |
| Non-stationary state | `NimbusSTS` / `StreamingSessionSTS` |
```python theme={null}
dec = adapter.recommend_adapt(X_stream, tau=your_tau)
if dec.should_adapt:
adapter.partial_fit(X_lab, y_lab)
states = adapter.predict(X_eval)
# optional: state.extras.update(dec.to_extras())
```
See [Personalizer overview](/personalizer/overview) for adapt vs trial gates, and [Active Learning](/python-sdk/active-learning) for label selection.
## Multi-session experiments
For experiments that span days or weeks:
* Use a stable preprocessing / encoder pipeline and save its parameters (`model_id`, `embedding_dim`).
* Save profiles / normalization with each subject (`Personalizer.save`, `export_profile()`, head artifacts).
* Track hardware, montage, impedance, session time, and participant state.
* Evaluate same-session and cross-session performance separately.
* Compare adaptation against a no-adaptation baseline.
Suggested metadata to log:
```json theme={null}
{
"subject_id": "S001",
"session_id": "2026-04-25-AM",
"paradigm": "motor_imagery",
"model_id": "partner-encoder",
"head": "lda",
"feature_type": "embedding",
"normalization": "zscore",
"sampling_rate": 250
}
```
## Robust deployment
Production systems should separate personalization, control, and safety policy:
1. Personalizer returns `BrainState` (intent, posterior, confidence, gates).
2. Quality gate accepts, rejects, or asks for more data (`need_more_data`).
3. Control layer maps accepted intents to actions.
4. Monitoring logs confidence, latency, rejection rate, and drift.
```python theme={null}
for state in adapter.predict(X_online):
if state.rejected or state.need_more_data:
reject_or_ask_more(state)
elif state.confidence >= 0.9:
execute(state.intent)
elif state.confidence >= 0.7:
request_confirmation(state.intent)
else:
reject_or_ask_more(state)
```
For detailed safeguards, see [Error Handling](/inference-configuration/error-handling) and [BrainState](/personalizer/brain-state).
## Next read
Encoder contract, adapt helper, BrainState
Compact starter recipes
Label-efficient calibration
Chunking and aggregation decisions
# Basic Examples
Source: https://docs.nimbusbci.com/examples/basic-examples
Compact Nimbus BCI recipes for Personalizer, motor imagery, P300, streaming, calibration, diagnostics, and model comparison.
Use these recipes after installation and preprocessing are complete. Python examples lead with `Personalizer` → `BrainState`; classical heads remain available for research CV and Julia.
* Product path: [Personalizer](/personalizer/overview)
* Paradigms: [Paradigms & profiles](/personalizer/paradigms)
* Python quickstart: [Python SDK Quickstart](/python-sdk/quickstart)
* Julia quickstart: [Julia SDK Quickstart](/julia-sdk/quickstart)
* Feature preparation: [Preprocessing Requirements](/inference-configuration/preprocessing-requirements)
## Personalizer (frozen encoder)
Wrap any `encode(X) → Z`, personalize online, consume `BrainState`.
```python theme={null}
from nimbus_bci import Personalizer, wrap
# Toy identity encoder — replace with partner / foundation encode(X) → Z
enc = wrap(lambda X: X, model_id="demo-encoder", embedding_dim=16)
adapter = Personalizer.for_deployment(enc, ["left", "right"])
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test)
print(states[0].intent, states[0].confidence, states[0].need_more_data)
```
Already have CSP / bandpower features? Use `Personalizer.for_features(["left", "right"])` — same app surface. Full contract: [Personalizer](/personalizer/overview).
## Motor imagery
Motor imagery workflows usually use CSP or bandpower features from 8–30 Hz EEG — or embeddings from a frozen trunk.
```python theme={null}
from nimbus_bci import Personalizer
from nimbus_bci.compat import extract_csp_features
from sklearn.model_selection import cross_val_score
# epochs: MNE Epochs object, already filtered and artifact-cleaned
X, csp = extract_csp_features(epochs, n_components=8)
y = epochs.events[:, 2]
adapter = Personalizer.for_features(["left", "right"], head="lda")
adapter.fit(X, y)
states = adapter.predict(X[:5])
# Research CV still uses the head
scores = cross_val_score(adapter.head, X, y, cv=5)
print(states[0].intent, f"CV accuracy: {scores.mean():.2%}")
```
```julia theme={null}
using NimbusSDK
using Statistics
NimbusSDK.install_core("your-api-key")
features = load_csp_features() # (n_features, n_samples, n_trials)
labels = load_labels() # 1-indexed labels
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = size(features, 1),
n_classes = length(unique(labels)),
chunk_size = nothing
)
data = BCIData(features, metadata, labels)
model = train_model(NimbusLDA, data; iterations=50)
results = predict_batch(model, data)
accuracy = mean(results.predictions .== labels)
println("Accuracy: $(round(accuracy * 100, digits=1))%")
```
Default head is LDA (`Personalizer(head="lda")`). Try `head="qda"` when class covariances differ substantially.
## P300 detection
Preferred product path (`pip install nimbus-bci[riemann]`): xDAWN → tangent via `Personalizer.for_p300`. Classical ERP amplitude features still work with `for_features`.
```python theme={null}
from nimbus_bci import Personalizer
# epochs: target/non-target MNE epochs (raw-ish ERP windows)
p = Personalizer.for_p300(
["NonTarget", "Target"],
tracking_alpha=0.05, # opt-in geodesic tracking
)
p.fit(epochs_cal, y_cal) # see fit_p300_session for day-2 bundles
states = p.predict(epochs_test)
print(states[0].intent, states[0].confidence)
```
```python theme={null}
from nimbus_bci import Personalizer
# epochs: target/non-target MNE epochs
sfreq = epochs.info["sfreq"]
start = int(0.3 * sfreq)
stop = int(0.5 * sfreq)
X = epochs.get_data()[:, :, start:stop].mean(axis=2)
y = epochs.events[:, 2]
adapter = Personalizer.for_features(["nontarget", "target"], head="qda")
adapter.fit(X, y)
states = adapter.predict(X[:10])
print(states[0].intent, states[0].confidence)
```
```julia theme={null}
using NimbusSDK
features = load_erp_features() # (n_channels, n_samples, n_trials)
labels = load_labels()
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :p300,
feature_type = :erp_amplitude,
n_features = size(features, 1),
n_classes = length(unique(labels)),
chunk_size = nothing
)
data = BCIData(features, metadata, labels)
model = train_model(NimbusQDA, data; iterations=50)
results = predict_batch(model, data)
```
## Streaming
Streaming uses short feature chunks and aggregates chunk predictions into a trial decision. Bridge to `BrainState` for apps.
```python theme={null}
from nimbus_bci import (
NimbusLDA,
StreamingSession,
brain_state_from_streaming_result,
)
from nimbus_bci.data import BCIMetadata
clf = NimbusLDA().fit(X_train, y_train)
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=X_train.shape[1],
n_classes=len(set(y_train)),
chunk_size=250,
)
session = StreamingSession(clf.model_, metadata)
for chunk in feature_stream:
session.process_chunk(chunk)
final = session.finalize_trial(method="weighted_vote")
state = brain_state_from_streaming_result(final, classes=["left", "right"])
print(state.intent, state.confidence, state.need_more_data)
session.reset()
```
```julia theme={null}
session = init_streaming(model, metadata)
for chunk in feature_stream
result = process_chunk(session, chunk)
end
final = finalize_trial(session; method=:weighted_vote)
```
For frozen-encoder product apps, prefer [Personalizer](/personalizer/overview) (batch `predict` → `BrainState`) over a raw streaming session. API detail: [Python Streaming Inference](/python-sdk/streaming-inference), [Streaming inference configuration](/inference-configuration/streaming-inference).
## Calibration and normalization
Estimate normalization parameters from calibration or training data only, then reuse them for test and deployment data.
```python theme={null}
from nimbus_bci import estimate_normalization_params, apply_normalization
norm = estimate_normalization_params(X_train, method="zscore")
X_train_norm = apply_normalization(X_train, norm)
X_test_norm = apply_normalization(X_test, norm)
```
```julia theme={null}
norm = estimate_normalization_params(train_features; method=:zscore)
train_norm = apply_normalization(train_features, norm)
test_norm = apply_normalization(test_features, norm)
```
See [Feature Normalization](/inference-configuration/feature-normalization) for cross-session details.
## Diagnostics
Run diagnostics when confidence is unexpectedly low or accuracy drops across sessions.
```julia theme={null}
report = diagnose_preprocessing(bci_data)
if !isempty(report.errors)
error("Preprocessing errors: $(report.errors)")
end
println("Quality score: $(round(report.quality_score * 100, digits=1))%")
```
For Python trial-level quality checks, see the [Python SDK API Reference](/python-sdk/api-reference#metrics) and [BrainState](/personalizer/brain-state) gates.
## Choosing a starting point
| Path | Use first when | Notes |
| --------------------------------------- | ---------------------------------------- | --------------------------------------- |
| `Personalizer.for_deployment` + `wrap` | Frozen / foundation encoder | Product wedge → `BrainState` |
| `Personalizer.for_features` | Classical CSP / bandpower / ERP features | Same app surface; pick `head=` |
| `Personalizer.for_riemann` / `for_p300` | Weak-Z / P300 geometry (`[riemann]`) | Geodesic tracking + session bundles |
| Standalone `NimbusLDA` / … | Research CV, sklearn pipelines | Heads power Personalizer under the hood |
| Julia `NimbusLDA` / `NimbusQDA` | Julia SDK workflows | No Personalizer yet |
| Head | Typical use |
| ------------ | ----------------------------------------------- |
| `lda` | Fast motor-imagery baseline |
| `qda` | Overlapping distributions (e.g. P300) |
| `softmax` | Nonlinear multinomial (Python extra) |
| STS / Probit | Long-session drift (Python) / Julia multinomial |
Full comparison: [Model Specification](/model-specification).
## Next read
Encoder contract and BrainState
MI / P300 session bundles and day-2 profiles
First Python workflow from install to inference
Higher-level deployment patterns
# Industry Use Cases
Source: https://docs.nimbusbci.com/examples/industry-use-cases
Real-world BCI use-case patterns across healthcare, assistive technology, research, and productivity using Nimbus Personalizer and Bayesian heads.
Maps common BCI product scenarios to Nimbus Personalizer and head choices. Pattern-focused — use [Basic Examples](/examples/basic-examples) and [Advanced Applications](/examples/advanced-applications) for implementation recipes.
Shipping an app on a frozen partner / foundation encoder? Lead with [Personalizer → BrainState](/personalizer/overview). Heads (`NimbusLDA`, …) power Personalizer (`head="lda"|...`) and also work standalone on classical features.
## Healthcare and rehabilitation
**Scenario:** motor imagery rehabilitation after stroke or injury.
Recommended pattern:
* Prefer Personalizer when a frozen trunk is available; otherwise `Personalizer(encoder=None)` on CSP / bandpower features.
* Start with `head="lda"` for fast baseline feedback.
* Use `BrainState.need_more_data` / `rejected` to decide whether feedback should be shown.
* Track session-level accuracy, confidence, fatigue indicators, and rejection rate.
* Keep a clinician-visible audit trail for model version, calibration data, and session metrics.
Key docs:
* [Personalizer](/personalizer/overview)
* [BrainState](/personalizer/brain-state)
* [Real-Time BCI Setup](/inference-configuration/real-time-setup)
* [Error Handling](/inference-configuration/error-handling)
## Assistive communication
**Scenario:** P300 or event-related communication interfaces.
Recommended pattern:
* Use ERP amplitude features in a consistent post-stimulus window (or a frozen encoder that emits trial embeddings).
* Prefer `Personalizer.for_p300(...)` (`[riemann]`) or `Personalizer.for_features(..., head="qda")` when target/non-target distributions overlap.
* Use `BrainState` confidence / rejection to decide between accept, confirm, or repeat.
* Log rejected selections separately from incorrect selections.
Key docs:
* [Basic Examples: P300 detection](/examples/basic-examples#p300-detection)
* [BrainState](/personalizer/brain-state)
* [Bayesian QDA](/models/rxgmm)
* [Feature Normalization](/inference-configuration/feature-normalization)
## Wheelchair or device control
**Scenario:** safety-critical control from motor imagery or hybrid BCI commands.
Recommended pattern:
* Separate Personalizer / head output (`BrainState`) from command execution.
* Require higher confidence for movement than for UI navigation.
* Add a confirmation state for ambiguous commands (`need_more_data` / mid confidence).
* Stop the control loop after repeated rejected trials.
* Monitor latency budget end to end: acquisition, preprocess / encode, inference, and action.
Key docs:
* [BrainState](/personalizer/brain-state)
* [Streaming Inference Configuration](/inference-configuration/streaming-inference)
* [Python Streaming Inference](/python-sdk/streaming-inference)
* [Julia Streaming Inference](/julia-sdk/streaming-inference)
## Neurofeedback and training
**Scenario:** live feedback for attention, motor imagery, or cognitive training.
Recommended pattern:
* Prefer Personalizer for frozen trunks; use streaming sessions when chunk-level latency matters.
* Keep outputs probabilistic and expose confidence / `BrainState`.
* Adapt difficulty based on recent performance, not a single prediction.
* Use `recommend_adapt` + `partial_fit` for stream updates; `NimbusSTS` for long-session drift.
Key docs:
* [Personalizer overview](/personalizer/overview)
* [Bayesian STS](/models/rxsts)
* [Advanced Applications](/examples/advanced-applications)
* [Probabilistic AI & Uncertainty](/core-concepts/probabilistic-ai)
## Research studies
**Scenario:** offline experiments, multi-subject evaluation, or model comparison.
Recommended pattern:
* Use batch processing for reproducible offline analysis.
* Evaluate with subject-wise or session-wise splits when studying generalization.
* Save preprocessing / encoder ids and normalization parameters with every experiment.
* Compare Personalizer heads (`lda` / `qda` / `softmax`) before moving to specialized models; report vs fine-tune when relevant ([Evidence](/personalizer/evidence)).
Key docs:
* [Personalizer Evidence](/personalizer/evidence)
* [Batch Inference Configuration](/inference-configuration/batch-processing)
* [Model Specification](/model-specification)
* [External Preprocessing Integration](/development/preprocessing-integration)
## Product analytics
For deployed BCI systems, monitor:
| Metric | Why it matters |
| --------------------------------- | --------------------------------------------- |
| Accuracy | Task success when labels are available |
| Mean confidence | Model certainty and signal quality |
| Rejection / `need_more_data` rate | Whether gates or preprocessing are too strict |
| Latency | Real-time user experience |
| Calibration age | When retraining / recalibration may be needed |
| Drift / adapt score | Electrode, fatigue, or session changes |
## Choosing a starting point
| Use case | First choice | Workflow |
| ----------------------- | ----------------------------------- | -------------------------------------------------- |
| Frozen encoder product | `Personalizer(head="lda")` | `wrap` → calibrate → `BrainState` gates |
| Motor imagery rehab | Personalizer (`head="lda"`) | Streaming or batch + confidence gates |
| P300 communication | Personalizer (`head="qda"`) | Batch calibration, then confidence-gated selection |
| Long neurofeedback | Personalizer or `NimbusSTS` | Adapt helper and/or stateful streaming |
| Julia multinomial tasks | `NimbusProbit` | Julia training and streaming |
| Cross-subject research | Personalizer / `NimbusLDA` baseline | Batch evaluation with strict normalization |
## Next read
Product wedge and BrainState
Compact recipes for common BCI tasks
Cross-subject, hybrid, adaptive, and robust deployment
Choose a head family for your use case
# Nimbus BCI Engine
Source: https://docs.nimbusbci.com/index
Bayesian personalization for frozen EEG encoders — wrap any trunk, adapt online with Personalizer, ship apps on BrainState. Python and Julia SDKs.
*Bayesian personalization for frozen EEG encoders — and classical BCI heads when you already have features.*
Nimbus owns the personalization layer: wrap any frozen trunk that emits `encode(X) → Z`, adapt online with a Bayesian head (`Personalizer`), and ship apps on `BrainState`. Classical CSP / bandpower / Riemann → `NimbusLDA` workflows remain fully supported.
## Start here
Encoder contract: `wrap` → `Personalizer` → `BrainState`.
Local Personalizer workflows with `nimbus-bci`.
RxInfer-backed workflows with `NimbusSDK.jl`.
## Why Nimbus
Compare classical LDA, SVM, and deep learning with Nimbus — including \~8–10× cheaper online head updates vs batch refit.
Predictions include posterior probabilities and confidence scores.
Batch and streaming workflows for low-latency BCI systems.
Bayesian heads used by Personalizer and standalone classifiers.
Validation, quality gates, diagnostics, and deployment patterns.
## Heads (used by Personalizer)
| Model | SDK | Best For |
| --------------- | -------------- | ---------------------------------------------------------- |
| `NimbusLDA` | Python + Julia | Fast baseline; default `Personalizer(head="lda")`. |
| `NimbusQDA` | Python + Julia | Overlapping classes and class-specific covariance. |
| `NimbusSoftmax` | Python | Multiclass nonlinear boundaries with optional JAX install. |
| `NimbusProbit` | Julia | Julia-native Bayesian multinomial probit workflows. |
| `NimbusSTS` | Python | Non-stationary sessions and latent-state adaptation. |
## Core Workflow
**Product path (frozen trunk):**
```text theme={null}
EEG → frozen encoder → Personalizer → BrainState → confidence-aware action
```
**Classical path (features already extracted):**
```text theme={null}
EEG → preprocessing (CSP / bandpower / Riemann) → Nimbus head → confidence-aware action
```
Nimbus expects embeddings or preprocessed features — not raw EEG. Start with [Personalizer & Middleware](/personalizer/overview) for the encoder contract, or [Preprocessing Requirements](/inference-configuration/preprocessing-requirements) for classical CSP / bandpower / ERP.
## Documentation map
Encoder contract and BrainState app surface.
Choose Python or Julia and run first inference.
Breaking changes when upgrading nimbus-bci.
LDA, QDA, Softmax, Probit, STS as Personalizer heads.
Compact recipes and higher-level application patterns.
## Common questions
Python does not require an API key. Julia requires an API key to install and use the commercial core.
No. Nimbus expects embeddings from a frozen encoder (`encode(X) → Z`) or features from preprocessing pipelines such as CSP, bandpower, or ERP extraction.
Use Python for Personalizer / BrainState app integration, sklearn/MNE workflows, and local development. Use Julia for RxInfer-backed workflows and Julia-native model tooling.
## Next read
Product wedge overview
First Personalizer in minutes
Julia SDK first steps
# Batch Inference Configuration
Source: https://docs.nimbusbci.com/inference-configuration/batch-processing
Configure Nimbus for offline BCI inference, calibration, validation, and research analysis across multiple trials.
Batch inference processes complete trials offline. Use it for calibration, validation, model comparison, research studies, and quality review. Use [Streaming Inference Configuration](/inference-configuration/streaming-inference) when predictions must update during a live trial.
Python product path: calibrate [`Personalizer`](/personalizer/overview) on labeled trials (embeddings or classical features), then evaluate `BrainState` offline. Standalone heads (`NimbusLDA`, …) remain valid for research CV.
## When To Use Batch
* Calibrate a Personalizer / head on labeled trials.
* Evaluate held-out sessions or subjects.
* Compare model families and hyperparameters.
* Run preprocessing diagnostics before deployment.
* Analyze confidence, entropy, accuracy, and ITR after a session.
## Data Contracts
| SDK | Typical Batch Shape | Notes |
| --------------------------- | ----------------------------------- | ------------------------------------------------------------------------- |
| Python Personalizer / heads | `(n_trials, n_features)` | Embeddings after encode, or classical features; sklearn-compatible heads. |
| Python `BCIData` utilities | `(n_features, n_samples, n_trials)` | Used by lower-level inference helpers. |
| Julia `BCIData` | `(n_features, n_samples, n_trials)` | Labels are typically 1-indexed integers. |
All batch workflows should use embeddings or preprocessed features, not raw EEG.
## Basic Pattern
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64)
adapter = Personalizer(
encoder=enc,
head="lda",
classes=["left", "right"],
preset="research",
)
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test) # list[BrainState]
print(states[0].intent, states[0].confidence)
```
```python theme={null}
from nimbus_bci import NimbusLDA
from sklearn.model_selection import cross_val_score
X = load_features() # (n_trials, n_features)
y = load_labels()
clf = NimbusLDA()
scores = cross_val_score(clf, X, y, cv=5)
clf.fit(X, y)
probabilities = clf.predict_proba(X)
predictions = clf.predict(X)
```
```julia theme={null}
using NimbusSDK
using Statistics
features = load_features() # (n_features, n_samples, n_trials)
labels = load_labels()
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = size(features, 1),
n_classes = length(unique(labels)),
chunk_size = nothing
)
data = BCIData(features, metadata, labels)
model = train_model(NimbusLDA, data; iterations=50)
results = predict_batch(model, data)
accuracy = mean(results.predictions .== labels)
```
## Evaluation Checklist
* Split by session or subject when testing generalization.
* Estimate normalization parameters on training folds only.
* Report accuracy with confidence and rejection rate.
* Inspect posterior entropy for uncertain trials.
* Compare against a simple `NimbusLDA` baseline before tuning.
## Batch Diagnostics
Run diagnostics when batch accuracy is unexpectedly low:
```julia theme={null}
report = diagnose_preprocessing(data)
if !isempty(report.errors)
error("Invalid batch data: $(report.errors)")
end
```
Common issues include wrong feature shape, raw EEG passed as features, inconsistent label encoding, missing normalization, and mismatched model metadata.
## Batch vs Streaming
| Need | Use |
| ------------------------------ | --------- |
| Offline calibration | Batch |
| Cross-validation | Batch |
| Research analysis | Batch |
| Live feedback | Streaming |
| Chunk-by-chunk command updates | Streaming |
Most production BCI systems use both: batch for calibration and validation, streaming for live operation.
## Next Read
Keep train/test/deployment scales consistent.
Configure live chunk processing.
Compact batch and streaming recipes.
Choose a model before evaluation.
# BCI Error Handling
Source: https://docs.nimbusbci.com/inference-configuration/error-handling
Handle Nimbus BCI failures with validation checks, confidence thresholds, streaming recovery, and production safeguards.
BCI applications should fail predictably. The most important safeguards are input validation, confidence-aware decisions, and clear fallback behavior when signal quality or streaming health degrades.
Python SDK workflows are local and focus on validation, inference, and preprocessing errors. Julia SDK workflows also include API key and core installation errors.
Python product path: map thresholds to [`BrainState`](/personalizer/brain-state) fields (`confidence`, `uncertainty`, `need_more_data`, `rejected`) instead of inventing a parallel gate on raw class indices. Prefer presets `strict` / `permissive` on `Personalizer` (0.6 rename; was `research` / `consumer`), or factories like `Personalizer.for_deployment`.
## Common Failure Modes
| Category | Typical Symptom | First Check |
| ------------------ | ---------------------------------------- | ----------------------------------------- |
| Authentication | Julia core install or model access fails | API key, network, cached core state |
| Data shape | Dimension mismatch | Expected feature and trial shape |
| Invalid values | NaN, Inf, or unstable predictions | Preprocessing output and export path |
| Model mismatch | Low confidence or feature errors | Feature count, class count, model family |
| Poor preprocessing | Near-chance accuracy | Frequency band, artifacts, normalization |
| Streaming drift | Confidence falls over time | Session state, electrode quality, fatigue |
## Validate Before Inference
Check the data contract before calling model APIs:
* Features are finite and non-constant.
* Feature count matches metadata and model expectations.
* Labels match trial count and class encoding.
* Test data uses training normalization parameters.
* Streaming chunks match `(n_features, chunk_size)`.
```python theme={null}
import numpy as np
def validate_features(X, expected_features=None):
if not np.isfinite(X).all():
raise ValueError("Features contain NaN or Inf")
if expected_features is not None and X.shape[-1] != expected_features:
raise ValueError("Feature count mismatch")
```
```julia theme={null}
function validate_chunk(chunk, metadata)
size(chunk, 1) == metadata.n_features || error("Feature count mismatch")
all(isfinite, chunk) || error("Chunk contains NaN or Inf")
return true
end
```
## Confidence Gates
Do not map every prediction directly to an action. Use thresholds that match application risk.
| Confidence | Suggested Action |
| ---------- | ------------------------------------------ |
| `>= 0.9` | Execute low-risk command or accept trial. |
| `0.7-0.9` | Ask for confirmation or show alternatives. |
| `0.5-0.7` | Reject or request a clearer trial. |
| `< 0.5` | Stop, recalibrate, or run diagnostics. |
```python theme={null}
if confidence >= 0.9:
execute(prediction)
elif confidence >= 0.7:
request_confirmation(prediction)
else:
reject_trial("low confidence")
```
## Preprocessing Diagnostics
When confidence is unexpectedly low, check data quality before tuning model hyperparameters.
```julia theme={null}
report = diagnose_preprocessing(bci_data)
if !isempty(report.errors)
error("Preprocessing failed: $(report.errors)")
end
if report.quality_score < 0.7
@warn "Low preprocessing quality" score=report.quality_score
end
```
See [Preprocessing Requirements](/inference-configuration/preprocessing-requirements) and [Feature Normalization](/inference-configuration/feature-normalization) for upstream fixes.
## Streaming Recovery
Streaming systems should skip bad chunks, report error rates, and stop when consecutive errors exceed a safe limit.
```julia theme={null}
consecutive_errors = 0
max_errors = 10
for chunk in stream
try
validate_chunk(chunk, metadata)
result = process_chunk(session, chunk)
consecutive_errors = 0
catch err
consecutive_errors += 1
@warn "Skipping invalid chunk" exception=err
if consecutive_errors >= max_errors
error("Stopping stream after repeated chunk failures")
end
end
end
```
For chunk sizing and aggregation decisions, see [Streaming Inference Configuration](/inference-configuration/streaming-inference).
## Julia Setup Errors
For Julia SDK deployments:
* Run `NimbusSDK.install_core(api_key)` during setup, not inside a hot inference loop.
* Cache credentials/core installation where appropriate.
* Handle missing or invalid API keys before starting acquisition.
* Keep offline inference assumptions explicit in deployment docs.
```julia theme={null}
using NimbusSDK
if !NimbusSDK.is_core_installed()
NimbusSDK.install_core(ENV["NIMBUS_API_KEY"])
end
```
## Production Checklist
* Validate every calibration file before training.
* Save model metadata, normalization parameters, and SDK versions.
* Log prediction, confidence, posterior summary, latency, and rejection reason.
* Separate classifier output from command execution.
* Use stricter thresholds for safety-critical actions.
* Monitor session-level confidence trends and rejection rates.
* Provide a fallback path for stream loss, repeated bad chunks, or recalibration needs.
## Troubleshooting Quick Reference
| Issue | Likely Cause | Fix |
| ------------------------------ | ----------------------------------- | -------------------------------------------------- |
| Accuracy near chance | Raw EEG or wrong feature band | Revisit preprocessing and feature extraction. |
| Confidence always low | Poor normalization or noisy trial | Run diagnostics and reuse train normalization. |
| Dimension mismatch | Feature count differs from metadata | Check export shape and model metadata. |
| Works offline, fails streaming | Chunk shape or state handling | Validate chunks and reset sessions between trials. |
| Cross-session drop | Electrode/session scale shift | Use saved normalization params. |
## Next Read
Project structure and production guardrails.
Chunking, aggregation, and quality gates.
Prevent data-quality failures upstream.
Compact recipes for common workflows.
# BCI Feature Normalization
Source: https://docs.nimbusbci.com/inference-configuration/feature-normalization
Normalize BCI features consistently across training, testing, and deployment to improve cross-session reliability.
Feature normalization keeps calibration, evaluation, and deployment data on the same scale. It is especially important for cross-session BCI, where electrode impedance, skin conductance, amplifier gain, and user state can shift feature amplitudes.
The same rules apply to **embedding rows** `Z` before `Personalizer.fit` / `partial_fit`, and to classical CSP / bandpower features before a standalone head. See [Personalizer & Middleware](/personalizer/overview).
Estimate normalization parameters from training or calibration data only. Reuse those exact parameters for test and deployment data.
## When To Normalize
Normalize when:
* using a model across sessions or days
* combining subjects or recording systems
* mixing feature families with different scales
* running long sessions where electrode/contact quality may drift
Do not normalize raw EEG before feature extraction. Extract CSP, bandpower, ERP, or other features first.
## Recommended Workflow
```python theme={null}
from nimbus_bci import estimate_normalization_params, apply_normalization
norm = estimate_normalization_params(X_train, method="zscore")
X_train_norm = apply_normalization(X_train, norm)
X_test_norm = apply_normalization(X_test, norm)
clf.fit(X_train_norm, y_train)
predictions = clf.predict(X_test_norm)
```
```julia theme={null}
using NimbusSDK
norm = estimate_normalization_params(train_features; method=:zscore)
train_norm = apply_normalization(train_features, norm)
test_norm = apply_normalization(test_features, norm)
train_data = BCIData(train_norm, metadata, train_labels)
model = train_model(NimbusLDA, train_data)
results = predict_batch(model, BCIData(test_norm, metadata, test_labels))
```
## Method Choice
| Method | Use When | Tradeoff |
| -------- | ----------------------------------- | ------------------------------ |
| `zscore` | Default BCI workflow | Sensitive to extreme outliers. |
| `robust` | Noisy data with artifacts | Less standard, more resilient. |
| `minmax` | Bounded feature ranges are required | Very sensitive to outliers. |
Start with `zscore`. Switch to `robust` when artifacts or heavy-tailed features make mean/std unstable.
## Common Pitfalls
* **Normalizing train and test separately**: causes scale leakage and unstable evaluation.
* **Normalizing raw EEG**: can break feature extraction assumptions.
* **Forgetting to save parameters**: makes deployment data incompatible with training scale.
* **Changing preprocessing after calibration**: invalidates the saved normalization parameters.
## What To Save
Save normalization parameters next to the model:
```json theme={null}
{
"normalization": "zscore",
"estimated_from": "training-session-001",
"feature_type": "csp",
"n_features": 8
}
```
Also save the SDK version, feature extraction settings, label mapping, and validation metrics.
## Quality Checks
Before inference:
* feature values are finite
* feature count matches model metadata
* normalization params came from training/calibration data
* test/deployment data used the same preprocessing pipeline
If confidence drops unexpectedly, run preprocessing diagnostics and inspect whether feature scale changed between sessions.
## Next Read
Prepare valid features before normalization.
Export/import feature arrays across tools.
Offline evaluation with normalized data.
Diagnose bad scale, shape, or confidence.
# EEG Preprocessing Requirements
Source: https://docs.nimbusbci.com/inference-configuration/preprocessing-requirements
Prepare EEG features for Nimbus with CSP, bandpower, ERP extraction, and data-quality requirements for reliable BCI inference.
**NimbusSDK expects embeddings or preprocessed features, NOT raw EEG.**
The SDK performs Bayesian inference on feature-space data — either frozen-encoder embeddings (`encode(X) → Z`) or classical CSP / bandpower / ERP features.
**Two valid handoffs into Nimbus**
1. **Product path:** freeze a neural trunk → `wrap` → [`Personalizer`](/personalizer/overview) → `BrainState` (Nimbus does not own the DL architecture).
2. **Classical path:** extract CSP / bandpower / ERP features → `NimbusLDA` / other heads (this page).
## What NimbusSDK Does
✅ Bayesian personalization (`Personalizer`) and standalone heads (LDA / QDA / Softmax / STS / Probit)\
✅ Real-time and batch inference with uncertainty\
✅ Confidence scoring, gating (`BrainState`), and quality assessment\
✅ Performance metrics (ITR, accuracy)
## What NimbusSDK Does NOT Do
❌ Host or train deep EEG architectures (trunks stay external)\
❌ Raw EEG filtering (bandpass, notch)\
❌ Artifact removal (ICA, regression, ASR)\
❌ Spatial filtering (CSP, ICA, Laplacian)\
❌ Feature extraction (bandpower, CSP, ERP)
## Why This Separation?
1. **Preprocessing / encoding is paradigm-specific**: Motor Imagery needs CSP at 8-30 Hz, P300 needs ERP at 0.5-10 Hz; foundation trunks define their own encode contract
2. **Hardware-dependent**: Different amplifiers require different artifact handling
3. **Domain expertise**: Use established tools (MNE-Python, EEGLAB, BrainDecode, etc.)
4. **Flexibility**: You can use any preprocessing or encoder pipeline
5. **Focus**: NimbusSDK excels at Bayesian personalization and inference, not signal processing
Encoder contract: [Encoder contract](/personalizer/encoder-contract). Classical tool integration: [Real-time Setup](/inference-configuration/real-time-setup).
## Required Preprocessing Pipeline
### Step 1: Bandpass Filtering
Remove frequencies outside the band of interest:
| BCI Paradigm | Frequency Band | Rationale |
| ----------------- | -------------- | ---------------------------------------------- |
| **Motor Imagery** | 8-30 Hz | Mu rhythm (8-13 Hz) and Beta rhythm (13-30 Hz) |
| **P300** | 0.5-10 Hz | Slow event-related potentials |
| **SSVEP** | Target ± 2 Hz | Narrow band around stimulation frequency |
**Tools**: MNE-Python `raw.filter()`, EEGLAB `pop_eegfiltnew()`
### Step 2: Artifact Removal
Remove physiological and environmental artifacts:
| Artifact Type | Frequency Range | Removal Method |
| -------------- | ---------------------- | ----------------------- |
| **Eye blinks** | 0-4 Hz, high amplitude | ICA (remove components) |
| **Muscle** | >30 Hz, high amplitude | ICA or thresholding |
| **Line noise** | 50/60 Hz | Notch filter |
| **Movement** | 0-10 Hz, transient | Regression or rejection |
**Recommended**: ICA (Independent Component Analysis) - Most versatile method
### Step 3: Epoching
Segment continuous EEG into trials aligned to events:
| Paradigm | Time Window | Baseline |
| ----------------- | --------------------------------- | -------------------------- |
| **Motor Imagery** | 0 to 4 seconds post-cue | -0.5 to 0 seconds |
| **P300** | -0.2 to 0.8 seconds post-stimulus | -0.2 to 0 seconds |
| **SSVEP** | 2 to 4 seconds post-onset | No baseline (steady-state) |
### Step 4: Feature Extraction
Convert filtered epochs to discriminative features (see below).
## Feature Types
### CSP (Common Spatial Patterns) - Motor Imagery
**Recommended for Motor Imagery**
CSP maximizes variance ratio between two classes, making it ideal for motor imagery BCI.
```python theme={null}
# MNE-Python example
from mne.decoding import CSP
import numpy as np
csp = CSP(n_components=8, reg=None, log=False, transform_into='csp_space')
csp.fit(X_train, y_train)
X_csp = csp.transform(X) # (n_trials, n_components, n_samples)
# Extract log-variance (standard for MI)
logvar_features = np.log(np.var(X_csp, axis=2)) # (n_trials, n_components)
# Transpose for Julia: (n_features, n_samples, n_trials)
X_julia = np.transpose(X_csp, (1, 2, 0))
```
**Output dimension**: `2 × n_components` (e.g., 16 for 8 components)
### Bandpower Features - SSVEP
Compute power in specific frequency bands:
```python theme={null}
from scipy.signal import welch
def compute_bandpower(epochs, bands=[(8, 13), (13, 30)]):
powers = []
for band in bands:
freqs, psd = welch(epochs, fs=250, nperseg=256)
band_power = np.mean(psd[:, (freqs >= band[0]) & (freqs <= band[1])], axis=1)
powers.append(band_power)
return np.column_stack(powers)
```
**Output dimension**: `n_channels × n_bands`
### ERP Amplitude - P300
Extract amplitude at specific time windows:
```python theme={null}
# Extract mean amplitude in P300 window (300-500ms)
sfreq = epochs.info['sfreq']
p300_start = int(0.3 * sfreq)
p300_end = int(0.5 * sfreq)
# Mean amplitude per channel in P300 window
p300_amplitude = epochs.get_data()[:, :, p300_start:p300_end].mean(axis=2)
```
## Paradigm-Specific Guidelines
### Motor Imagery
**Recommended pipeline:**
1. Bandpass: **8-30 Hz** (mu + beta)
2. Artifact removal: ICA (remove eye blinks)
3. Epoching: 0-4 seconds post-cue
4. Feature extraction: **CSP** (8 components → 16 features)
5. Temporal aggregation: **Log-variance**
**Data requirements:**
* Minimum: 40 trials per class
* Recommended: 80+ trials per class
**Expected accuracy**: 70-90% (subject-dependent)
### P300
**Recommended pipeline:**
1. Bandpass: **0.5-10 Hz**
2. Artifact removal: ICA or rejection
3. Epoching: -0.2 to 0.8 seconds post-stimulus
4. Feature extraction: ERP amplitude (300-500ms window)
5. Temporal aggregation: Mean
**Data requirements:**
* Minimum: 200 target, 1000 non-target trials
* Recommended: 400 target, 2000 non-target trials
**Expected accuracy**: 80-95% (with averaging)
### SSVEP
**Recommended pipeline:**
1. Bandpass: **Target frequency ± 2 Hz**
2. Artifact removal: Eye blink rejection
3. Epoching: 2-4 seconds post-onset
4. Feature extraction: CCA or bandpower
5. No baseline correction (steady-state)
**Data requirements:**
* Minimum: 30 trials per frequency
* Recommended: 60+ trials per frequency
**Expected accuracy**: 85-98%
## Data Format Requirements
### Expected Format
```julia theme={null}
features::Array{Float64, 3} # (n_features × n_samples × n_trials)
```
Where:
* `n_features`: Number of extracted features (e.g., 16 for CSP)
* `n_samples`: Samples per trial (e.g., 1000 for 4 seconds at 250 Hz)
* `n_trials`: Number of trials
### Converting from Python/NumPy
Python typically uses `(n_trials, n_features, n_samples)`:
```python theme={null}
# Python shape
X_csp.shape # (100 trials, 8 components, 1000 samples)
# Convert for Julia
import numpy as np
X_julia = np.transpose(X_csp, (1, 2, 0)) # (8, 1000, 100)
# Save
from scipy.io import savemat
savemat('features.mat', {'features': X_julia, 'labels': y + 1}) # 1-indexed labels!
```
### Labels
Labels must be **1-indexed integers**:
```julia theme={null}
labels = [1, 2, 3, 4, 1, 2, ...] # ✅ Correct (Julia-style)
labels = [0, 1, 2, 3, 0, 1, ...] # ❌ Wrong (Python-style)
```
Convert 0-indexed to 1-indexed:
```python theme={null}
y_julia = y + 1 # If y ∈ {0, 1, 2, 3}, convert to {1, 2, 3, 4}
```
## Common Pitfalls
### Pitfall 1: Using Raw EEG Instead of Features
**Symptom**: Accuracy near chance level (25% for 4-class)
**Fix**: Apply feature extraction (CSP, bandpower, etc.)
```julia theme={null}
# WRONG - Raw EEG channels
features = raw_eeg # (32 channels × 1000 samples × 100 trials)
# CORRECT - CSP features
csp_features = extract_csp(raw_eeg) # (16 features × 1000 samples × 100 trials)
```
### Pitfall 2: Wrong Frequency Band
**Symptom**: Low confidence scores, poor separability
| Paradigm | Correct Band | Wrong Band | Result |
| ------------- | ------------ | ---------- | -------------------- |
| Motor Imagery | 8-30 Hz | 0.5-10 Hz | Accuracy ↓ 20-30% |
| P300 | 0.5-10 Hz | 8-30 Hz | Amplitude ↓, noise ↑ |
### Pitfall 3: Incorrect Data Shape
**Symptom**: `DimensionMismatch` error
```julia theme={null}
# WRONG - Transposed dimensions
features = X_csp # (trials × features × samples) from numpy
# CORRECT - Transpose to NimbusSDK format
features = permutedims(X_csp, (2, 3, 1)) # (features × samples × trials)
```
## Validation Checklist
Before using NimbusSDK, verify:
### Data Quality ✅
* [ ] **No NaN values**: `@assert !any(isnan, features)`
* [ ] **No Inf values**: `@assert !any(isinf, features)`
* [ ] **Finite range**: Values are reasonable
* [ ] **No constant features**: Each feature varies
### Preprocessing Steps ✅
* [ ] **Bandpass filtered**: Paradigm-appropriate frequency band
* [ ] **Artifacts removed**: ICA or equivalent applied
* [ ] **Epoched correctly**: Proper time windows
* [ ] **Features extracted**: CSP/bandpower/ERP, not raw EEG
### Format Requirements ✅
* [ ] **Correct shape**: `(n_features × n_samples × n_trials)`
* [ ] **Correct type**: `Float64` (or convertible)
* [ ] **Labels valid**: 1-indexed integers
* [ ] **Metadata accurate**: Sampling rate, paradigm, feature type
## Feature Normalization
**Feature normalization is CRITICAL for cross-session BCI!**
EEG amplitude varies 50-200% across sessions. Proper normalization improves cross-session accuracy by 15-30%.
For optimal performance, especially when using models across different sessions:
```julia theme={null}
using NimbusSDK
# Training: Estimate normalization parameters
norm_params = estimate_normalization_params(train_features; method=:zscore)
train_norm = apply_normalization(train_features, norm_params)
# Testing: Apply SAME parameters
test_norm = apply_normalization(test_features, norm_params)
# Save parameters with model
@save "model.jld2" model norm_params
```
**Impact**:
* Same session: +1% accuracy
* Cross-session (next day): +15-25% accuracy
* Multi-subject transfer: +15-20% accuracy
See [Feature Normalization](/inference-configuration/feature-normalization) for the recommended train/test scaling workflow.
## Preprocessing Diagnostics
NimbusSDK includes built-in diagnostics:
```julia theme={null}
using NimbusSDK
# Run diagnostics
report = diagnose_preprocessing(bci_data)
# Check for issues
if !isempty(report.errors)
@error "Preprocessing errors detected" report.errors
end
# View recommendations
println("Quality score: $(round(report.quality_score * 100, digits=1))%")
for rec in report.recommendations
println(" • ", rec)
end
```
**What it checks:**
* Line noise (50/60 Hz components)
* Amplitude range (detects raw EEG vs features)
* DC offset
* Temporal correlation
* Feature normalization
* NaN/Inf values
## Next Read
Integration with EEG acquisition systems
Process multiple trials efficiently
Complete SDK reference
Working preprocessing examples
## Additional Resources
### Tools
* [MNE-Python](https://mne.tools/) - Python EEG processing
* [EEGLAB](https://eeglab.org/) - MATLAB EEG processing
* [BrainFlow](https://brainflow.readthedocs.io/) - Hardware integration
### Papers
* Ramoser et al. (2000). "Optimal spatial filtering of single trial EEG"
* Blankertz et al. (2008). "Optimizing spatial filters for robust EEG single-trial analysis"
* Lotte et al. (2018). "A review of classification algorithms for EEG-based BCI"
# Real-Time BCI Setup
Source: https://docs.nimbusbci.com/inference-configuration/real-time-setup
Set up low-latency BCI acquisition pipelines with LSL, BrainFlow, preprocessing, and streaming handoff to Nimbus SDKs.
This page owns hardware and acquisition setup: how EEG gets from an amplifier into embedding or feature chunks that Nimbus can consume. For SDK streaming APIs, see [Streaming Inference Configuration](/inference-configuration/streaming-inference), [Python Streaming Inference](/python-sdk/streaming-inference), and [Julia Streaming Inference](/julia-sdk/streaming-inference).
**Online encode path:** amplifier → preprocess → frozen trunk `encode` → embedding chunks → [`Personalizer`](/personalizer/overview) / streaming session → `BrainState`.\
**Classical path:** amplifier → preprocess → CSP/bandpower chunks → head / streaming session.
## Real-Time Pipeline
```text theme={null}
EEG amplifier -> acquisition transport -> preprocess / encode -> chunks -> Nimbus (Personalizer / streaming) -> application command
```
Nimbus expects **embedding or feature chunks**, not raw EEG. Your acquisition loop should filter, clean, window, and either encode or extract features before calling the SDK.
## Acquisition Options
| Option | Best for | Notes |
| ------------------------- | ---------------------------------------------- | --------------------------------------------------------- |
| Lab Streaming Layer (LSL) | Research labs and multi-device synchronization | Common for EEG streams and markers |
| BrainFlow | Consumer and research EEG hardware | Unified API for many boards |
| Vendor SDK | Production devices | Lowest integration overhead when the vendor API is stable |
| File replay | Testing and demos | Useful for deterministic latency tests |
## Setup Checklist
1. Confirm amplifier sampling rate and channel order.
2. Synchronize event markers with EEG samples.
3. Apply artifact handling and feature extraction in the same way as calibration.
4. Emit chunks with stable shape: `(n_features, chunk_size)`.
5. Use the same normalization parameters from training.
6. Measure latency around preprocessing and SDK inference separately.
7. Add rejection thresholds for low-confidence or high-entropy decisions.
## LSL Pattern
Use LSL when you need synchronized EEG and marker streams.
```python theme={null}
from pylsl import StreamInlet, resolve_stream
eeg_stream = resolve_stream("type", "EEG")[0]
marker_stream = resolve_stream("type", "Markers")[0]
eeg_inlet = StreamInlet(eeg_stream)
marker_inlet = StreamInlet(marker_stream)
while True:
samples, timestamps = eeg_inlet.pull_chunk(timeout=0.0)
markers, marker_times = marker_inlet.pull_chunk(timeout=0.0)
if samples:
feature_chunk = preprocess_to_features(samples, markers)
result = session.process_chunk(feature_chunk)
handle_prediction(result.prediction, result.confidence)
```
## BrainFlow Pattern
Use BrainFlow when you want one API across supported EEG boards.
```python theme={null}
from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds
params = BrainFlowInputParams()
board = BoardShim(BoardIds.CYTON_BOARD.value, params)
board.prepare_session()
board.start_stream()
try:
while True:
data = board.get_current_board_data(250)
feature_chunk = preprocess_to_features(data)
result = session.process_chunk(feature_chunk)
handle_prediction(result.prediction, result.confidence)
finally:
board.stop_stream()
board.release_session()
```
## Latency Budget
| Stage | Target |
| ------------------------------------ | --------------------------------- |
| Acquisition buffer | 1-10ms |
| Preprocessing and feature extraction | 5-50ms, depending on feature type |
| Nimbus inference | 10-25ms typical |
| Application command | 1-10ms |
Profile each stage separately. If latency is high, the bottleneck is often acquisition buffering or feature extraction rather than model inference.
## Production Guardrails
* Warm up model and preprocessing code before the session starts.
* Use fixed-size buffers to avoid unbounded memory growth.
* Reset streaming sessions between trials.
* Log per-stage latency, confidence, entropy, and rejection decisions.
* Keep calibration preprocessing and online preprocessing identical.
## Next Read
Choose chunk sizes and aggregation methods.
Use `StreamingSession` in Python.
Use `init_streaming`, `process_chunk`, and `finalize_trial` in Julia.
Prepare feature-space inputs correctly.
# Streaming Inference Configuration
Source: https://docs.nimbusbci.com/inference-configuration/streaming-inference
Configure cross-SDK chunk size, temporal aggregation, and streaming decision patterns for low-latency BCI inference.
This page is the cross-SDK configuration overview for streaming inference: when to stream, chunk sizes, aggregation policy, and quality gates.
For implementation APIs:
* **Python**: [Python Streaming Inference](/python-sdk/streaming-inference) — `StreamingSession`, `StreamingSessionSTS`, BrainState bridge
* **Frozen-encoder apps**: [Personalizer](/personalizer/overview) for app-facing decisions
* **Julia**: [Julia Streaming Inference](/julia-sdk/streaming-inference)
## Streaming vs Batch
Use **batch inference** when full trials are already available and you are doing offline evaluation, model validation, or analytics.
Use **streaming inference** when embedding or feature chunks arrive over time and the application needs low-latency updates before a complete trial has finished.
```text theme={null}
EEG stream -> preprocess / encode -> chunks -> streaming session -> BrainState / final decision
```
## Core Configuration
Every streaming setup needs:
* `sampling_rate`: acquisition rate in Hz.
* `chunk_size`: number of samples per chunk.
* `paradigm`: task type such as motor imagery, P300, or SSVEP.
* `feature_type`: feature representation such as CSP, bandpower, or ERP amplitude.
* `n_features`: feature count per chunk.
* `n_classes`: number of output classes.
* `temporal_aggregation`: how to reduce feature time structure when required.
## Chunk Size Guidelines
| Chunk duration | Typical use | Trade-off |
| -------------- | -------------------------------------------- | -------------------------------------- |
| 0.25-0.5s | Fast feedback, games, exploratory interfaces | Lower latency, less evidence per chunk |
| 0.5-1.0s | Most real-time BCI systems | Balanced responsiveness and confidence |
| 1.0-2.0s | Medical or high-stakes decisions | Higher confidence, slower feedback |
Start with 0.5-1.0s chunks for motor imagery and adjust based on confidence, latency, and user experience.
## Aggregation Methods
Streaming produces one posterior per chunk. Trial-level decisions combine those chunk posteriors.
| Method | Use when |
| ---------------- | ----------------------------------------------------- |
| `weighted_vote` | Default — confidence-weighted decisions across chunks |
| `majority_vote` | Equal weight per chunk |
| `posterior_mean` | Smooth posterior averaging across chunks |
| `max_confidence` | Trust the single most confident chunk |
Start with `weighted_vote`. Use `posterior_mean` for smoother probabilities, or `max_confidence` when the task has short high-signal windows.
## Quality Gates
Streaming systems should monitor:
* confidence (`max posterior probability`)
* entropy (`prediction uncertainty`)
* class balance over recent trials
* rejection rate
* per-chunk and per-trial latency
For Python rejection and quality APIs, see [Python SDK API Reference](/python-sdk/api-reference). For end-to-end real-time setup, see [Real-Time BCI Setup](/inference-configuration/real-time-setup).
## Next read
Python `StreamingSession` API and STS state handling
Julia streaming API for local chunk processing
Hardware, LSL, BrainFlow, and acquisition loops
Offline trial processing and diagnostics
# Julia SDK API Reference
Source: https://docs.nimbusbci.com/julia-sdk/api-reference
Complete API reference for NimbusSDK.jl covering NimbusLDA, NimbusQDA, NimbusProbit, and RxInfer-powered BCI workflows.
**Python SDK Users:** Looking for Python documentation? See [Python SDK API Reference](/python-sdk/api-reference).
This page documents the **Julia SDK** (NimbusSDK.jl).
The NimbusSDK.jl Julia package provides production-ready Bayesian inference for Brain-Computer Interface (BCI) applications. Built on **RxInfer.jl**, it offers three models (NimbusLDA, NimbusQDA, and NimbusProbit) with batch and streaming inference capabilities. **Note**: NimbusSTS is currently available in the Python SDK only.
## Installation
NimbusSDK.jl is now available in the public Julia General Registry:
```julia theme={null}
using Pkg
# Install the public wrapper (registered package)
Pkg.add("NimbusSDK")
```
After installing the wrapper, install the proprietary core with your license key:
```julia theme={null}
using NimbusSDK
# Install the commercial core (one-time setup)
NimbusSDK.install_core("your-api-key-here")
```
Get your API key at: [hello@nimbusbci.com](mailto:hello@nimbusbci.com)
### Requirements
* Julia ≥ 1.9
* Valid NimbusSDK license key
* Preprocessed EEG features (CSP, bandpower, etc.) - **not raw EEG**
**What changed?** NimbusSDK.jl is now a public wrapper package in the Julia General Registry. The proprietary inference core (NimbusSDKCore) is automatically installed when you provide your API key. No more private registry setup needed!
## Quick Start
```julia theme={null}
using NimbusSDK
# One-time setup: Install core with your API key
NimbusSDK.install_core("your-api-key")
# Load model
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
# Prepare data
data = BCIData(features, metadata, labels)
# Run inference
results = predict_batch(model, data)
```
## Setup
### install\_core()
Install the proprietary NimbusSDKCore with your API key. This is a **one-time setup** that downloads and configures the commercial inference engine.
```julia theme={null}
install_core(api_key::String) -> Bool
```
**Parameters:**
* `api_key::String` - Your NimbusSDK API key (format: `nbci_live_...` or `nbci_test_...`)
**Returns:** `true` if installation successful
**Example:**
```julia theme={null}
using NimbusSDK
# One-time setup (downloads and installs core)
NimbusSDK.install_core("nbci_live_...")
# After installation, you can use the SDK in any project
using NimbusSDK
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
```
The core installation is persistent. You only need to run `install_core()` once per machine. After that, `using NimbusSDK` will work in any Julia project.
### check\_installation()
Verify that the core is installed and working correctly.
```julia theme={null}
check_installation() -> Bool
```
**Returns:** `true` if core is installed and operational
**Note**: `check_installation()` is provided by the NimbusSDK wrapper package. For direct NimbusSDKCore usage, check authentication status using `NimbusSDKCore.AUTH_STATE[]`.
## Authentication
**For most users**: Authentication is handled automatically by `NimbusSDK.install_core()`. The functions below are for advanced users working directly with NimbusSDKCore.
### authenticate()
Authenticate with NimbusSDKCore using an API key. This function validates the key remotely and caches credentials locally.
```julia theme={null}
authenticate(api_key::String; offline_mode::Bool=false) -> AuthSession
```
**Parameters:**
* `api_key::String` - Your NimbusSDK API key (format: `nbci_live_...` or `nbci_test_...`)
* `offline_mode::Bool` - If `true`, skip remote validation and use cached credentials (default: `false`)
**Returns:** `AuthSession` object containing authentication state
**Example:**
```julia theme={null}
using NimbusSDKCore
# Authenticate online (validates with API)
session = NimbusSDKCore.authenticate("nbci_live_your_key_here")
# Authenticate offline (uses cached credentials)
session = NimbusSDKCore.authenticate("nbci_live_your_key_here"; offline_mode=true)
```
### invalidate\_session()
Clear the current authentication session and cached credentials.
```julia theme={null}
invalidate_session() -> Nothing
```
**Example:**
```julia theme={null}
using NimbusSDKCore
# Clear authentication
NimbusSDKCore.invalidate_session()
```
### AuthSession
Authentication session object containing license information and API state.
```julia theme={null}
struct AuthSession
api_key::String
user_id::String
license_type::Symbol # :trial, :research, :commercial, :enterprise
expires_at::DateTime
features_enabled::Vector{Symbol} # [:batch_inference, :streaming, :training, ...]
usage_quota::Union{Int, Nothing} # Remaining quota (nothing = unlimited)
usage_quota_max::Union{Int, Nothing} # Maximum quota
refresh_token::Union{String, Nothing} # Refresh token for renewing access
end
```
### Key Management Functions
#### save\_api\_key()
Save an API key to local storage for later use.
```julia theme={null}
save_api_key(api_key::String) -> Nothing
```
#### get\_stored\_api\_key()
Retrieve a previously saved API key.
```julia theme={null}
get_stored_api_key() -> Union{String, Nothing}
```
**Returns:** API key string or `nothing` if no key is stored
#### delete\_stored\_api\_key()
Delete a stored API key from local storage.
```julia theme={null}
delete_stored_api_key() -> Nothing
```
#### authenticate\_from\_storage()
Authenticate using a previously saved API key.
```julia theme={null}
authenticate_from_storage() -> AuthSession
```
**Returns:** `AuthSession` if authentication successful
**Throws:** Error if no stored key or authentication fails
### Quota Management
#### refresh\_quota()
Refresh API quota information from the server.
```julia theme={null}
refresh_quota() -> AuthSession
```
**Returns:** Updated `AuthSession` with refreshed quota information
#### check\_quota\_and\_refresh()
Check quota status and refresh if needed.
```julia theme={null}
check_quota_and_refresh() -> Bool
```
**Returns:** `true` if quota is available, `false` if exhausted
#### get\_quota\_status()
Get current quota status without refreshing.
```julia theme={null}
get_quota_status() -> NamedTuple
```
**Returns:** Named tuple with `remaining::Int`, `monthly_limit::Int`, `usage_percentage::Float64`
**Example:**
```julia theme={null}
using NimbusSDKCore
# Check quota
status = get_quota_status()
println("Quota remaining: $(status.remaining) / $(status.monthly_limit)")
println("Usage: $(round(status.usage_percentage, digits=1))%")
```
## Models
NimbusSDK.jl provides three Bayesian inference models (Bayesian STS is available in Python SDK only):
### NimbusLDA
**Primary Name**: Bayesian LDA (Bayesian Linear Discriminant Analysis)\
**API Name**: `NimbusLDA`\
**Mathematical Model**: Pooled Gaussian Classifier (PGC)
Linear Discriminant Analysis with shared precision matrix. Fast inference with good performance for well-separated classes.
**Fields:**
* `mean_posteriors::Vector` - Full posterior distributions for class means (MvNormal objects, one per class)
* `precision_posterior` - Full posterior distribution for shared precision matrix (Wishart object, shared across all classes)
* `priors::Vector{Float64}` - Empirical class priors from training data (must sum to 1.0)
* `metadata::ModelMetadata` - Model metadata
* `dof_offset::Int` - Degrees of freedom offset used during training (default: 2)
* `mean_prior_precision::Float64` - Mean prior precision strength used during training (default: 0.01)
**Accessing model parameters**: To get point estimates from posterior distributions, use `mean(model.mean_posteriors[k])` for class means and `mean(model.precision_posterior)` for the precision matrix. The SDK stores full posterior distributions (not just point estimates) for proper Bayesian inference.
### NimbusQDA
**Primary Name**: Bayesian QDA\
**API Name**: `NimbusQDA`\
**Mathematical Model**: Heteroscedastic Gaussian Classifier (HGC)
Gaussian classifier with class-specific covariance matrices. More flexible, handles overlapping distributions.
**Fields:**
* `mean_posteriors::Vector` - Full posterior distributions for class means (MvNormal objects, one per class)
* `precision_posteriors::Vector` - Full posterior distributions for precision matrices (Wishart objects, one per class)
* `priors::Vector{Float64}` - Empirical class priors from training data (must sum to 1.0)
* `metadata::ModelMetadata` - Model metadata
* `dof_offset::Int` - Degrees of freedom offset used during training (default: 2)
* `mean_prior_precision::Float64` - Mean prior precision strength used during training (default: 0.01)
**Accessing model parameters**: To get point estimates from posterior distributions, use `mean(model.mean_posteriors[k])` for class means and `mean(model.precision_posteriors[k])` for class-specific precision matrices. The SDK stores full posterior distributions (not just point estimates) for proper Bayesian inference.
### NimbusProbit
**Primary Name**: Bayesian Multinomial Probit Regression\
**API Name**: `NimbusProbit`\
**Mathematical Model**: Bayesian Multinomial Probit Regression
Non-Gaussian multinomial classifier for complex multinomial classification tasks.
**Fields:**
* `B_posterior` - Learned regression coefficients posterior
* `W_posterior` - Learned precision matrix posterior
* `metadata::ModelMetadata` - Model metadata
* `N::Int` - Number of trials per observation
* Hyperparameters: `ξβ`, `Wβ`, `W_df`, `W_scale`
### load\_model()
Load a pre-trained or custom model.
```julia theme={null}
load_model(ModelType, model_name::String) -> Model
load_model(ModelType, filepath::String) -> Model
```
**Parameters:**
* `ModelType` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
* `model_name::String` - Model identifier or filepath
**Example:**
```julia theme={null}
# Load from Nimbus model zoo
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
# Load custom model
model = load_model(NimbusLDA, "my_model.jld2")
```
### save\_model()
Save a trained or loaded model to disk.
```julia theme={null}
save_model(model, filepath::String)
```
**Parameters:**
* `model` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
* `filepath::String` - File path to save model (typically `.jld2` format)
### train\_model()
Train a new model on labeled data.
```julia theme={null}
train_model(ModelType, train_data::BCIData; kwargs...) -> Model
```
**Parameters (common to all models):**
* `ModelType` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
* `train_data::BCIData` - Labeled training data (must include labels!)
* `iterations::Int` - Number of inference iterations (default: 50)
* `showprogress::Bool` - Show training progress (default: false)
* `name::String` - Model name (defaults are model-specific: `"custom_nimbus_lda"`, `"custom_nimbus_qda"`, or `"custom_nimbus_probit"`)
* `description::String` - Model description (default: `""`)
**Model-specific hyperparameters:**
* **NimbusLDA / NimbusQDA**
* `dof_offset::Int` – Degrees of freedom offset for Wishart priors during training
* Default: `2`, range: (\[1, 5])
* `mean_prior_precision::Float64` – Prior precision for class means
* Default: `0.01`, range: (\[0.001, 0.1])
* **NimbusProbit**
* `N::Int` – Trials per observation (default: `1`)
* `ξβ::Union{Nothing, Vector{Float64}}` – Prior mean for regression coefficients (`nothing` → auto)
* `Wβ::Union{Nothing, Matrix{Float64}}` – Prior precision for regression coefficients (`nothing` → auto)
* `W_df::Union{Nothing, Float64}` – Wishart degrees of freedom (`nothing` → auto)
* `W_scale::Union{Nothing, Matrix{Float64}}` – Wishart scale matrix (`nothing` → auto)
Hyperparameters for each model type are documented in more detail on the corresponding model pages:
`/models/rxlda`, `/models/rxgmm`, and `/models/nimbusprobit`.
**Example (NimbusLDA):**
```julia theme={null}
# Train with default hyperparameters
model = train_model(
NimbusLDA,
train_data;
iterations = 50,
showprogress = true,
name = "my_motor_imagery_model",
description = "4-class motor imagery with CSP"
)
# Train with custom hyperparameters
model = train_model(
NimbusLDA,
train_data;
iterations = 50,
showprogress = true,
name = "my_tuned_model",
description = "4-class MI with tuned hyperparameters",
dof_offset = 3, # More regularization for noisy data
mean_prior_precision = 0.05 # Stronger prior
)
```
### calibrate\_model()
Fine-tune a pre-trained model with subject-specific data (faster than training from scratch).
```julia theme={null}
calibrate_model(
base_model,
calib_data::BCIData;
iterations::Int = 20
) -> Model
```
**Parameters:**
* `base_model` - Pre-trained model to calibrate
* `calib_data::BCIData` - Calibration data with labels
* `iterations::Int` - Number of calibration iterations (default: 20)
**Hyperparameters preserved (v0.2.0+)**: `calibrate_model()` automatically uses the same hyperparameters (`dof_offset`, `mean_prior_precision`, etc.) as the base model. You cannot override them during calibration.
**Example:**
```julia theme={null}
base_model = load_model(NimbusLDA, "motor_imagery_baseline_v1")
personalized_model = calibrate_model(base_model, calib_data; iterations=20)
# The personalized model inherits all hyperparameters from base_model
```
## Data Structures
### BCIData
Container for BCI data with features, metadata, and optional labels.
```julia theme={null}
struct BCIData
features::Array{Float64, 3} # (n_features × n_samples × n_trials)
metadata::BCIMetadata
labels::Union{Nothing, Vector{Int}} # Optional labels for training/evaluation
end
```
**Example:**
```julia theme={null}
data = BCIData(
csp_features, # (16, 250, 20) - 16 features, 250 samples, 20 trials
BCIMetadata(...),
labels # [1, 2, 3, 4, 1, 2, ...] - trial labels (1-indexed)
)
```
### BCIMetadata
Metadata describing the BCI data properties.
```julia theme={null}
struct BCIMetadata
sampling_rate::Float64 # Hz (e.g., 250.0)
paradigm::Symbol # :motor_imagery, :p300, :ssvep, :erp
feature_type::Symbol # :csp, :bandpower, :time_domain, :frequency_domain, :raw_filtered
n_features::Int # Number of features (e.g., 16 for CSP)
n_classes::Int # Number of classes (e.g., 4)
chunk_size::Union{Nothing, Int} # For streaming: samples per chunk
temporal_aggregation::Symbol # :mean, :median, :logvar, :none
end
```
**Example:**
```julia theme={null}
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = nothing, # Batch mode
temporal_aggregation = :logvar # For CSP features in MI
)
```
## Inference Functions
### predict\_batch()
Perform batch inference on multiple trials.
```julia theme={null}
predict_batch(
model,
data::BCIData;
iterations::Int = 10
) -> BatchResult
```
**Parameters:**
* `model` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
* `data::BCIData` - Data to predict (labels optional)
* `iterations::Int` - Number of inference iterations (default: 10)
**Returns:**
```julia theme={null}
struct BatchResult
predictions::Vector{Int} # Predicted class for each trial
confidences::Vector{Float64} # Confidence (max posterior) for each trial
posteriors::Matrix{Float64} # Full posterior distributions (n_trials × n_classes)
free_energy::Union{Float64, Nothing} # Mean RxInfer free energy if available
entropy::Vector{Float64} # Shannon entropy per trial (bits)
mean_entropy::Float64 # Average entropy across trials
mahalanobis_distances::Matrix{Float64} # Distances to each class center (n_trials × n_classes)
outlier_scores::Vector{Float64} # Minimum distance to any class (per trial)
latency_ms::Int # Total batch latency in milliseconds
per_trial_latency_ms::Vector{Float64} # Latency per trial in milliseconds
balance::Float64 # Class distribution balance (0–1)
confidence_calibration::Union{CalibrationMetrics, Nothing} # Calibration metrics if labels available
end
```
**Example:**
```julia theme={null}
results = predict_batch(model, data)
println("Predictions: ", results.predictions)
println("Mean confidence: ", mean(results.confidences))
# Calculate accuracy if labels available
accuracy = sum(results.predictions .== data.labels) / length(data.labels)
println("Accuracy: $(round(accuracy * 100, digits=1))%")
```
### init\_streaming()
Initialize a streaming session for chunk-by-chunk inference.
```julia theme={null}
init_streaming(model, metadata::BCIMetadata) -> StreamingSession
```
**Parameters:**
* `model` - Loaded model
* `metadata::BCIMetadata` - Metadata with `chunk_size` set
**Returns:** `StreamingSession` for processing chunks
**Example:**
```julia theme={null}
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = 250 # 1 second chunks at 250 Hz
)
session = init_streaming(model, metadata)
```
### process\_chunk()
Process a single chunk of data during streaming.
```julia theme={null}
process_chunk(session::StreamingSession, chunk::Array{Float64, 2}; iterations::Int = 10) -> ChunkResult
```
**Parameters:**
* `session::StreamingSession` - Active streaming session
* `chunk::Array{Float64, 2}` - Chunk data (n\_features × chunk\_size)
* `iterations::Int` - Number of inference iterations for this chunk (default: 10)
**Returns:** `ChunkResult`
```julia theme={null}
struct ChunkResult
prediction::Int # Predicted class for this chunk
confidence::Float64 # Confidence for this chunk
posterior::Vector{Float64} # Posterior distribution for this chunk
latency_ms::Float64 # Processing time for this chunk (ms)
end
```
**Example:**
```julia theme={null}
for chunk in eeg_stream
result = process_chunk(session, chunk)
println("Prediction: $(result.prediction), Confidence: $(result.confidence)")
end
```
### finalize\_trial()
Finalize a trial by aggregating results from all processed chunks.
```julia theme={null}
finalize_trial(
session::StreamingSession;
method::Symbol = :weighted_vote,
temporal_weighting::Bool = true,
label::Union{Int, Nothing} = nothing
) -> StreamingResult
```
**Parameters:**
* `session::StreamingSession` - Active streaming session
* `method::Symbol` - Aggregation method (`:weighted_vote`, `:max_confidence`, `:posterior_mean`, `:unanimous`)
* `temporal_weighting::Bool` - Apply paradigm-specific temporal weights (default: true)
* `label::Union{Int, Nothing}` - Optional ground-truth label for calibration metrics
**Returns:** `StreamingResult` with final prediction and diagnostics
```julia theme={null}
struct StreamingResult
prediction::Int # Aggregated prediction
confidence::Float64 # Aggregated confidence
posterior::Vector{Float64} # Aggregated posterior
chunk_posteriors::Vector{Vector{Float64}} # Per-chunk posterior distributions
entropy::Float64 # Entropy of final posterior (bits)
aggregation_method::Symbol # Aggregation method used
n_chunks::Int # Number of chunks in trial
latency_ms::Float64 # Total latency (ms)
chunk_latencies_ms::Vector{Float64} # Latency per chunk
balance::Float64 # Class distribution balance across chunks
confidence_calibration::Union{CalibrationMetrics, Nothing} # Calibration metrics if label provided
end
```
**Example:**
```julia theme={null}
# Process trial
for chunk in trial_chunks
process_chunk(session, chunk)
end
# Get final prediction
final_result = finalize_trial(session; method=:weighted_vote, temporal_weighting=true)
println("Final prediction: $(final_result.prediction)")
println("Confidence: $(final_result.confidence)")
println("Aggregation method: $(final_result.aggregation_method)")
```
## Utility Functions
### calculate\_ITR()
Calculate Information Transfer Rate (ITR) in bits/minute.
```julia theme={null}
calculate_ITR(
accuracy::Float64,
n_classes::Int,
trial_duration::Float64;
clip_negative::Bool = false
) -> Float64
```
**Parameters:**
* `accuracy::Float64` - Classification accuracy (0.0 to 1.0)
* `n_classes::Int` - Number of classes
* `trial_duration::Float64` - Trial duration in seconds
* `clip_negative::Bool` - If true, negative ITR values (below chance) are clipped to 0.0
**Example:**
```julia theme={null}
accuracy = 0.85
n_classes = 4
trial_duration = 4.0 # seconds
itr = calculate_ITR(accuracy, n_classes, trial_duration)
println("ITR: $(round(itr, digits=1)) bits/minute")
```
### should\_reject\_trial()
Check if a trial should be rejected based on confidence threshold.
```julia theme={null}
should_reject_trial(confidence::Float64, threshold::Float64 = 0.7) -> Bool
```
### assess\_trial\_quality()
Assess the quality of inference results.
```julia theme={null}
assess_trial_quality(result::BatchResult) -> TrialQuality
```
**Returns:**
```julia theme={null}
struct TrialQuality
overall_score::Float64
confidence_acceptable::Bool
recommendation::String
end
```
### diagnose\_preprocessing()
Diagnose preprocessing quality and provide recommendations.
```julia theme={null}
diagnose_preprocessing(data::BCIData) -> PreprocessingReport
```
**Returns:**
```julia theme={null}
struct PreprocessingReport
errors::Vector{String}
warnings::Vector{String}
recommendations::Vector{String}
quality_score::Float64
end
```
**Example:**
```julia theme={null}
report = diagnose_preprocessing(data)
if !isempty(report.errors)
@error "Preprocessing issues: $(report.errors)"
end
println("Quality score: $(round(report.quality_score * 100, digits=1))%")
```
### compute\_fisher\_score()
Compute Fisher discriminant scores for feature selection.
```julia theme={null}
compute_fisher_score(features::Matrix{Float64}, labels::Vector{Int}) -> Vector{Float64}
```
**Parameters:**
* `features::Matrix{Float64}` - Feature matrix (n\_trials × n\_features)
* `labels::Vector{Int}` - Class labels
**Returns:** Fisher scores for each feature (higher scores indicate better discriminability)
**Example:**
```julia theme={null}
# Compute discriminability of each feature
fisher_scores = compute_fisher_score(features, labels)
# Find most discriminative features
for (i, score) in enumerate(fisher_scores)
println("Feature $i: Fisher score = $(round(score, digits=3))")
end
```
### rank\_features\_by\_discriminability()
Rank features by their discriminability using Fisher scores.
```julia theme={null}
rank_features_by_discriminability(features::Matrix{Float64}, labels::Vector{Int}) -> Vector{Int}
```
**Parameters:**
* `features::Matrix{Float64}` - Feature matrix (n\_trials × n\_features)
* `labels::Vector{Int}` - Class labels
**Returns:** Indices of features sorted by discriminability (most discriminative first)
**Example:**
```julia theme={null}
# Get feature ranking
ranked_indices = rank_features_by_discriminability(features, labels)
println("Most discriminative features:")
for (i, idx) in enumerate(ranked_indices[1:5])
println("$i. Feature $idx (Fisher score: $(round(fisher_scores[idx], digits=3)))")
end
```
### aggregate\_chunks()
Aggregate chunk-level predictions and confidences into a final trial result.
```julia theme={null}
aggregate_chunks(
predictions::Vector{Int},
confidences::Vector{Float64},
n_classes::Int;
posteriors::Union{Vector{Vector{Float64}}, Nothing} = nothing,
method::Symbol = :weighted_vote
) -> NamedTuple
```
**Parameters:**
* `predictions::Vector{Int}` - Predictions from each chunk
* `confidences::Vector{Float64}` - Confidences from each chunk
* `n_classes::Int` - Number of classes
* `posteriors` - Optional full posterior distributions from each chunk
* `method::Symbol` - Aggregation method (`:weighted_vote`, `:max_confidence`, `:posterior_mean`, `:unanimous`)
**Returns:** Named tuple with `prediction`, `confidence`, and `posterior`
**Example:**
```julia theme={null}
# Aggregate chunk results
final_result = aggregate_chunks(
chunk_predictions,
chunk_confidences,
4; # 4 classes
method = :weighted_vote
)
println("Final prediction: $(final_result.prediction)")
println("Final confidence: $(round(final_result.confidence, digits=3))")
```
### get\_paradigm\_defaults()
Get default parameters for a specific BCI paradigm.
```julia theme={null}
get_paradigm_defaults(paradigm::Symbol) -> NamedTuple
```
**Parameters:**
* `paradigm::Symbol`: Paradigm name (`:motor_imagery`, `:p300`, `:ssvep`, `:erp`)
**Returns:** Named tuple with default parameters for the paradigm
**Example:**
```julia theme={null}
# Get motor imagery defaults
defaults = get_paradigm_defaults(:motor_imagery)
println("Chunk size: $(defaults.chunk_size) samples")
println("Confidence threshold: $(defaults.confidence_threshold)")
println("Aggregation method: $(defaults.aggregation_method)")
# Use in metadata
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = defaults.chunk_size,
temporal_aggregation = :logvar
)
```
### Model Utility Functions
#### get\_n\_features()
Get the number of features expected by a model.
```julia theme={null}
get_n_features(model::BCIModel) -> Int
```
**Parameters:**
* `model` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
**Returns:** Number of features expected by the model
**Example:**
```julia theme={null}
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
n_features = get_n_features(model) # e.g., 16
```
#### get\_n\_classes()
Get the number of classes a model can classify.
```julia theme={null}
get_n_classes(model::BCIModel) -> Int
```
**Parameters:**
* `model` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
**Returns:** Number of classes the model can classify
**Example:**
```julia theme={null}
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
n_classes = get_n_classes(model) # e.g., 4
```
#### get\_paradigm()
Get the target paradigm for a model.
```julia theme={null}
get_paradigm(model::BCIModel) -> Union{Symbol, Nothing}
```
**Parameters:**
* `model` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
**Returns:** Paradigm symbol (`:motor_imagery`, `:p300`, etc.) or `nothing` if paradigm-agnostic
**Example:**
```julia theme={null}
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
paradigm = get_paradigm(model) # :motor_imagery
```
#### list\_available\_models()
List available models from the Nimbus model registry based on your license.
```julia theme={null}
list_available_models(; paradigm=nothing, model_type=nothing) -> Vector{Dict}
```
**Parameters:**
* `paradigm` - Optional filter by BCI paradigm (`:motor_imagery`, `:p300`, `:ssvep`)
* `model_type` - Optional filter by model type (`:NimbusLDA`, `:NimbusQDA`, `:NimbusProbit`)
**Returns:** Vector of model information dictionaries with keys: `name`, `version`, `type`, `paradigm`, `n_features`, `n_classes`, `requires_license`
**Example:**
```julia theme={null}
using NimbusSDKCore
# List all available models
all_models = list_available_models()
# Filter by paradigm
mi_models = list_available_models(paradigm=:motor_imagery)
# Filter by model type
lda_models = list_available_models(model_type=:NimbusLDA)
# Print model information
for model in all_models
println("$(model.name): $(model.type) - $(model.paradigm)")
end
```
#### get\_model\_info()
Get detailed information about a specific model from the registry.
```julia theme={null}
get_model_info(model_name::String) -> Union{Dict, Nothing}
```
**Parameters:**
* `model_name::String` - Name of the model to look up
**Returns:** Model information dictionary or `nothing` if not found
**Example:**
```julia theme={null}
info = get_model_info("motor_imagery_4class_v1")
if !isnothing(info)
println("Model: $(info.name)")
println("Features: $(info.n_features)")
println("Classes: $(info.n_classes)")
end
```
#### check\_model\_license\_compatibility()
Check if your current license allows access to a specific model.
```julia theme={null}
check_model_license_compatibility(model_name::String) -> Bool
```
**Parameters:**
* `model_name::String` - Name of the model to check
**Returns:** `true` if your license allows access, `false` otherwise
**Example:**
```julia theme={null}
if check_model_license_compatibility("motor_imagery_4class_v1")
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
else
@warn "Your license does not allow access to this model"
end
```
## Data Validation
### validate\_data()
Validate BCI data for common issues before inference.
```julia theme={null}
validate_data(data::BCIData) -> Bool
```
**Description:**
Validates data for NaN/Inf values, correct dimensions, and provides warnings for suspicious data patterns.
**Returns:** `true` if validation passes
**Throws:** Error if validation fails
**Example:**
```julia theme={null}
# Validate data before inference
try
validate_data(data)
println("✓ Data validation passed")
results = predict_batch(model, data)
catch e
if isa(e, DataValidationError)
@error "Data validation failed: $(error_msg(e))"
else
rethrow(e)
end
end
```
### validate\_chunk()
Validate a single chunk for streaming inference.
```julia theme={null}
validate_chunk(chunk::Matrix{Float64}, metadata::BCIMetadata) -> Bool
```
**Description:**
Validates chunk dimensions and data quality for streaming inference.
### check\_model\_compatibility()
Check if a model is compatible with your data.
```julia theme={null}
check_model_compatibility(model::BCIModel, data::BCIData) -> Bool
```
**Parameters:**
* `model` - `NimbusLDA`, `NimbusQDA`, or `NimbusProbit`
* `data::BCIData` - Data to check compatibility with
**Returns:** `true` if model and data are compatible
**Example:**
```julia theme={null}
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
data = BCIData(features, metadata, labels)
if check_model_compatibility(model, data)
results = predict_batch(model, data)
else
@error "Model and data are incompatible"
end
```
## Feature Normalization
**Critical for cross-session BCI!** EEG amplitude varies 50-200% across sessions. Proper normalization improves accuracy by 15-30%.
Feature normalization is essential for BCI models used across different sessions or subjects. NimbusSDK provides comprehensive normalization utilities.
### NormalizationParams
Storage for normalization parameters.
```julia theme={null}
struct NormalizationParams
method::Symbol
means::Vector{Float64}
stds::Vector{Float64}
mins::Vector{Float64}
maxs::Vector{Float64}
medians::Vector{Float64}
mads::Vector{Float64}
computed_from_n_trials::Int
end
```
**Fields:**
* `method` - Normalization method (`:zscore`, `:minmax`, `:robust`, `:none`)
* `means`, `stds` - Per-feature statistics for z-score normalization
* `mins`, `maxs` - Per-feature statistics for min-max normalization
* `medians`, `mads` - Per-feature statistics for robust normalization
* `computed_from_n_trials` - Number of trials used to compute statistics
### estimate\_normalization\_params()
Estimate normalization parameters from training data.
```julia theme={null}
estimate_normalization_params(
features::Array{Float64, 3};
method::Symbol = :zscore
) -> NormalizationParams
```
**Parameters:**
* `features::Array{Float64, 3}` - Training features (n\_features × n\_samples × n\_trials)
* `method::Symbol` - Normalization method
* `:zscore` - Z-score normalization (mean=0, std=1) \[default, recommended]
* `:minmax` - Min-max scaling to \[0, 1]
* `:robust` - Robust normalization using median and MAD (outlier-resistant)
* `:none` - No normalization
**Returns:** `NormalizationParams` object with computed statistics
**Example:**
```julia theme={null}
# Compute normalization params from training data
train_features = randn(16, 250, 100)
norm_params = estimate_normalization_params(train_features; method=:zscore)
# Save params with model for consistent test-time normalization
@save "model.jld2" model norm_params
```
Normalization should be computed AFTER feature extraction but BEFORE model training. The same normalization parameters must be applied to test data.
### apply\_normalization()
Apply pre-computed normalization to features.
```julia theme={null}
apply_normalization(
features::Array{Float64, 3},
params::NormalizationParams
) -> Array{Float64, 3}
```
**Parameters:**
* `features::Array{Float64, 3}` - Features to normalize (n\_features × n\_samples × n\_trials)
* `params::NormalizationParams` - Pre-computed normalization parameters
**Returns:** Normalized features with same shape as input
**Example:**
```julia theme={null}
# Training phase
train_features = randn(16, 250, 100)
norm_params = estimate_normalization_params(train_features; method=:zscore)
train_normalized = apply_normalization(train_features, norm_params)
# Test phase (using same parameters)
test_features = randn(16, 250, 20)
test_normalized = apply_normalization(test_features, norm_params)
```
### normalize\_features()
Convenience function to estimate and apply normalization in one call.
```julia theme={null}
normalize_features(
features::Array{Float64, 3};
method::Symbol = :zscore
) -> Array{Float64, 3}
```
**Parameters:**
* `features::Array{Float64, 3}` - Features with shape (n\_features × n\_samples × n\_trials)
* `method::Symbol` - Normalization method (`:zscore`, `:minmax`, `:robust`, `:none`)
**Returns:** Normalized features with same shape as input
This function computes normalization parameters from the input data itself. For proper train/test separation, use `estimate_normalization_params()` and `apply_normalization()` separately.
**Example:**
```julia theme={null}
features = randn(16, 250, 100)
normalized = normalize_features(features; method=:zscore)
```
### check\_normalization\_status()
Check if features appear normalized and get recommendations.
```julia theme={null}
check_normalization_status(
features::Array{Float64, 3};
tolerance::Float64 = 0.1
) -> NamedTuple
```
**Parameters:**
* `features::Array{Float64, 3}` - Features to check (n\_features × n\_samples × n\_trials)
* `tolerance::Float64` - Tolerance for detecting unnormalized data
**Returns:** Named tuple with:
* `appears_normalized::Bool` - Whether data appears normalized
* `mean_abs_mean::Float64` - Mean absolute value of per-feature means
* `mean_std::Float64` - Mean of per-feature standard deviations
* `recommendations::Vector{String}` - Suggested actions
**Example:**
```julia theme={null}
features = randn(16, 250, 100)
status = check_normalization_status(features)
println("Normalized: ", status.appears_normalized)
if !status.appears_normalized
println("Recommendations:")
for rec in status.recommendations
println(" • ", rec)
end
end
```
### Normalization Best Practices
**Correct Workflow:**
```julia theme={null}
# 1. Estimate params from TRAINING data only
train_features = csp_features_train # (16 × 250 × 80)
norm_params = estimate_normalization_params(train_features; method=:zscore)
# 2. Apply to BOTH training and test data
train_norm = apply_normalization(train_features, norm_params)
test_norm = apply_normalization(test_features, norm_params)
# 3. Save params with your model
@save "model_with_norm.jld2" model norm_params
# 4. Later: Load and apply same params
@load "model_with_norm.jld2" model norm_params
new_data_norm = apply_normalization(new_data, norm_params)
```
**Common Pitfalls:**
❌ **Never** normalize train and test separately\
❌ **Never** normalize raw EEG (do it after features)\
❌ **Never** forget to save normalization params
See [Feature Normalization](/inference-configuration/feature-normalization) for the recommended train/test scaling workflow.
## Performance Metrics
### BCIPerformanceMetrics
Container for BCI performance metrics.
```julia theme={null}
struct BCIPerformanceMetrics
accuracy::Float64 # Classification accuracy (0–1)
information_transfer_rate::Float64 # ITR in bits/minute
false_positive_rate::Float64 # Average FPR across classes
false_negative_rate::Float64 # Average FNR across classes
mean_confidence::Float64 # Average confidence across trials
mean_trial_duration::Float64 # Trial duration in seconds
selection_rate::Float64 # Successful selections per minute
end
```
### OnlinePerformanceTracker
Track performance metrics in real-time with a sliding window.
```julia theme={null}
struct OnlinePerformanceTracker
predictions::Vector{Int}
true_labels::Vector{Int}
confidences::Vector{Float64}
timestamps::Vector{DateTime}
window_size::Int
end
OnlinePerformanceTracker(window_size::Int = 100) -> tracker
```
**Parameters:**
* `window_size::Int` - Number of recent trials to include in metrics (default: 100)
**Example:**
```julia theme={null}
tracker = OnlinePerformanceTracker(window_size=50)
for (pred, true_lbl, conf) in zip(results.predictions, data.labels, results.confidences)
metrics = update_and_report!(tracker, pred, true_lbl, conf)
println("Running accuracy: $(round(metrics.accuracy * 100, digits=1))%")
end
full_metrics = get_metrics(tracker, n_classes=4, trial_duration=4.0)
println("ITR: $(full_metrics.information_transfer_rate) bits/min")
```
#### update\_and\_report!()
Update the tracker with a new prediction and return current metrics.
```julia theme={null}
update_and_report!(
tracker::OnlinePerformanceTracker,
prediction::Int,
true_label::Int,
confidence::Float64
) -> BCIPerformanceMetrics
```
**Parameters:**
* `tracker::OnlinePerformanceTracker` - Tracker to update
* `prediction::Int` - Predicted class label
* `true_label::Int` - True class label
* `confidence::Float64` - Prediction confidence
**Returns:** `BCIPerformanceMetrics` computed over the sliding window
#### get\_metrics()
Get comprehensive performance metrics from the tracker.
```julia theme={null}
get_metrics(
tracker::OnlinePerformanceTracker,
n_classes::Int,
trial_duration::Float64
) -> BCIPerformanceMetrics
```
**Parameters:**
* `tracker::OnlinePerformanceTracker` - Tracker to query
* `n_classes::Int` - Number of classes
* `trial_duration::Float64` - Trial duration in seconds
**Returns:** `BCIPerformanceMetrics` with all computed metrics
### Calibration Metrics
#### compute\_balance()
Compute class distribution balance (how evenly distributed classes are).
```julia theme={null}
compute_balance(predictions::Vector{Int}, n_classes::Int) -> Float64
```
**Parameters:**
* `predictions::Vector{Int}` - Predicted class labels
* `n_classes::Int` - Number of classes
**Returns:** Balance score (0.0 = completely imbalanced, 1.0 = perfectly balanced)
**Example:**
```julia theme={null}
balance = compute_balance(results.predictions, 4)
println("Class balance: $(round(balance, digits=3))")
```
#### compute\_calibration\_metrics()
Compute confidence calibration metrics (ECE/MCE).
```julia theme={null}
compute_calibration_metrics(
confidences::Vector{Float64},
predictions::Vector{Int},
true_labels::Vector{Int};
n_bins::Int = 10
) -> CalibrationMetrics
```
**Parameters:**
* `confidences::Vector{Float64}` - Prediction confidences
* `predictions::Vector{Int}` - Predicted class labels
* `true_labels::Vector{Int}` - True class labels
* `n_bins::Int` - Number of bins for calibration (default: 10)
**Returns:** `CalibrationMetrics` struct with `ece`, `mce`, `n_bins`
**Example:**
```julia theme={null}
cal_metrics = compute_calibration_metrics(
results.confidences,
results.predictions,
data.labels
)
println("ECE: $(round(cal_metrics.ece, digits=3))")
println("MCE: $(round(cal_metrics.mce, digits=3))")
```
#### CalibrationMetrics
Container for calibration metrics.
```julia theme={null}
struct CalibrationMetrics
ece::Float64 # Expected Calibration Error (0-1, lower is better)
mce::Float64 # Maximum Calibration Error (0-1, lower is better)
n_bins::Int # Number of bins used
end
```
### Enhanced Diagnostic Functions
#### compute\_entropy()
Compute Shannon entropy for a single probability distribution.
```julia theme={null}
compute_entropy(probabilities::Vector{Float64}) -> Float64
```
**Parameters:**
* `probabilities::Vector{Float64}` - Probability distribution (must sum to 1.0)
**Returns:** Entropy in bits (0 = certain, log₂(n\_classes) = maximum uncertainty)
**Example:**
```julia theme={null}
# Compute entropy for a single trial
entropy = compute_entropy(results.posteriors[1, :])
println("Trial entropy: $(round(entropy, digits=3)) bits")
```
#### compute\_mean\_entropy()
Compute mean entropy across multiple probability distributions.
```julia theme={null}
compute_mean_entropy(posteriors::Matrix{Float64}) -> Float64
```
**Parameters:**
* `posteriors::Matrix{Float64}` - Posterior distributions (n\_trials × n\_classes)
**Returns:** Mean entropy in bits
**Example:**
```julia theme={null}
mean_entropy = compute_mean_entropy(results.posteriors)
println("Mean entropy: $(round(mean_entropy, digits=3)) bits")
```
#### compute\_mahalanobis\_distances()
Compute Mahalanobis distances from each trial to each class center.
```julia theme={null}
compute_mahalanobis_distances(
model::BCIModel,
features::Array{Float64, 3}
) -> Matrix{Float64}
```
**Parameters:**
* `model` - `NimbusLDA` or `NimbusQDA` (NimbusProbit returns zeros)
* `features::Array{Float64, 3}` - Feature array (n\_features × n\_samples × n\_trials)
**Returns:** Distance matrix (n\_trials × n\_classes)
**Example:**
```julia theme={null}
distances = compute_mahalanobis_distances(model, data.features)
println("Distances shape: $(size(distances))")
```
#### compute\_outlier\_scores()
Compute outlier scores (minimum distance to any class) for each trial.
```julia theme={null}
compute_outlier_scores(
model::BCIModel,
features::Array{Float64, 3}
) -> Vector{Float64}
```
**Parameters:**
* `model` - `NimbusLDA` or `NimbusQDA`
* `features::Array{Float64, 3}` - Feature array (n\_features × n\_samples × n\_trials)
**Returns:** Outlier scores (higher = more outlier-like)
**Example:**
```julia theme={null}
outlier_scores = compute_outlier_scores(model, data.features)
outliers = findall(outlier_scores .> 5.0)
println("Outlier trials: $outliers")
```
## Error Handling
Common exceptions:
* `AuthenticationError` - Invalid or expired API key
* `DataValidationError` - Invalid data format or dimensions
* `ModelCompatibilityError` - Model incompatible with data
* `QuotaExceededError` - API quota limit exceeded
```julia theme={null}
try
results = predict_batch(model, data)
catch e
if isa(e, DataValidationError)
@error "Data validation failed" error_msg(e)
elseif isa(e, AuthenticationError)
@error "Authentication failed - check API key"
else
@error "Inference failed" e
end
end
```
## Configuration
### Model Compatibility
Check if a model is compatible with your data:
```julia theme={null}
check_model_compatibility(model::BCIModel, data::BCIData) -> Bool
```
**Note:** See the [Data Validation](#check_model_compatibility) section for details on `check_model_compatibility()`.
## Next Read
Detailed documentation for Bayesian LDA
Detailed documentation for Bayesian QDA
Detailed documentation for NimbusProbit
How to prepare your EEG data
Efficient offline batch inference
Real-time chunk-by-chunk processing
Complete working examples
## Support
* **Email**: [hello@nimbusbci.com](mailto:hello@nimbusbci.com)
* **Documentation**: [https://docs.nimbusbci.com](https://docs.nimbusbci.com)
* **GitHub**: [https://github.com/nimbusbci/NimbusSDK.jl](https://github.com/nimbusbci/NimbusSDK.jl)
* **Issues**: [https://github.com/nimbusbci/NimbusSDK.jl/issues](https://github.com/nimbusbci/NimbusSDK.jl/issues)
# Julia SDK Quickstart
Source: https://docs.nimbusbci.com/julia-sdk/quickstart
Get started with NimbusSDK.jl from API key setup to first BCI inference, including installation, authentication, and production-ready basics.
Build your first brain-computer interface application in just a few steps. This guide will take you from setup to running inference on neural data in under 10 minutes.
**Choose Your SDK:**
This page covers the **Julia SDK** (NimbusSDK.jl). If you're using Python, see the [Python SDK Quickstart](/python-sdk/quickstart) instead.
* 🐍 **Python SDK**: sklearn-compatible, works with MNE-Python, fully local → [Python Quickstart](/python-sdk/quickstart)
* ⚡ **Julia SDK**: RxInfer.jl-based, maximum performance, requires API key → Continue below
## Prerequisites
Before you begin, make sure you have:
* **Julia 1.9+** installed on your system
* **Basic understanding** of EEG data and BCI concepts
* **Preprocessed features** (CSP, bandpower, etc.) - see [preprocessing requirements](/inference-configuration/preprocessing-requirements)
**Important**: NimbusSDK.jl expects **preprocessed features**, not raw EEG data. You must apply bandpass filtering, artifact removal, and feature extraction (e.g., CSP) before using the SDK.
## Step 1: Get Your API Key
Contact us at **[hello@nimbusbci.com](mailto:hello@nimbusbci.com)** to request your API key. Include your use case and expected volume.
API keys are typically issued within 24 hours. Include details about your BCI application, expected data volume, and timeline in your request.
## Step 2: Install NimbusSDK.jl
NimbusSDK.jl is now available in the public Julia General Registry:
```julia theme={null}
using Pkg
# Install the public wrapper (no private registry needed!)
Pkg.add("NimbusSDK")
```
After installing the wrapper, install the proprietary core with your API key:
```julia theme={null}
using NimbusSDK
# One-time setup: Install the commercial core
NimbusSDK.install_core("your-api-key-here")
```
**What changed?** NimbusSDK.jl is now a public wrapper package. No more private registry setup! Just `Pkg.add("NimbusSDK")` and then install the core with your API key.
The core installation is persistent. You only need to run `install_core()` once per machine. After that, `using NimbusSDK` will work in any Julia project.
## Step 3: Your First BCI Inference
Create a new Julia script and run your first BCI inference:
```julia theme={null}
using NimbusSDK
using Statistics
# Step 1: One-time setup with your API key
NimbusSDK.install_core("your-api-key-here")
# Step 2: Load a pre-trained NimbusLDA model
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
println("✓ Model loaded: $(get_n_features(model)) features, $(get_n_classes(model)) classes")
# Step 3: Prepare your preprocessed CSP features
# Shape: (n_features × n_samples × n_trials)
# Example: 16 CSP features × 250 samples × 20 trials
n_features = 16
n_samples = 250 # 1 second at 250 Hz
n_trials = 20
# Generate sample CSP features (in practice, load from your preprocessing)
features = randn(n_features, n_samples, n_trials)
labels = rand(1:4, n_trials) # Random labels for demo
# Create BCIData with metadata
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = n_features,
n_classes = 4,
chunk_size = nothing # Batch mode
)
data = BCIData(features, metadata, labels)
# Step 4: Run batch inference
println("Running batch inference...")
results = predict_batch(model, data; iterations=10)
# Step 5: Analyze results
println("\n✓ Results:")
println(" Mean confidence: $(round(mean(results.confidences), digits=3))")
println(" Accuracy: $(round(100 * sum(results.predictions .== labels) / length(labels), digits=1))%")
println(" ITR: $(round(calculate_ITR(
sum(results.predictions .== labels) / length(labels),
4,
4.0
), digits=1)) bits/minute")
```
### Required Format for Features
NimbusSDK expects features in a specific format:
* **Dimensions**: `(n_features × n_samples × n_trials)`
* **Feature types**: CSP (recommended), bandpower, ERP amplitudes
* **Preprocessing**: Must be applied before using SDK
**Critical**: You must preprocess your EEG data before using NimbusSDK. The SDK does NOT process raw EEG - it requires extracted features. See [Preprocessing Guide](/inference-configuration/preprocessing-requirements) for details.
## Step 4: Training Your Own Model
Train a custom NimbusLDA model on your labeled data:
```julia theme={null}
using NimbusSDK
using Statistics
# One-time setup: Install core with your API key
NimbusSDK.install_core("your-api-key")
# Prepare training data (features already preprocessed!)
train_features = load_your_csp_features() # (n_features × n_samples × n_trials)
train_labels = load_your_labels() # Vector of integers: 1, 2, 3, 4, ...
# IMPORTANT: Normalize features for cross-session performance
# This is critical if you plan to use the model on new sessions
norm_params = estimate_normalization_params(train_features; method=:zscore)
train_features_norm = apply_normalization(train_features, norm_params)
train_data = BCIData(
train_features_norm, # Use normalized features
BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = nothing, # Batch mode
temporal_aggregation = :logvar # For CSP features in MI
),
train_labels # Required for training!
)
# Train the model
println("Training NimbusLDA model...")
model = train_model(
NimbusLDA,
train_data;
iterations = 50, # Number of inference iterations
showprogress = true, # Show training progress
name = "my_custom_model",
description = "4-class motor imagery with CSP"
)
# Save the trained model WITH normalization parameters
using JLD2
@save "my_custom_model.jld2" model norm_params
# Later: Load and use with same normalization
@load "my_custom_model.jld2" model norm_params
# Load your test features and apply the same normalization
test_features = load_your_test_features() # (n_features × n_samples × n_trials)
test_features_norm = apply_normalization(test_features, norm_params)
# Reuse the same metadata structure as training
test_data = BCIData(test_features_norm, train_data.metadata)
results = predict_batch(model, test_data)
```
**Feature Normalization is Critical!**
EEG amplitude varies 50-200% across sessions. Without normalization, accuracy drops 15-30% for cross-session BCI.
Always:
1. Estimate normalization params from training data
2. Apply same params to test/deployment data
3. Save params with your model
See [Feature Normalization](/inference-configuration/feature-normalization) for details.
**New in v0.2.0**: You can now fine-tune model hyperparameters (`dof_offset`, `mean_prior_precision`) to optimize performance for your specific dataset. See the [model documentation](/models/rxlda#hyperparameter-tuning-v020) for detailed tuning guidance.
## Step 5: Streaming Inference
For real-time applications, use streaming inference:
```julia theme={null}
using NimbusSDK
# One-time setup: Install core with your API key
NimbusSDK.install_core("your-api-key")
# Load model (core is already installed)
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
# Initialize streaming session (1 second chunks at 250 Hz)
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = 250, # 1 second chunks
temporal_aggregation = :logvar # For CSP features in MI
)
session = init_streaming(model, metadata)
# Process chunks as they arrive
for trial in your_eeg_stream()
# Process one chunk
chunk_result = process_chunk(session, trial)
println("Chunk prediction: $(chunk_result.prediction) " *
"(confidence: $(round(chunk_result.confidence, digits=3)))")
# Finalize trial with weighted voting across all chunks
if is_trial_complete(trial)
final_result = finalize_trial(session; method=:weighted_vote)
println("✓ Final prediction: $(final_result.prediction)")
# Check quality
if should_reject_trial(final_result.confidence, 0.7)
println("⚠️ Low confidence - trial rejected")
end
end
end
```
## Available Models
NimbusSDK includes Bayesian inference models for different BCI scenarios:
### Bayesian LDA (NimbusLDA)
**API Name**: `NimbusLDA`\
**Mathematical Model**: Pooled Gaussian Classifier (PGC)
* **Shared covariance** across classes
* Fast inference and training
* Best for well-separated classes
* Typical training time: 10-30 seconds (50 iterations, 100 trials)
### Bayesian QDA (NimbusQDA)
**API Name**: `NimbusQDA`\
**Mathematical Model**: Heteroscedastic Gaussian Classifier (HGC)
* **Class-specific covariances**
* More flexible, handles overlapping distributions
* Better for complex class structures
* Slightly slower than Bayesian LDA
### NimbusProbit (Julia only)
**API Name**: `NimbusProbit`\
**Mathematical Model**: Bayesian Multinomial Probit Regression
* **Continuous transition mapping** to latent space
* Most flexible for complex multinomial tasks
* Works in (K-1) dimensional space
* Ideal for advanced BCI applications
All models use **RxInfer.jl** for efficient variational Bayesian inference with reactive message passing.
## Next Read
Now that you have NimbusSDK running, explore these advanced features:
Complete SDK documentation with all functions and types
Critical guide for cross-session BCI performance (+15-30% accuracy)
Learn about the Bayesian LDA model in detail
Learn about the Bayesian QDA model in detail
Learn about NimbusProbit in detail
Adaptive model for non-stationary data (Python SDK only)
Critical guide on preparing your EEG data
Process multiple trials efficiently
Real-time chunk-by-chunk processing
## Common Use Cases
Control devices with imagined movements using CSP features
Communication interfaces using event-related potentials
High-speed visual BCI with steady-state responses
Real-time brain state monitoring and training
## Quickstart FAQ
Yes. Install the wrapper with `Pkg.add("NimbusSDK")`, then run `NimbusSDK.install_core("your-api-key")` once per machine.
NimbusSDK.jl expects preprocessed features rather than raw EEG. See [Preprocessing Requirements](/inference-configuration/preprocessing-requirements) and [Feature Normalization](/inference-configuration/feature-normalization).
Continue with the [Julia SDK API Reference](/julia-sdk/api-reference), [Batch Processing](/inference-configuration/batch-processing), and [Streaming Inference](/inference-configuration/streaming-inference).
## Troubleshooting
**API Key Issues:**
* Ensure your API key is set correctly
* Contact [hello@nimbusbci.com](mailto:hello@nimbusbci.com) if your key isn't working
**Data Format Issues:**
* Features must be in shape: `(n_features × n_samples × n_trials)`
* Ensure you've applied preprocessing (filtering, artifact removal, feature extraction)
* No NaN or Inf values in data
**Model Loading Issues:**
* Check that you have a valid model name
* Ensure your internet connection is working (for pre-trained models)
* Verify API key is authenticated
Need assistance? We're here to help:
* **Email**: [hello@nimbusbci.com](mailto:hello@nimbusbci.com) for technical support
* **Documentation**: Browse our comprehensive guides
* **Examples**: Check out working code samples in the SDK
* **API Documentation**: See [Julia SDK Reference](/julia-sdk/api-reference)
When reporting issues, please include:
* Your Julia version and OS
* The exact error message
* A minimal code example that reproduces the issue
* Your data shape and preprocessing steps
## Performance Tips
**Optimize for Production:**
* Use batch inference for multiple trials (more efficient than streaming)
* Implement proper error handling with try-catch blocks
* Monitor API usage and quotas
* Cache model information locally after loading
* Use appropriate confidence thresholds for your application
* Check preprocessing quality with `diagnose_preprocessing()`
Congratulations! 🎉 You've successfully set up NimbusSDK.jl and run your first brain-computer interface inference. You're now ready to build powerful BCI applications with sub-20ms latency and production-ready Bayesian inference.
# Julia Streaming Inference
Source: https://docs.nimbusbci.com/julia-sdk/streaming-inference
Configure local real-time BCI streaming with NimbusSDK.jl using chunk processing, trial finalization, and confidence-aware aggregation.
Streaming inference processes preprocessed feature chunks as they arrive from your BCI pipeline. It runs locally in `NimbusSDK.jl`; no network call is made during chunk inference.
For Python streaming, see [Python SDK Streaming Inference](/python-sdk/streaming-inference). For cross-SDK configuration guidance, see [Streaming Inference Configuration](/inference-configuration/streaming-inference).
## When To Use It
Use Julia streaming when your application needs low-latency feedback before a full trial is complete:
* Real-time motor imagery control
* Neurofeedback and training applications
* Assistive interfaces with confidence-based rejection
* Long-running monitoring sessions
The Julia SDK streaming API supports `NimbusLDA`, `NimbusQDA`, and `NimbusProbit`.
## Streaming Flow
```text theme={null}
EEG hardware -> preprocessing -> feature chunks -> NimbusSDK.jl -> predictions
```
Nimbus expects feature chunks, not raw EEG. Filtering, artifact handling, and feature extraction should happen before calling `process_chunk()`.
## Basic Setup
```julia theme={null}
using NimbusSDK
# One-time setup. The core can be cached for later use.
NimbusSDK.install_core("your-api-key")
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = 250
)
session = init_streaming(model, metadata)
```
## Process Chunks
Each chunk should be shaped `(n_features, chunk_size)`.
```julia theme={null}
for chunk in eeg_feature_stream
result = process_chunk(session, chunk; iterations=10)
println("Prediction: $(result.prediction)")
println("Confidence: $(round(result.confidence, digits=3))")
println("Posterior: $(result.posterior)")
end
```
## Finalize A Trial
After enough chunks have been processed for a trial, aggregate them into a final prediction:
```julia theme={null}
final = finalize_trial(session; method=:weighted_vote)
if should_reject_trial(final.confidence, 0.7)
@warn "Trial rejected" confidence=final.confidence
else
@info "Trial accepted" prediction=final.prediction confidence=final.confidence
end
```
Supported aggregation methods:
* `:weighted_vote`: weight chunk predictions by confidence.
* `:max_confidence`: use the prediction from the most confident chunk.
* `:posterior_mean`: average chunk-level posterior distributions.
* `:unanimous`: require all chunks to agree, with fallback behavior when they do not.
## Chunk Size Guidance
| Paradigm | Typical Chunk Size | Notes |
| ------------- | ------------------ | ------------------------------------------ |
| Motor imagery | 250-500 samples | Balances latency and accuracy at 250 Hz. |
| P300 | 100-200 samples | Short windows for event-related responses. |
| SSVEP | 500-1000 samples | Longer windows help frequency estimates. |
Smaller chunks reduce latency but can lower per-chunk confidence. Larger chunks improve evidence quality but delay feedback.
## Production Checklist
* Validate each chunk shape before calling `process_chunk()`.
* Warm up the model with a dummy chunk before the user session.
* Use a confidence threshold for high-stakes actions.
* Keep preprocessing and feature extraction deterministic between calibration and deployment.
* Reset or recreate sessions between independent trials when you do not want chunk history to carry over.
## Next Read
Acquisition and hardware setup guidance.
Complete Julia SDK function reference.
Offline trial-level inference patterns.
Feature preparation requirements before inference.
# Model Specification
Source: https://docs.nimbusbci.com/model-specification
Technical overview of Nimbus probabilistic models, including NimbusLDA, NimbusQDA, NimbusSoftmax, NimbusProbit, and adaptive NimbusSTS.
NimbusSDK provides **pre-built probabilistic models** powered by [RxInfer.jl](https://github.com/ReactiveBayes/RxInfer.jl), a reactive message passing framework for efficient Bayesian inference. In the Python SDK these models are also the **heads** behind `Personalizer(head=...)` — see [Personalizer & Middleware](/personalizer/overview).
**Nimbus Models Available:**
* [NimbusLDA](/models/rxlda) - Fast, shared covariance (**Python + Julia**); default `Personalizer(head="lda")`
* [NimbusQDA](/models/rxgmm) - Flexible, class-specific covariances (**Python + Julia**); `Personalizer(head="qda")`
* [NimbusSoftmax](/models/rxpolya) - Non-Gaussian static model (**Python only**); `Personalizer(head="softmax")`
* [NimbusProbit](/models/nimbusprobit) - Non-Gaussian static model (**Julia only**)
* [NimbusSTS](/models/rxsts) - Stateful adaptive model (**Python only**; classical / streaming path)
For detailed implementation, see the individual model pages above.
***
## Start Here
Python product path: frozen trunk → Bayesian head → BrainState.
Default Personalizer head (`head="lda"`) and fast classical baseline.
Class-specific covariance; `Personalizer(head="qda")`.
Adaptive stateful modeling for non-stationarity.
## Model Architecture
The static models (LDA, QDA, Softmax, Probit) are built on **factor graphs** with **reactive message passing**:
```
Prior Likelihood Posterior
p(class) ────────────────────────────────► p(class|data)
▲
│
p(data|class)
(Gaussian or Softmax)
```
### Factor Graphs
Factor graphs represent the joint probability distribution:
$$
p(\text{class}, \text{data}) = p(\text{class}) \cdot p(\text{data}|\text{class})
$$
**Components:**
1. **Prior**: $p(\text{class})$ - uniform or learned class probabilities
2. **Likelihood**: $p(\text{data}|\text{class})$ - Gaussian (LDA/QDA) or a non-Gaussian classifier (Softmax/Probit)
3. **Posterior**: $p(\text{class}|\text{data})$ - computed via message passing
### Reactive Message Passing
RxInfer.jl uses **reactive programming** for efficient inference:
```
Data Stream ──► Factor Graph ──► Message Passing ──► Posterior Updates
│
▼
(Incremental Updates)
```
**Benefits:**
* **Incremental processing**: Process data chunks as they arrive
* **Low latency**: 10-25ms per chunk
* **Memory efficient**: Constant memory usage
* **Real-time capable**: Streaming inference without buffering
## Model Comparison
| Feature | NimbusLDA | NimbusQDA | NimbusSoftmax (Py) | NimbusSTS (Py) |
| --------------------- | ------------------------- | ------------------------- | ------------------------- | --------------------------------------------- |
| **Covariance** | Shared across classes | Class-specific | N/A (logistic) | N/A (state-space) |
| **Decision Boundary** | Linear | Quadratic | Non-linear (flexible) | Dynamic (time-varying) |
| **Temporal Model** | Static | Static | Static | **Stateful (EKF)** |
| **Flexibility** | Lower | Higher | Highest | Adaptive |
| **Speed** | Fastest (10-15ms) | Fast (15-25ms) | Fast (15-25ms) | Real-time (20-30ms) |
| **Best For** | Motor imagery, stationary | P300, overlapping classes | Complex multinomial | **Non-stationary, long sessions** |
| **State Management** | None | None | None | **`propagate_state()`, `get_latent_state()`** |
| **Online Learning** | `partial_fit()` | `partial_fit()` | `partial_fit()` | **Continuous with delayed feedback** |
| **Use Case** | Short sessions | Short sessions | Short sessions | **Long sessions, cross-day, adaptive BCI** |
| **Training Time** | Fast | Moderate | Moderate | |
| **Parameters** | Fewer (efficient) | More (flexible) | Most (very flexible) | |
| **Best For** | Well-separated classes | Overlapping distributions | Complex multinomial tasks | |
| **Overfitting Risk** | Lowest | Moderate | Higher (more parameters) | |
| **Data Requirements** | 40+ trials/class | 60+ trials/class | 80+ trials/class | |
***
## BCI Paradigm Applications
### Motor Imagery
**Recommended Model**: Bayesian LDA (NimbusLDA)
Motor imagery classes are typically well-separated in CSP feature space, making NimbusLDA ideal:
* **2-class** (left/right hand): 75-90% accuracy
* **4-class** (hands/feet/tongue): 70-85% accuracy
* **Inference**: 10-15ms per trial
* **ITR**: 15-25 bits/minute
**Why NimbusLDA?**
* Fast inference for real-time control
* Shared covariance assumption holds well
* Lowest data requirements
See [Basic Examples - Motor Imagery](/examples/basic-examples#motor-imagery) for implementation.
### P300 Event-Related Potential
**Recommended Model**: Bayesian QDA (NimbusQDA)
P300 target and non-target ERPs have overlapping distributions, requiring flexible modeling:
* **Binary detection**: 85-95% accuracy (with averaging)
* **Inference**: 15-25ms per epoch
* **ITR**: 10-20 bits/minute
**Why NimbusQDA?**
* Class-specific covariances capture ERP morphology
* Better for overlapping distributions
* Handles individual differences
See [Basic Examples - P300 Detection](/examples/basic-examples#p300-detection) for implementation.
### SSVEP (Steady-State Visual Evoked Potential)
**Recommended Model**: NimbusLDA or NimbusQDA
* **4-target**: 85-95% accuracy, use NimbusLDA
* **6+ target**: 80-90% accuracy, use NimbusQDA
* **Inference**: 10-20ms per trial
* **ITR**: 30-50 bits/minute
**Model Selection:**
* **NimbusLDA**: For 2-4 targets with well-separated frequencies
* **NimbusQDA**: For 6+ targets with overlapping harmonics
See [Advanced Applications](/examples/advanced-applications) for deployment patterns and model-combination guidance.
### Paradigm Comparison
| Paradigm | Recommended Model | Typical Accuracy | ITR | User Training |
| -------------------------- | ------------------------------------------ | -------------------------------- | -------------- | ------------- |
| **Motor Imagery** | NimbusLDA (or NimbusSTS for long sessions) | 70-85% (4-class) | 15-25 bits/min | High |
| **P300** | NimbusQDA | 85-95% (with reps) | 10-20 bits/min | Low |
| **SSVEP** | NimbusLDA / NimbusQDA | 85-95% (4-class) | 30-50 bits/min | Low |
| **Long-duration Adaptive** | **NimbusSTS (Py)** | **75-90% (maintains over time)** | **Varies** | **Medium** |
**New: Use NimbusSTS (Python-only)** for sessions >30 minutes or when you observe accuracy degradation over time. It's the only model that explicitly handles temporal drift and non-stationarity.
***
## Advanced Techniques
### Hyperparameter Optimization
Use grid search or Bayesian optimization to find optimal hyperparameters:
```python theme={null}
from sklearn.model_selection import GridSearchCV
from nimbus_bci import NimbusLDA
param_grid = {
'mu_scale': [1.0, 3.0, 5.0, 7.0],
'class_prior_alpha': [0.5, 1.0, 2.0]
}
grid = GridSearchCV(NimbusLDA(), param_grid, cv=5, n_jobs=-1)
grid.fit(X_train, y_train)
print(f"Best params: {grid.best_params_}")
best_clf = grid.best_estimator_
```
```julia theme={null}
# Grid search over hyperparameters
mean_prior_precisions = [0.001, 0.01, 0.05, 0.1]
best_accuracy = 0.0
best_params = nothing
for mean_prior_precision in mean_prior_precisions
model = train_model(
NimbusLDA,
train_data;
mean_prior_precision=mean_prior_precision
)
results = predict_batch(model, val_data)
accuracy = sum(results.predictions .== val_data.labels) / length(val_data.labels)
if accuracy > best_accuracy
best_accuracy = accuracy
best_params = (mean_prior_precision=mean_prior_precision,)
end
end
```
See [Python SDK - sklearn Integration](/python-sdk/sklearn-integration) for more tuning examples.
### Cross-Subject Transfer Learning
Train on multiple subjects for better generalization:
```python theme={null}
from nimbus_bci import NimbusLDA, estimate_normalization_params, apply_normalization
import numpy as np
# Collect data from multiple subjects
all_X = [load_subject_data(s) for s in subjects]
X_combined = np.vstack(all_X)
# Normalize across subjects
norm_params = estimate_normalization_params(X_combined, method="zscore")
X_norm = apply_normalization(X_combined, norm_params)
# Train with higher regularization
clf = NimbusLDA(mu_scale=7.0) # Higher than single-subject
clf.fit(X_norm, y_combined)
# Calibrate for new subject
clf.partial_fit(X_new_subject[:20], y_new[:20])
```
### Ensemble Methods
Combine multiple models for improved robustness:
```python theme={null}
from nimbus_bci import NimbusLDA, NimbusQDA
import numpy as np
# Train multiple models
clf_lda = NimbusLDA()
clf_lda.fit(X_train, y_train)
clf_qda = NimbusQDA()
clf_qda.fit(X_train, y_train)
# Ensemble prediction (weighted average)
probs_lda = clf_lda.predict_proba(X_test)
probs_qda = clf_qda.predict_proba(X_test)
ensemble_probs = 0.6 * probs_lda + 0.4 * probs_qda
ensemble_predictions = ensemble_probs.argmax(axis=1)
```
### Confidence Calibration
Ensure predicted probabilities match actual accuracy:
```python theme={null}
from sklearn.calibration import CalibratedClassifierCV
from nimbus_bci import NimbusLDA, compute_calibration_metrics
# Train and calibrate
clf = NimbusLDA()
clf.fit(X_train, y_train)
clf_calibrated = CalibratedClassifierCV(clf, method='isotonic', cv='prefit')
clf_calibrated.fit(X_val, y_val)
# Check calibration improvement
calib_metrics = compute_calibration_metrics(preds, confidences, y_test)
print(f"ECE: {calib_metrics.ece:.3f}")
```
***
## Model Limitations
**General Limitations:**
* **Static models**: No built-in temporal dynamics (use preprocessing for temporal features)
* **Supervised only**: Require labeled training data
* **Fixed structure**: Cannot modify factor graph structure at runtime
**Model-Specific:**
* **NimbusLDA**: Assumes Gaussian distributions with shared covariance, linear decision boundaries
* **NimbusQDA**: Assumes Gaussian distributions with class-specific covariances, higher overfitting risk with limited data
* **NimbusSoftmax / NimbusProbit**: May require more training data than LDA/QDA for complex tasks
## Troubleshooting
**Symptoms**: High training accuracy, low test accuracy
**Solutions**:
* Increase `mu_scale` (stronger regularization)
* Use cross-validation for hyperparameter tuning
* Collect more training data
* Apply ensemble methods
**Symptoms**: Good within-subject, poor across-subject performance
**Solutions**:
* Train on multi-subject data
* Increase regularization (`mu_scale`)
* Normalize features consistently
* Use subject-specific calibration
**Symptoms**: Model biased toward majority class
**Solutions**:
* Use class weighting
* Apply SMOTE or other resampling
* Adjust decision threshold
* Use stratified cross-validation
***
## Next Read
Fast, shared covariance classifier
Flexible, class-specific covariances
Adaptive, for non-stationary data
Non-Gaussian static classification
Non-Gaussian static classification
Compact Python and Julia recipes
Calibration, adaptation, hybrid systems
***
**Development Philosophy**: Nimbus provides **battle-tested, production-ready models** (NimbusLDA, NimbusQDA, NimbusSoftmax (Py), NimbusProbit (Jl), NimbusSTS (Py)) that are proven effective for BCI applications. These models cover the majority of BCI use cases with fast inference, uncertainty quantification, and online learning. NimbusSTS adds adaptive capabilities for non-stationary data and long-duration sessions.
# NimbusProbit (Julia)
Source: https://docs.nimbusbci.com/models/nimbusprobit
NimbusProbit for Julia: Bayesian multinomial probit classification with RxInfer, uncertainty quantification, and flexible non-Gaussian boundaries for BCI tasks.
**Julia**: `NimbusProbit` | **Python equivalent**: [`NimbusSoftmax`](/models/rxpolya)
**Mathematical model**: Bayesian multinomial probit regression
`NimbusProbit` is the Julia SDK's flexible non-Gaussian static classifier. Compared with Gaussian models (`NimbusLDA`, `NimbusQDA`), it can represent more complex decision boundaries while still returning posterior probabilities and uncertainty metrics.
**Availability**
* **Julia SDK**: ✅ `NimbusProbit`
* **Python SDK**: ❌ Use [`NimbusSoftmax`](/models/rxpolya) for Python's non-Gaussian static classifier
## Quick Start
```julia theme={null}
using NimbusSDK
# One-time setup
NimbusSDK.install_core("nbci_live_your_key")
# Train
model = train_model(
NimbusProbit,
train_data;
iterations = 50
)
# Batch inference
results = predict_batch(model, test_data)
accuracy = sum(results.predictions .== labels) / length(labels)
println("Accuracy: $(round(accuracy * 100, digits=1))%")
```
## When to Use NimbusProbit
* You are using the **Julia SDK** and need a flexible static classifier.
* `NimbusLDA` / `NimbusQDA` plateau on complex multi-class data.
* Class boundaries are non-Gaussian or not well modeled by class-conditional Gaussians.
* You need calibrated posterior probabilities from a probabilistic model.
## When Not to Use It
* If latency must be minimized: start with `NimbusLDA`, then `NimbusQDA`.
* If class centers and Mahalanobis distance are important: use `NimbusLDA` or `NimbusQDA`.
* If the task is non-stationary or drifting: use `NimbusSTS` in the Python SDK.
* If you are using Python: use [`NimbusSoftmax`](/models/rxpolya).
## Model Architecture
`NimbusProbit` is implemented with RxInfer and models a latent multinomial probit representation.
```julia theme={null}
struct NimbusProbit <: BCIModel
B_posterior # Learned regression coefficient posterior
W_posterior # Learned precision matrix posterior
metadata::ModelMetadata # Model info
N::Int # Trials per observation
ξβ::Vector{Float64} # Prior mean for B
Wβ::Matrix{Float64} # Prior precision for B
W_df::Float64 # Wishart degrees of freedom
W_scale::Matrix{Float64} # Wishart scale matrix
end
```
### RxInfer Learning Model
```julia theme={null}
@model function NimbusProbit_learning_model(obs, N, X, n_classes, n_features,
ξβ, Wβ, W_df, W_scale)
B ~ MvNormalWeightedMeanPrecision(ξβ, Wβ)
W ~ Wishart(W_df, W_scale)
for i in eachindex(obs)
Ψ[i] ~ ContinuousTransition(X[i], B, W)
obs[i] ~ MultinomialPolya(N, Ψ[i])
end
end
```
The public API hides the factor-graph details behind `train_model()` and `predict_batch()`.
## Hyperparameters
| Parameter | Default | Description |
| -------------- | --------------- | ------------------------------------------- |
| `iterations` | `50` | Variational inference iterations |
| `showprogress` | `false` | Display training progress |
| `N` | `1` | Number of trials per observation |
| `ξβ` | auto-configured | Prior mean for regression coefficients |
| `Wβ` | auto-configured | Prior precision for regression coefficients |
| `W_df` | auto-configured | Wishart degrees of freedom |
| `W_scale` | auto-configured | Wishart scale matrix |
## Train a Custom Model
```julia theme={null}
using NimbusSDK
metadata = BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = nothing
)
train_data = BCIData(train_features, metadata, train_labels)
model = train_model(
NimbusProbit,
train_data;
iterations = 50,
showprogress = true,
name = "motor_imagery_probit",
description = "4-class MI classifier with NimbusProbit"
)
```
## Tune Hyperparameters
Use stronger priors for noisy or limited data, and weaker priors for clean datasets with many trials.
```julia theme={null}
B_dim = (n_classes - 1) * n_features
model = train_model(
NimbusProbit,
train_data;
iterations = 50,
N = 1,
Wβ = 1e-5 * diageye(B_dim),
W_df = Float64(n_classes + 5),
showprogress = true
)
```
| Scenario | `Wβ` scale | `W_df` offset | Notes |
| ---------------------- | ---------------- | ------------- | ----------------------- |
| Excellent data quality | `1e-6` | `2` | Minimal regularization |
| Good data quality | `1e-5` | `5` | Balanced default |
| Moderate data quality | `1e-5` to `1e-4` | `5-8` | Slight regularization |
| Poor data quality | `1e-4` | `10` | Stronger regularization |
| Very limited trials | `1e-4` | `15` | Maximum regularization |
## Batch Inference
```julia theme={null}
test_data = BCIData(test_features, metadata, test_labels)
results = predict_batch(model, test_data; iterations = 10)
println("Predictions: ", results.predictions)
println("Mean confidence: ", mean(results.confidences))
accuracy = sum(results.predictions .== test_labels) / length(test_labels)
itr = calculate_ITR(accuracy, 4, 4.0)
println("Accuracy: $(round(accuracy * 100, digits=1))%")
println("ITR: $(round(itr, digits=1)) bits/minute")
```
## Streaming Inference
```julia theme={null}
session = init_streaming(model, metadata_with_chunk_size)
for chunk in eeg_feature_stream
result = process_chunk(session, chunk; iterations = 10)
println("Chunk: pred=$(result.prediction), conf=$(round(result.confidence, digits=3))")
end
final_result = finalize_trial(session; method = :weighted_vote)
println("Final: pred=$(final_result.prediction), conf=$(round(final_result.confidence, digits=3))")
```
## Training Requirements
* Use preprocessed features, not raw EEG.
* Normalize features before training for cross-session stability.
* Use enough trials for a flexible multinomial model; start with `NimbusLDA` / `NimbusQDA` for small datasets.
* Keep labels aligned with the Julia SDK's class convention for the dataset you are using.
## Model Inspection
```julia theme={null}
println("B posterior:")
println(" Type: ", typeof(model.B_posterior))
println(" Mean: ", mean(model.B_posterior))
println("\nW posterior:")
println(" Type: ", typeof(model.W_posterior))
println(" Mean: ", mean(model.W_posterior))
println("\nHyperparameters:")
println(" N: ", model.N)
println(" ξβ dimensions: ", size(model.ξβ))
println(" Wβ dimensions: ", size(model.Wβ))
println(" W_df: ", model.W_df)
println(" W_scale dimensions: ", size(model.W_scale))
```
## Model Selection Context
Use `NimbusProbit` when you are in Julia and need a flexible non-Gaussian static classifier. If you need faster inference or explicit class-center diagnostics, start with `NimbusLDA` or `NimbusQDA`. If you are using Python, the analogous non-Gaussian static model is `NimbusSoftmax`.
For the canonical side-by-side comparison, see [Model Specification](/model-specification).
## Next Read
Python's non-Gaussian static classifier.
Full Julia model and inference API.
Faster static model with shared covariance.
Static model with class-specific covariance.
## References
**Implementation:**
* RxInfer.jl: [https://rxinfer.com/](https://rxinfer.com/)
* Julia source code: `src/models/nimbus_probit/` in NimbusSDKCore
**Theory:**
* Bayesian multinomial probit regression
* Variational inference with reactive message passing
* Continuous-transition latent variable models for multinomial classification
# Bayesian QDA
Source: https://docs.nimbusbci.com/models/rxgmm
Bayesian QDA classifier with class-specific covariances for overlapping BCI classes, delivering uncertainty-aware inference in 15-25ms.
**Python**: `NimbusQDA` | **Julia**: `NimbusQDA`\
**Mathematical Model**: Heteroscedastic Gaussian Classifier (HGC)
NimbusQDA is a Bayesian classification model with **class-specific covariance matrices**, making it more flexible than NimbusLDA for modeling complex class distributions.
**Python Personalizer head:** use as `Personalizer(head="qda")`, or standalone as `NimbusQDA`. See [Personalizer & Middleware](/personalizer/overview).
**Available in Both SDKs:**
* **Python SDK**: `NimbusQDA` class (sklearn-compatible)
* **Julia SDK**: `NimbusQDA` (RxInfer.jl-based)
Both implementations provide class-specific covariances for flexible modeling.
## Start Here
Start with SDK setup and first inference workflow.
Compare Nimbus models by data characteristics and use case.
See practical BCI examples for training and inference.
## Overview
Bayesian QDA extends beyond traditional Gaussian classifiers by allowing each class to have its own covariance structure:
✅ **Class-specific covariances** (unlike Bayesian LDA's shared covariance)
✅ **More flexible modeling** of heterogeneous distributions
✅ **Posterior probability distributions** with uncertainty quantification
✅ **Fast inference** (\~15-25ms per trial)
✅ **Training and calibration** support
✅ **Batch and streaming** inference modes
## Quick Start
```python theme={null}
from nimbus_bci import NimbusQDA
import numpy as np
# Create and fit classifier
clf = NimbusQDA(mu_scale=3.0)
clf.fit(X_train, y_train)
# Predict with uncertainty
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
# Better for P300 and overlapping distributions
```
```julia theme={null}
using NimbusSDK
# Train model
model = train_model(
NimbusQDA,
train_data;
iterations=50
)
# Predict
results = predict_batch(model, test_data)
```
### When to Use Bayesian QDA
**Bayesian QDA is ideal for:**
* Complex, overlapping class distributions
* Classes with significantly different variances
* P300 detection (target/non-target with different spreads)
* When Bayesian LDA accuracy is unsatisfactory
* When you need maximum flexibility
**Use [Bayesian LDA](/models/rxlda) instead if:**
* Classes are well-separated and have similar spreads
* Speed is critical (Bayesian LDA is faster)
* Training data is limited (Bayesian LDA needs less data)
* Memory is constrained (Bayesian LDA uses less memory)
## Model Architecture
### Mathematical Foundation (Heteroscedastic Gaussian Classifier)
Bayesian QDA implements a Heteroscedastic Gaussian Classifier (HGC), which models class-conditional distributions with **class-specific precision matrices**:
```
p(x | y=k) = N(μ_k, W_k^-1)
```
Where:
* `μ_k` = mean vector for class k
* `W_k` = **class-specific** precision matrix (different for each class)
* Allows different covariance structures per class
**Key Difference from Bayesian LDA**: Each class can have its own covariance structure, making the model more flexible but also more parameter-heavy.
### Hyperparameters
Bayesian QDA supports configurable hyperparameters for optimal performance tuning:
**Available Hyperparameters (training):**
| Parameter | Type | Default | Range | Description |
| ---------------------- | ------- | ------- | ------------- | -------------------------------------------- |
| `dof_offset` | Int | 2 | \[1, 5] | Degrees of freedom offset for Wishart priors |
| `mean_prior_precision` | Float64 | 0.01 | \[0.001, 0.1] | Prior precision for class means |
**Parameter Effects:**
* **dof\_offset**: Controls regularization strength
* Lower values (1) → More data-driven, less regularization
* Higher values (3-5) → More regularization, more conservative
* **mean\_prior\_precision**: Controls prior strength on class means
* Lower values (0.001) → Weaker prior, trusts data more
* Higher values (0.05-0.1) → Stronger prior, more regularization
### Model Structure
```julia theme={null}
struct NimbusQDA <: BCIModel
mean_posteriors::Vector # MvNormal posteriors for class means
precision_posteriors::Vector # Wishart posteriors for class precisions
priors::Vector{Float64} # Empirical class priors
metadata::ModelMetadata # Model info
dof_offset::Int # Degrees of freedom offset (training)
mean_prior_precision::Float64 # Mean prior precision (training)
end
```
### RxInfer Implementation
**Learning Phase:**
```julia theme={null}
@model function NimbusQDA_learning_model(y, labels, n_features, n_classes, dof_offset, mean_prior_precision)
# Priors on class means
for k in 1:n_classes
m[k] ~ MvNormal(mean=zeros(n_features), precision=mean_prior_precision * I)
end
# Priors on class-specific precisions
for k in 1:n_classes
W[k] ~ Wishart(n_features + dof_offset, I) # Each class has its own W
end
# Likelihood
for i in eachindex(y)
k = labels[i]
y[i] ~ MvNormal(mean=m[k], precision=W[k]) # Class-specific precision
end
end
```
## Usage
### 1. Load Pre-trained Model
**Python SDK**: The Python SDK (`nimbus-bci`) trains models locally. See [Python SDK Quickstart](/python-sdk/quickstart) for training examples.
```python theme={null}
from nimbus_bci import NimbusQDA
import pickle
# Python SDK: Train locally, no model zoo
clf = NimbusQDA(mu_scale=3.0)
clf.fit(X_train, y_train)
# Save and load
with open("my_p300_qda.pkl", "wb") as f:
pickle.dump(clf, f)
with open("my_p300_qda.pkl", "rb") as f:
clf = pickle.load(f)
print(f"Model info: {clf.classes_} classes, {clf.n_features_in_} features")
```
```julia theme={null}
using NimbusSDK
# Authenticate
NimbusSDK.install_core("nbci_live_your_key")
# Load from Nimbus model zoo
model = load_model(NimbusQDA, "p300_qda_v1")
println("Model loaded:")
println(" Features: $(get_n_features(model))")
println(" Classes: $(get_n_classes(model))")
println(" Paradigm: $(get_paradigm(model))")
```
### 2. Train Custom Model
```python theme={null}
from nimbus_bci import NimbusQDA
import mne
import pickle
# Load P300 data
raw = mne.io.read_raw_fif("p300_oddball.fif", preload=True)
raw.filter(0.5, 10)
events = mne.find_events(raw)
event_id = {'target': 1, 'non-target': 2}
epochs = mne.Epochs(raw, events, event_id, tmin=-0.2, tmax=0.8, preload=True)
# Extract ERP features (mean amplitude in the P300 window)
data = epochs.get_data() # (n_epochs, n_channels, n_times)
times = epochs.times
p300_window = (times >= 0.25) & (times <= 0.5)
X = data[:, :, p300_window].mean(axis=2)
y = epochs.events[:, 2]
# Train NimbusQDA with default hyperparameters
clf = NimbusQDA()
clf.fit(X, y)
# Or train with custom hyperparameters
clf_tuned = NimbusQDA(
mu_scale=3.0, # Prior strength
class_prior_alpha=1.0
)
clf_tuned.fit(X, y)
# Save
with open("my_p300_qda.pkl", "wb") as f:
pickle.dump(clf_tuned, f)
print(f"Training accuracy: {clf_tuned.score(X, y):.1%}")
```
```julia theme={null}
using NimbusSDK
# Prepare training data with labels
train_features = erp_features # (8 × 200 × 150) - preprocessed P300 features
train_labels = [1, 2, 1, 2, ...] # 150 labels (1=target, 2=non-target)
train_data = BCIData(
train_features,
BCIMetadata(
sampling_rate = 250.0,
paradigm = :p300,
feature_type = :erp,
n_features = 8,
n_classes = 2,
chunk_size = nothing
),
train_labels # Required for training!
)
# Train NimbusQDA model with default hyperparameters
model = train_model(
NimbusQDA,
train_data;
iterations = 50, # Inference iterations
showprogress = true, # Show progress bar
name = "my_p300_qda",
description = "P300 binary classifier with NimbusQDA"
)
# Or train with custom hyperparameters (v0.2.0+)
model = train_model(
NimbusQDA,
train_data;
iterations = 50,
showprogress = true,
name = "my_p300_qda_tuned",
description = "P300 classifier with tuned hyperparameters",
dof_offset = 2, # DOF offset (default: 2)
mean_prior_precision = 0.01 # Prior precision (default: 0.01)
)
# Save for later use
save_model(model, "my_p300_qda.jld2")
```
**Training Parameters:**
* `iterations`: Number of variational inference iterations (default: 50)
* More iterations = better convergence, typical range: 50-100
* `showprogress`: Display progress bar during training
* `name`: Model identifier
* `description`: Model description
* `dof_offset`: Degrees of freedom offset (default: 2, range: \[1, 5])
* `mean_prior_precision`: Prior precision for means (default: 0.01, range: \[0.001, 0.1])
### 3. Subject-Specific Calibration
```python theme={null}
from nimbus_bci import NimbusQDA
import pickle
# Load baseline model
with open("p300_baseline.pkl", "rb") as f:
base_clf = pickle.load(f)
# Collect calibration trials (10-20 per class)
X_calib, y_calib = collect_calibration_trials()
# Personalize using online learning
personalized_clf = NimbusQDA()
personalized_clf.fit(X_baseline, y_baseline)
for _ in range(10):
personalized_clf.partial_fit(X_calib, y_calib)
# Save
with open("subject_001_p300_calibrated.pkl", "wb") as f:
pickle.dump(personalized_clf, f)
```
```julia theme={null}
# Load base model
base_model = load_model(NimbusQDA, "p300_baseline_v1")
# Collect calibration trials (10-20 per class)
calib_data = BCIData(calib_features, metadata, calib_labels)
# Calibrate model
personalized_model = calibrate_model(
base_model,
calib_data;
iterations = 20 # Fewer iterations needed
)
save_model(personalized_model, "subject_001_p300_calibrated.jld2")
```
**Calibration Benefits:**
* Requires only 10-20 trials per class
* Faster than training from scratch
* Adapts to subject-specific characteristics
* **Hyperparameters preserved**: `calibrate_model()` automatically uses the same hyperparameters as the base model (v0.2.0+)
### 4. Batch Inference
```python theme={null}
import numpy as np
# Run batch inference
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
confidences = np.max(probabilities, axis=1)
# Analyze results
print(f"Predictions: {predictions}")
print(f"Mean confidence: {np.mean(confidences):.3f}")
# Calculate metrics
accuracy = np.mean(predictions == y_test)
print(f"Accuracy: {accuracy * 100:.1f}%")
```
```julia theme={null}
# Prepare test data
test_data = BCIData(test_features, metadata, test_labels)
# Run batch inference
results = predict_batch(model, test_data; iterations=10)
# Analyze results
println("Predictions: ", results.predictions)
println("Mean confidence: ", mean(results.confidences))
# Calculate metrics
accuracy = sum(results.predictions .== test_labels) / length(test_labels)
println("Accuracy: $(round(accuracy * 100, digits=1))%")
```
### 5. Streaming Inference
For detailed Python streaming examples, see [Python SDK Streaming Inference](/python-sdk/streaming-inference).
```python theme={null}
from nimbus_bci import StreamingSession
# Initialize streaming session
session = StreamingSession(clf.model_, metadata_with_chunk_size)
# Process chunks
for chunk in eeg_feature_stream:
result = session.process_chunk(chunk)
print(f"Chunk: pred={result.prediction}, conf={result.confidence:.3f}")
# Finalize trial
final_result = session.finalize_trial()
print(f"Final: pred={final_result.prediction}, conf={final_result.confidence:.3f}")
session.reset()
```
```julia theme={null}
# Initialize streaming session
session = init_streaming(model, metadata_with_chunk_size)
# Process chunks
for chunk in eeg_feature_stream
result = process_chunk(session, chunk; iterations=10)
println("Chunk: pred=$(result.prediction), conf=$(round(result.confidence, digits=3))")
end
# Finalize trial
final_result = finalize_trial(session; method=:weighted_vote)
println("Final: pred=$(final_result.prediction), conf=$(round(final_result.confidence, digits=3))")
```
## Hyperparameter Tuning (v0.2.0+)
Fine-tune Bayesian QDA for optimal performance on your specific dataset.
### When to Tune Hyperparameters
Consider tuning when:
* Default performance is unsatisfactory
* You have specific data characteristics (very noisy or very clean)
* You have limited or extensive training data
* Working with complex, overlapping class distributions
* P300 or other paradigms with heterogeneous class variances
### Tuning Strategies
#### For High SNR / Clean Data / Many Trials
Use lower regularization to let the data drive the model:
```python theme={null}
from nimbus_bci import NimbusQDA
# Lower regularization for clean data
clf = NimbusQDA(
mu_scale=1.0, # Weaker prior
class_prior_alpha=0.5
)
clf.fit(X_train, y_train)
```
```julia theme={null}
model = train_model(
NimbusQDA,
train_data;
iterations = 50,
dof_offset = 1, # Less regularization
mean_prior_precision = 0.001 # Weaker prior, trust data more
)
```
**Use when:**
* SNR > 5 dB
* 100+ trials per class
* Clean, artifact-free data
* Well-controlled experimental conditions
#### For Low SNR / Noisy Data / Few Trials
Use higher regularization for stability (especially important for QDA with class-specific covariances):
```python theme={null}
from nimbus_bci import NimbusQDA
# Higher regularization for noisy data
clf = NimbusQDA(
mu_scale=5.0, # Stronger prior
class_prior_alpha=2.0
)
clf.fit(X_train, y_train)
```
```julia theme={null}
model = train_model(
NimbusQDA,
train_data;
iterations = 50,
dof_offset = 3, # More regularization
mean_prior_precision = 0.05 # Stronger prior
)
```
**Use when:**
* SNR \< 2 dB
* 40-80 trials per class
* Noisy data or limited artifact removal
* Challenging recording conditions
* Risk of overfitting to class-specific noise
#### Balanced / Default Settings
The defaults work well for most scenarios:
```julia theme={null}
model = train_model(
NimbusQDA,
train_data;
iterations = 50,
dof_offset = 2, # Balanced (default)
mean_prior_precision = 0.01 # Balanced (default)
)
```
**Use when:**
* Moderate SNR (2-5 dB)
* 80-150 trials per class
* Standard BCI recording conditions
* Starting point for experimentation
### P300-Specific Tuning
For P300 paradigms where target/non-target classes have different variances:
```julia theme={null}
# P300 often benefits from QDA's class-specific covariances
# Use moderate regularization to avoid overfitting
model = train_model(
NimbusQDA,
p300_train_data;
iterations = 50,
dof_offset = 2, # Standard regularization
mean_prior_precision = 0.02 # Slightly stronger than default
)
```
### Hyperparameter Search Example
Systematically search for optimal hyperparameters:
```julia theme={null}
using NimbusSDK
# Define search grid
dof_values = [1, 2, 3, 4]
prior_values = [0.001, 0.01, 0.03, 0.05]
# Split data
train_data, val_data = split_data(all_data, ratio=0.8)
best_accuracy = 0.0
best_params = nothing
println("Searching hyperparameters for NimbusQDA...")
for dof in dof_values
for prior in prior_values
# Train model
model = train_model(
NimbusQDA,
train_data;
iterations = 50,
dof_offset = dof,
mean_prior_precision = prior,
predictive_dof_offset = dof,
showprogress = false
)
# Validate
results = predict_batch(model, val_data)
accuracy = sum(results.predictions .== val_data.labels) / length(val_data.labels)
println(" dof=$dof, prior=$prior: $(round(accuracy*100, digits=1))%")
if accuracy > best_accuracy
best_accuracy = accuracy
best_params = (dof=dof, prior=prior)
end
end
end
println("\nBest hyperparameters:")
println(" dof_offset: $(best_params.dof)")
println(" mean_prior_precision: $(best_params.prior)")
println(" Validation accuracy: $(round(best_accuracy*100, digits=1))%")
# Retrain with best params
final_model = train_model(
NimbusQDA,
all_data;
iterations = 50,
dof_offset = best_params.dof,
mean_prior_precision = best_params.prior,
predictive_dof_offset = best_params.dof
)
```
### Quick Tuning Guidelines
| Scenario | `dof_offset` | `mean_prior_precision` | Notes |
| -------------------------- | ------------ | ---------------------- | -------------------------------------------- |
| **Excellent data quality** | 1 | 0.001 | Minimal regularization |
| **Good data quality** | 2 (default) | 0.01 (default) | Balanced approach |
| **Moderate data quality** | 2-3 | 0.01-0.03 | Slight regularization |
| **Poor data quality** | 3-4 | 0.05-0.1 | Strong regularization |
| **Very limited trials** | 4 | 0.1 | Maximum regularization |
| **P300 target/non-target** | 2 | 0.02 | Moderate, class-specific covariances helpful |
**Pro Tip**: Bayesian QDA's class-specific covariances can overfit to noise with poor data. When in doubt, start with defaults and increase regularization (`dof_offset=3`, `mean_prior_precision=0.03`) if you see overfitting.
**Important**: Always set `predictive_dof_offset` to match `dof_offset` for consistency between training and inference phases.
## Training Requirements
### Data Requirements
* **Minimum**: 40 trials per class (80 total for 2-class)
* **Recommended**: 80+ trials per class
* **For calibration**: 10-20 trials per class
**Bayesian QDA requires at least 2 observations per class** to estimate class-specific statistics. Training will fail if any class has fewer than 2 observations.
### Feature Normalization
**Critical for cross-session BCI performance!**
Normalize your features before training for 15-30% accuracy improvement across sessions.
```python theme={null}
from sklearn.preprocessing import StandardScaler
import pickle
# Estimate normalization from training data
scaler = StandardScaler()
X_train_norm = scaler.fit_transform(X_train)
# Train with normalized features
clf = NimbusQDA()
clf.fit(X_train_norm, y_train)
# Save model and scaler together
with open("model_with_scaler.pkl", "wb") as f:
pickle.dump({'model': clf, 'scaler': scaler}, f)
# Later: Apply same normalization to test data
X_test_norm = scaler.transform(X_test)
predictions = clf.predict(X_test_norm)
```
```julia theme={null}
# Estimate normalization from training data
norm_params = estimate_normalization_params(train_features; method=:zscore)
train_norm = apply_normalization(train_features, norm_params)
# Train with normalized features
train_data = BCIData(train_norm, metadata, labels)
model = train_model(NimbusQDA, train_data)
# Save params with model
@save "model.jld2" model norm_params
# Later: Apply same params to test data
test_norm = apply_normalization(test_features, norm_params)
```
See [Feature Normalization](/inference-configuration/feature-normalization) for the recommended train/test scaling workflow.
### Feature Requirements
Bayesian QDA expects **preprocessed features**, not raw EEG:
✅ **Required preprocessing:**
* Bandpass filtering (paradigm-specific)
* Artifact removal
* Feature extraction (CSP, ERP amplitude, bandpower, etc.)
* Proper temporal aggregation
❌ **NOT accepted:**
* Raw EEG channels
* Unfiltered data
See [Preprocessing Requirements](/inference-configuration/preprocessing-requirements).
## Performance Characteristics
### Computational Performance
| Operation | Latency | Notes |
| ------------------- | ----------------- | ----------------------------------- |
| **Training** | 15-40 seconds | 50 iterations, 100 trials per class |
| **Calibration** | 8-20 seconds | 20 iterations, 20 trials per class |
| **Batch Inference** | 15-25ms per trial | 10 iterations |
| **Streaming Chunk** | 15-25ms | 10 iterations per chunk |
Slightly slower than NimbusLDA due to class-specific covariances.
### Classification Accuracy
| Paradigm | Classes | Typical Accuracy | When to Use Bayesian QDA |
| ------------- | --------------------- | ---------------- | ------------------------------------------ |
| P300 | 2 (Target/Non-target) | 85-95% | Target/non-target have different variances |
| Motor Imagery | 2-4 | 70-85% | When Bayesian LDA accuracy insufficient |
| SSVEP | 2-6 | 85-98% | Complex frequency responses |
Bayesian QDA typically provides 2-5% higher accuracy than Bayesian LDA when class covariances differ significantly, at the cost of \~5-10ms additional latency.
## Model Inspection
### View Model Parameters
```python theme={null}
import numpy as np
params = clf.model_.params
# Posterior class means
print("Posterior class means:")
for k, class_label in enumerate(clf.classes_):
print(f" Class {class_label}: {params['mu'][k]}")
# Class-specific Normal-Wishart posterior parameters
print("\nClass-specific precision posterior parameters:")
for k, class_label in enumerate(clf.classes_):
print(f" Class {class_label}: nu={params['nu'][k]}, psi shape={params['psi'][k].shape}")
# Compare posterior scale diagonals across classes
print("\nPosterior scale diagonal comparison:")
for k, class_label in enumerate(clf.classes_):
print(f" Class {class_label}: {np.diag(params['psi'][k])}")
# Class priors
print("\nClass priors:")
priors = np.exp(params["log_priors"])
for k, class_label in enumerate(clf.classes_):
print(f" Class {class_label}: {priors[k]:.3f}")
```
```julia theme={null}
# Extract point estimates from posterior distributions
using Distributions
# Class means (extract from posterior distributions)
println("Class means:")
for (k, mean_posterior) in enumerate(model.mean_posteriors)
mean_point = mean(mean_posterior) # Extract point estimate
println(" Class $k: ", mean_point)
end
# Class-specific precisions (extract from posterior distributions)
println("\nClass-specific precision matrices:")
for (k, precision_posterior) in enumerate(model.precision_posteriors)
prec_point = mean(precision_posterior) # Extract point estimate
println(" Class $k (first 3x3):")
println(prec_point[1:3, 1:3])
end
# Compare covariances across classes
println("\nCovariance structure comparison:")
for k in 1:length(model.precision_posteriors)
prec_point = mean(model.precision_posteriors[k])
cov_k = inv(prec_point) # Convert precision to covariance
println(" Class $k variance (diagonal): ", diag(cov_k))
end
# Class priors
println("\nClass priors:")
for (k, prior) in enumerate(model.priors)
println(" Class $k: ", prior)
end
```
**Accessing model parameters**: The SDK stores full posterior distributions (not just point estimates) for proper Bayesian inference. To get point estimates, use `mean(posterior)` to extract the mean of the posterior distribution. For precision matrices, use `mean(precision_posterior)` to get the expected precision matrix.
### Visualize Class Differences
```julia theme={null}
using Plots
using Distributions
# Compare class covariances
for k in 1:length(model.precision_posteriors)
prec_point = mean(model.precision_posteriors[k]) # Extract point estimate
cov_k = inv(prec_point) # Convert precision to covariance
heatmap(cov_k, title="Class $k Covariance", colorbar=true)
end
```
## Advantages & Limitations
### Advantages
✅ **Flexible Modeling**: Each class has its own covariance\
✅ **Better for Complex Data**: Handles heterogeneous distributions\
✅ **Higher Accuracy**: 2-5% improvement when classes differ significantly\
✅ **Uncertainty Quantification**: Full Bayesian posteriors\
✅ **Production-Ready**: Battle-tested in P300 applications
### Limitations
❌ **More Parameters**: Requires more training data than NimbusLDA\
❌ **Slower Inference**: \~15-25ms vs \~10-15ms for NimbusLDA\
❌ **Higher Memory**: Stores n\_classes precision matrices\
❌ **More Complex**: Longer training time
## Model Selection Context
Use `NimbusQDA` when the task is stationary but classes have different spreads, covariance structure, or overlapping distributions. If speed and simple class structure matter more, start with `NimbusLDA`. If the boundary is non-Gaussian, consider `NimbusSoftmax` in Python or `NimbusProbit` in Julia. If class distributions drift over time, use `NimbusSTS` in Python.
For the canonical side-by-side comparison, see [Model Specification](/model-specification).
## Practical Examples
### P300 Detection
```julia theme={null}
# Bayesian QDA is ideal for P300 where target/non-target have different variances
# Train on P300 data
p300_data = BCIData(erp_features, p300_metadata, labels) # 1=target, 2=non-target
model = train_model(NimbusQDA, p300_data; iterations=50)
# Test
results = predict_batch(model, test_data)
# Calculate sensitivity/specificity
targets = test_labels .== 1
target_preds = results.predictions .== 1
sensitivity = sum(target_preds .& targets) / sum(targets)
specificity = sum(.!target_preds .& .!targets) / sum(.!targets)
println("Sensitivity: $(round(sensitivity * 100, digits=1))%")
println("Specificity: $(round(specificity * 100, digits=1))%")
```
### When Bayesian LDA Fails
```julia theme={null}
# Try Bayesian LDA first
rxlda_model = train_model(NimbusLDA, train_data)
rxlda_results = predict_batch(rxlda_model, test_data)
rxlda_acc = sum(rxlda_results.predictions .== test_labels) / length(test_labels)
# If accuracy insufficient, try Bayesian QDA
if rxlda_acc < 0.75
println("Bayesian LDA accuracy low ($(round(rxlda_acc*100, digits=1))%), trying Bayesian QDA...")
qda_model = train_model(NimbusQDA, train_data)
qda_results = predict_batch(qda_model, test_data)
qda_acc = sum(qda_results.predictions .== test_labels) / length(test_labels)
println("Bayesian QDA accuracy: $(round(qda_acc*100, digits=1))%")
println("Improvement: +$(round((qda_acc-rxlda_acc)*100, digits=1))%")
end
```
## Next Read
Faster model with shared covariance
Python non-Gaussian static classifier for complex decision boundaries
Julia non-Gaussian static classifier for complex decision boundaries
Adaptive model for non-stationary data
Complete training tutorial
Working examples
## References
**Implementation:**
* RxInfer.jl: [https://rxinfer.com/](https://rxinfer.com/)
* Source code: `NimbusQDA` model implementation in NimbusSDK.jl
**Theory:**
* Bishop, C. M. (2006). "Pattern Recognition and Machine Learning" (Quadratic discriminant analysis and Gaussian classifiers)
* Heteroscedastic Gaussian Classifier (HGC) with class-specific covariances
**BCI Applications:**
* Farwell, L. A., & Donchin, E. (1988). "Talking off the top of your head"
* Lotte et al. (2018). "A review of classification algorithms for EEG-based BCI"
# Bayesian LDA
Source: https://docs.nimbusbci.com/models/rxlda
Bayesian LDA with shared covariance for fast motor-imagery BCI inference (10-15ms), including calibrated uncertainty and posterior probabilities.
**Python**: `NimbusLDA` | **Julia**: `NimbusLDA`\
**Mathematical Model**: Pooled Gaussian Classifier (PGC)
Bayesian LDA is a Bayesian classification model with uncertainty quantification. It uses a **shared precision matrix** across all classes, making it fast and efficient for BCI classification.
**Python Personalizer head:** use as `Personalizer(head="lda")` (default), or standalone as `NimbusLDA`. See [Personalizer & Middleware](/personalizer/overview).
**Available in Both SDKs:**
* **Python SDK**: `NimbusLDA` class (sklearn-compatible)
* **Julia SDK**: `NimbusLDA` (RxInfer.jl-based)
Both implementations provide the same Bayesian inference with uncertainty quantification.
## Start Here
Start with SDK setup and first inference workflow.
Compare Nimbus models by data characteristics and use case.
See practical BCI examples for training and inference.
## Overview
Bayesian LDA extends traditional Linear Discriminant Analysis with full Bayesian inference, providing:
✅ **Posterior probability distributions** (not just point estimates)
✅ **Uncertainty quantification** for each prediction
✅ **Probabilistic confidence scores**
✅ **Fast inference** (\<20ms per trial)
✅ **Training and calibration** support
✅ **Batch and streaming** inference modes
## Quick Start
```python theme={null}
from nimbus_bci import NimbusLDA
import numpy as np
# Create and fit classifier
clf = NimbusLDA(mu_scale=3.0)
clf.fit(X_train, y_train)
# Predict with uncertainty
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
# Online learning
clf.partial_fit(X_new, y_new)
```
```julia theme={null}
using NimbusSDK
# Train model
model = train_model(
NimbusLDA,
train_data;
iterations=50
)
# Predict
results = predict_batch(model, test_data)
```
### When to Use Bayesian LDA
**Bayesian LDA is ideal for:**
* Motor Imagery classification (2-4 classes)
* Well-separated class distributions
* Fast inference requirements (\<20ms)
* Interpretable results for medical applications
* When classes have similar covariance structures
**Consider [Bayesian QDA](/models/rxgmm) instead if:**
* Classes have significantly different covariance structures
* Complex, overlapping distributions
* Need more flexibility in modeling per-class variances
## Model Architecture
### Mathematical Foundation (Pooled Gaussian Classifier)
Bayesian LDA implements a Pooled Gaussian Classifier (PGC), which models class-conditional distributions with a shared precision matrix:
```
p(x | y=k) = N(μ_k, W^-1)
```
Where:
* `μ_k` = mean vector for class k (learned from data)
* `W` = **shared** precision matrix (same for all classes)
* Assumes classes have similar covariance structure
**Key Assumption**: All classes share the same covariance structure, which makes training and inference faster.
### Hyperparameters
Bayesian LDA supports configurable hyperparameters for optimal performance tuning:
**Available Hyperparameters (training):**
| Parameter | Type | Default | Range | Description |
| ---------------------- | ------- | ------- | ------------- | ------------------------------------------- |
| `dof_offset` | Int | 2 | \[1, 5] | Degrees of freedom offset for Wishart prior |
| `mean_prior_precision` | Float64 | 0.01 | \[0.001, 0.1] | Prior precision for class means |
**Parameter Effects:**
* **dof\_offset**: Controls regularization strength
* Lower values (1) → More data-driven, less regularization
* Higher values (3-5) → More regularization, more conservative
* **mean\_prior\_precision**: Controls prior strength on class means
* Lower values (0.001) → Weaker prior, trusts data more
* Higher values (0.05-0.1) → Stronger prior, more regularization
### Model Structure
```julia theme={null}
struct NimbusLDA <: BCIModel
mean_posteriors::Vector # MvNormal posteriors for class means
precision_posterior::Any # Wishart posterior for shared precision
priors::Vector{Float64} # Empirical class priors
metadata::ModelMetadata # Model info
dof_offset::Int # Degrees of freedom offset (training)
mean_prior_precision::Float64 # Mean prior precision (training)
end
```
### RxInfer Implementation
The Bayesian LDA model uses RxInfer.jl for variational Bayesian inference:
**Learning Phase:**
```julia theme={null}
@model function NimbusLDA_learning_model(y, labels, n_features, n_classes, dof_offset, mean_prior_precision)
# Prior on shared precision
dof = n_features + dof_offset
W ~ Wishart(dof, I)
# Priors on class means
for k in 1:n_classes
m[k] ~ MvNormal(mean=zeros(n_features), precision=mean_prior_precision * I)
end
# Likelihood with known labels
for i in eachindex(y)
k = labels[i]
y[i] ~ MvNormal(mean=m[k], precision=W)
end
end
```
**Prediction Phase (Mixture Likelihood):**
Inference uses the learned posterior distributions as priors in a mixture model:
```julia theme={null}
@model function NimbusLDA_predictive(y, empirical_priors, n_features)
n_classes = length(empirical_priors)
# Class means and shared precision are initialized from trained posteriors.
local m
for k in 1:n_classes
m[k] ~ MvNormal(mean=zeros(n_features), precision=I)
end
local p
for k in 1:n_classes
p[k] ~ Wishart(n_features + 2, I)
end
z ~ Categorical(empirical_priors)
for i in eachindex(y)
y[i] ~ NormalMixture(switch = z, m = m, p = p)
end
end
```
The trained mean and precision posteriors are supplied through RxInfer initialization so uncertainty is carried into prediction.
## Usage
### 1. Load Pre-trained Model
**Python SDK**: The Python SDK (`nimbus-bci`) trains models locally and doesn't require pre-trained model loading. See [Python SDK Quickstart](/python-sdk/quickstart) for training examples.
```python theme={null}
from nimbus_bci import NimbusLDA
import pickle
# Python SDK: Train your own model locally
# No authentication or model zoo needed
# Quick training example
clf = NimbusLDA(mu_scale=3.0)
clf.fit(X_train, y_train)
# Save for later use
with open("my_motor_imagery.pkl", "wb") as f:
pickle.dump(clf, f)
# Load saved model
with open("my_motor_imagery.pkl", "rb") as f:
clf = pickle.load(f)
print("Model info:")
print(f" Classes: {clf.classes_}")
print(f" Features: {clf.n_features_in_}")
```
```julia theme={null}
using NimbusSDK
# Authenticate
NimbusSDK.install_core("nbci_live_your_key")
# Load from Nimbus model zoo
model = load_model(NimbusLDA, "motor_imagery_4class_v1")
println("Model loaded:")
println(" Features: $(get_n_features(model))")
println(" Classes: $(get_n_classes(model))")
println(" Paradigm: $(get_paradigm(model))")
```
### 2. Train Custom Model
```python theme={null}
from nimbus_bci import NimbusLDA
from nimbus_bci.compat import extract_csp_features
import mne
import pickle
# Load and preprocess EEG data
raw = mne.io.read_raw_gdf("motor_imagery.gdf", preload=True)
raw.filter(8, 30)
events = mne.find_events(raw)
event_id = {'left': 1, 'right': 2, 'feet': 3, 'tongue': 4}
epochs = mne.Epochs(raw, events, event_id, tmin=0, tmax=4, preload=True)
# Extract CSP features
csp_features, csp = extract_csp_features(epochs, n_components=8)
labels = epochs.events[:, 2]
# Train NimbusLDA model with default hyperparameters
clf = NimbusLDA()
clf.fit(csp_features, labels)
# Or train with custom hyperparameters
clf_tuned = NimbusLDA(
mu_scale=3.0, # Prior strength for means (default: 1.0)
class_prior_alpha=1.0
)
clf_tuned.fit(csp_features, labels)
# Save for later use
with open("my_motor_imagery.pkl", "wb") as f:
pickle.dump(clf_tuned, f)
print("Model trained successfully!")
print(f"Training accuracy: {clf_tuned.score(csp_features, labels):.1%}")
```
```julia theme={null}
using NimbusSDK
# Prepare training data with labels
train_features = csp_features # (16 × 250 × 100)
train_labels = [1, 2, 3, 4, 1, 2, ...] # 100 labels
train_data = BCIData(
train_features,
BCIMetadata(
sampling_rate = 250.0,
paradigm = :motor_imagery,
feature_type = :csp,
n_features = 16,
n_classes = 4,
chunk_size = nothing
),
train_labels # Required for training!
)
# Train NimbusLDA model with default hyperparameters
model = train_model(
NimbusLDA,
train_data;
iterations = 50, # Inference iterations
showprogress = true, # Show progress bar
name = "my_motor_imagery",
description = "4-class MI classifier with CSP"
)
# Or train with custom hyperparameters (v0.2.0+)
model = train_model(
NimbusLDA,
train_data;
iterations = 50,
showprogress = true,
name = "my_motor_imagery_tuned",
description = "4-class MI with tuned hyperparameters",
dof_offset = 2, # DOF offset (default: 2)
mean_prior_precision = 0.01 # Prior precision (default: 0.01)
)
# Save for later use
save_model(model, "my_model.jld2")
```
**Training Parameters:**
* `iterations`: Number of variational inference iterations (default: 50)
* More iterations = better convergence but slower training
* 50-100 is typically sufficient
* `showprogress`: Display progress bar during training
* `name`: Model identifier
* `description`: Model description for documentation
* `dof_offset`: Degrees of freedom offset (default: 2, range: \[1, 5])
* `mean_prior_precision`: Prior precision for means (default: 0.01, range: \[0.001, 0.1])
### 3. Subject-Specific Calibration
Fine-tune a pre-trained model with subject-specific data (much faster than training from scratch):
```python theme={null}
from nimbus_bci import NimbusLDA
import numpy as np
import pickle
# Load baseline model (trained on multiple subjects)
with open("motor_imagery_baseline.pkl", "rb") as f:
base_clf = pickle.load(f)
# Collect 10-20 calibration trials from new subject
X_calib, y_calib = collect_calibration_trials() # Your function
# Personalize using online learning (partial_fit)
personalized_clf = NimbusLDA()
personalized_clf.fit(X_baseline, y_baseline) # Start with baseline
# Fine-tune on calibration data
for _ in range(10): # Multiple passes for adaptation
personalized_clf.partial_fit(X_calib, y_calib)
# Save personalized model
with open("subject_001_calibrated.pkl", "wb") as f:
pickle.dump(personalized_clf, f)
print("Model personalized successfully!")
```
```julia theme={null}
# Load base model
base_model = load_model(NimbusLDA, "motor_imagery_baseline_v1")
# Collect 10-20 calibration trials from new subject
calib_features = collect_calibration_trials() # Your function
calib_labels = [1, 2, 3, 4, 1, 2, ...]
calib_data = BCIData(calib_features, metadata, calib_labels)
# Calibrate (personalize) the model
personalized_model = calibrate_model(
base_model,
calib_data;
iterations = 20 # Fewer iterations needed
)
save_model(personalized_model, "subject_001_calibrated.jld2")
```
**Calibration Benefits:**
* Requires only 10-20 trials per class (vs 50-100 for training from scratch)
* Faster: 20 iterations vs 50-100
* Better generalization: Uses pre-trained model as prior
* Typical accuracy improvement: 5-15% over generic model
* **Hyperparameters automatically preserved**: `calibrate_model()` inherits training hyperparameters from the base model (`dof_offset`, `mean_prior_precision`) ensuring consistency
**Important**: You don't need to specify hyperparameters when calibrating - they are automatically inherited from the base model. This ensures the calibrated model uses the same regularization strategy that worked well during initial training.
### 4. Batch Inference
Process multiple trials efficiently:
```python theme={null}
from nimbus_bci import NimbusLDA
import numpy as np
# Run batch inference
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
confidences = np.max(probabilities, axis=1)
# Analyze results
print(f"Predictions: {predictions}")
print(f"Mean confidence: {np.mean(confidences):.3f}")
# Calculate accuracy
accuracy = np.mean(predictions == y_test)
print(f"Accuracy: {accuracy * 100:.1f}%")
```
```julia theme={null}
# Prepare test data
test_data = BCIData(test_features, metadata, test_labels)
# Run batch inference
results = predict_batch(model, test_data; iterations=10)
# Analyze results
println("Predictions: ", results.predictions)
println("Mean confidence: ", mean(results.confidences))
# Calculate accuracy
accuracy = sum(results.predictions .== test_labels) / length(test_labels)
println("Accuracy: $(round(accuracy * 100, digits=1))%")
# Calculate ITR
itr = calculate_ITR(accuracy, 4, 4.0) # 4 classes, 4-second trials
println("ITR: $(round(itr, digits=1)) bits/minute")
```
### 5. Streaming Inference
Real-time chunk-by-chunk processing:
For detailed Python streaming examples, see [Python SDK Streaming Inference](/python-sdk/streaming-inference).
```python theme={null}
from nimbus_bci import StreamingSession
from nimbus_bci.data import BCIMetadata
# Configure metadata with chunk size
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=16,
n_classes=4,
chunk_size=250 # 1-second chunks
)
# Initialize streaming session
session = StreamingSession(clf.model_, metadata)
# Process chunks as they arrive
for chunk in eeg_feature_stream:
result = session.process_chunk(chunk)
print(f"Chunk: pred={result.prediction}, conf={result.confidence:.3f}")
# Finalize trial with aggregation
final_result = session.finalize_trial()
print(f"Final: pred={final_result.prediction}, conf={final_result.confidence:.3f}")
# Reset for next trial
session.reset()
```
```julia theme={null}
# Initialize streaming session
session = init_streaming(model, metadata_with_chunk_size)
# Process chunks as they arrive
for chunk in eeg_feature_stream
result = process_chunk(session, chunk; iterations=10)
println("Chunk: pred=$(result.prediction), conf=$(round(result.confidence, digits=3))")
end
# Finalize trial with aggregation
final_result = finalize_trial(session; method=:weighted_vote)
println("Final: pred=$(final_result.prediction), conf=$(round(final_result.confidence, digits=3))")
```
## Hyperparameter Tuning (v0.2.0+)
Fine-tune Bayesian LDA for optimal performance on your specific dataset.
### When to Tune Hyperparameters
Consider tuning when:
* Default performance is unsatisfactory
* You have specific data characteristics (very noisy or very clean)
* You have limited or extensive training data
* You want to optimize for your specific paradigm
### Tuning Strategies
#### For High SNR / Clean Data / Many Trials
Use lower regularization to let the data drive the model:
```python theme={null}
from nimbus_bci import NimbusLDA
# Lower regularization for clean data
clf = NimbusLDA(
mu_scale=1.0, # Weaker prior, trust data more
class_prior_alpha=0.5
)
clf.fit(X_train, y_train)
```
```julia theme={null}
model = train_model(
NimbusLDA,
train_data;
iterations = 50,
dof_offset = 1, # Less regularization
mean_prior_precision = 0.001 # Weaker prior, trust data more
)
```
**Use when:**
* SNR > 5 dB
* 100+ trials per class
* Clean, artifact-free data
* Well-controlled experimental conditions
#### For Low SNR / Noisy Data / Few Trials
Use higher regularization for stability:
```python theme={null}
from nimbus_bci import NimbusLDA
# Higher regularization for noisy data
clf = NimbusLDA(
mu_scale=5.0, # Stronger prior
class_prior_alpha=2.0
)
clf.fit(X_train, y_train)
```
```julia theme={null}
model = train_model(
NimbusLDA,
train_data;
iterations = 50,
dof_offset = 3, # More regularization
mean_prior_precision = 0.05 # Stronger prior
)
```
**Use when:**
* SNR \< 2 dB
* 40-80 trials per class
* Noisy data or limited artifact removal
* Challenging recording conditions
#### Balanced / Default Settings
The defaults work well for most scenarios:
```julia theme={null}
model = train_model(
NimbusLDA,
train_data;
iterations = 50,
dof_offset = 2, # Balanced (default)
mean_prior_precision = 0.01 # Balanced (default)
)
```
**Use when:**
* Moderate SNR (2-5 dB)
* 80-150 trials per class
* Standard BCI recording conditions
* Starting point for experimentation
### Hyperparameter Search Example
Systematically search for optimal hyperparameters:
```python theme={null}
from nimbus_bci import NimbusLDA
from sklearn.model_selection import GridSearchCV, train_test_split
# Define parameter grid
param_grid = {
'mu_scale': [1.0, 3.0, 5.0, 10.0],
'class_prior_alpha': [0.5, 1.0, 2.0]
}
# Split data
X_train_sub, X_val, y_train_sub, y_val = train_test_split(
X_train, y_train, test_size=0.2, stratify=y_train, random_state=42
)
print("Searching hyperparameters...")
clf = NimbusLDA()
grid_search = GridSearchCV(
clf,
param_grid,
cv=5,
scoring='accuracy',
verbose=1
)
grid_search.fit(X_train_sub, y_train_sub)
print("\nBest hyperparameters:")
print(f" mu_scale: {grid_search.best_params_['mu_scale']}")
print(f" class_prior_alpha: {grid_search.best_params_['class_prior_alpha']}")
print(f" CV accuracy: {grid_search.best_score_*100:.1f}%")
# Get best model
best_clf = grid_search.best_estimator_
# Retrain on all training data
best_clf.fit(X_train, y_train)
```
```julia theme={null}
using NimbusSDK
# Define search grid
dof_values = [1, 2, 3, 4]
prior_values = [0.001, 0.01, 0.05, 0.1]
# Split data into train/validation
train_data, val_data = split_data(all_data, ratio=0.8)
best_accuracy = 0.0
best_params = nothing
println("Searching hyperparameters...")
for dof in dof_values
for prior in prior_values
# Train model with these hyperparameters
model = train_model(
NimbusLDA,
train_data;
iterations = 50,
dof_offset = dof,
mean_prior_precision = prior,
showprogress = false
)
# Validate
results = predict_batch(model, val_data)
accuracy = sum(results.predictions .== val_data.labels) / length(val_data.labels)
println(" dof=$dof, prior=$prior: $(round(accuracy*100, digits=1))%")
# Track best
if accuracy > best_accuracy
best_accuracy = accuracy
best_params = (dof=dof, prior=prior)
end
end
end
println("\nBest hyperparameters:")
println(" dof_offset: $(best_params.dof)")
println(" mean_prior_precision: $(best_params.prior)")
println(" Validation accuracy: $(round(best_accuracy*100, digits=1))%")
# Retrain on all data with best hyperparameters
final_model = train_model(
NimbusLDA,
all_data;
iterations = 50,
dof_offset = best_params.dof,
mean_prior_precision = best_params.prior
)
```
### Quick Tuning Guidelines
| Scenario | `dof_offset` | `mean_prior_precision` | Notes |
| -------------------------- | ------------ | ---------------------- | ---------------------- |
| **Excellent data quality** | 1 | 0.001 | Minimal regularization |
| **Good data quality** | 2 (default) | 0.01 (default) | Balanced approach |
| **Moderate data quality** | 2-3 | 0.01-0.03 | Slight regularization |
| **Poor data quality** | 3-4 | 0.05-0.1 | Strong regularization |
| **Very limited trials** | 4 | 0.1 | Maximum regularization |
**Pro Tip**: Start with defaults (`dof_offset=2`, `mean_prior_precision=0.01`) and only tune if performance is unsatisfactory. The defaults are optimized for typical BCI scenarios.
## Training Requirements
### Data Requirements
* **Minimum**: 40 trials per class (160 total for 4-class)
* **Recommended**: 80+ trials per class (320+ total for 4-class)
* **For calibration**: 10-20 trials per class sufficient
**Bayesian LDA requires at least 2 trials** to estimate class statistics and shared precision matrix. Single-trial training is not statistically valid for LDA and will raise an `ArgumentError`.
Your training data must have shape `(n_features, n_samples, n_trials)` where `n_trials >= 2`.
### Feature Normalization
**Critical for cross-session BCI performance!**
Normalize your features before training for 15-30% accuracy improvement across sessions.
```python theme={null}
from sklearn.preprocessing import StandardScaler
import pickle
# Estimate normalization from training data
scaler = StandardScaler()
X_train_norm = scaler.fit_transform(X_train)
# Train with normalized features
clf = NimbusLDA()
clf.fit(X_train_norm, y_train)
# Save model and scaler together
with open("model_with_scaler.pkl", "wb") as f:
pickle.dump({'model': clf, 'scaler': scaler}, f)
# Later: Apply same normalization to test data
X_test_norm = scaler.transform(X_test)
predictions = clf.predict(X_test_norm)
```
```julia theme={null}
# Estimate normalization from training data
norm_params = estimate_normalization_params(train_features; method=:zscore)
train_norm = apply_normalization(train_features, norm_params)
# Train with normalized features
train_data = BCIData(train_norm, metadata, labels)
model = train_model(NimbusLDA, train_data)
# Save params with model
@save "model.jld2" model norm_params
# Later: Apply same params to test data
test_norm = apply_normalization(test_features, norm_params)
```
See [Feature Normalization](/inference-configuration/feature-normalization) for the recommended train/test scaling workflow.
### Feature Requirements
Bayesian LDA expects **preprocessed features**, not raw EEG:
✅ **Required preprocessing:**
* Bandpass filtering (8-30 Hz for motor imagery)
* Artifact removal (ICA recommended)
* Spatial filtering (CSP for motor imagery)
* Feature extraction (log-variance for CSP features)
* **Temporal aggregation** (handled automatically during training/inference)
❌ **NOT accepted:**
* Raw EEG channels
* Unfiltered data
* Non-extracted features
See [Preprocessing Requirements](/inference-configuration/preprocessing-requirements) for details.
### Temporal Aggregation
**Critical Preprocessing Step**: Before training, the SDK automatically aggregates the temporal dimension of each trial into a single feature vector. This prevents treating temporally correlated samples as independent observations, which would violate the i.i.d. assumption of the model.
The aggregation method depends on your feature type and paradigm:
* **CSP features**: Log-variance aggregation (default for motor imagery)
* **Power spectral features**: Mean or median aggregation
* **Other features**: Configurable via `BCIMetadata.temporal_aggregation`
This aggregation happens automatically during `train_model()` and `predict_batch()` calls.
## Performance Characteristics
### Computational Performance
| Operation | Latency | Notes |
| ------------------- | ----------------- | ----------------------------------- |
| **Training** | 10-30 seconds | 50 iterations, 100 trials per class |
| **Calibration** | 5-15 seconds | 20 iterations, 20 trials per class |
| **Batch Inference** | 10-20ms per trial | 10 iterations |
| **Streaming Chunk** | 10-20ms | 10 iterations per chunk |
All measurements on standard CPU (no GPU required).
### Classification Accuracy
| Paradigm | Classes | Typical Accuracy | ITR |
| ------------- | --------------------- | ---------------- | -------------- |
| Motor Imagery | 2 (L/R hand) | 75-90% | 15-25 bits/min |
| Motor Imagery | 4 (L/R/Feet/Tongue) | 70-85% | 20-35 bits/min |
| P300 | 2 (Target/Non-target) | 80-95% | 25-40 bits/min |
Accuracy is highly subject-dependent. Subject-specific calibration typically improves accuracy by 5-15%.
## Model Inspection
### View Model Parameters
```python theme={null}
import numpy as np
params = clf.model_.params
# Posterior class means
print("Posterior class means:")
for k, class_label in enumerate(clf.classes_):
print(f" Class {class_label}: {params['mu'][k]}")
# Shared Normal-Wishart posterior parameters
print("\nShared precision posterior:")
print(f" nu: {params['nu']}")
print(f" psi shape: {params['psi'].shape}")
# Model info
print("\nModel info:")
print(f" Features: {clf.n_features_in_}")
print(f" Classes: {clf.classes_}")
# Class priors (learned from data)
print("\nClass priors:")
priors = np.exp(params["log_priors"])
for k, class_label in enumerate(clf.classes_):
print(f" Class {class_label}: {priors[k]:.3f}")
```
```julia theme={null}
# Extract point estimates from posterior distributions
using Distributions
# Class means (extract from posterior distributions)
println("Class means:")
for (k, mean_posterior) in enumerate(model.mean_posteriors)
mean_point = mean(mean_posterior) # Extract point estimate
println(" Class $k: ", mean_point)
end
# Shared precision matrix (extract from posterior distribution)
println("\nShared precision matrix (first 3x3):")
precision_point = mean(model.precision_posterior) # Extract point estimate
println(precision_point[1:3, 1:3])
# Model metadata
println("\nMetadata:")
println(" Name: ", model.metadata.name)
println(" Paradigm: ", model.metadata.paradigm)
println(" Features: ", model.metadata.n_features)
println(" Classes: ", model.metadata.n_classes)
# Class priors
println("\nClass priors:")
for (k, prior) in enumerate(model.priors)
println(" Class $k: ", prior)
end
```
**Accessing model parameters**: The SDK stores full posterior distributions (not just point estimates) for proper Bayesian inference. To get point estimates, use `mean(posterior)` to extract the mean of the posterior distribution. For precision matrices, use `mean(precision_posterior)` to get the expected precision matrix.
### Compare Models
```python theme={null}
from nimbus_bci import NimbusLDA
import numpy as np
# Train multiple models with different hyperparameters and compare
models = []
for mu_scale in [1.0, 3.0, 5.0]:
clf = NimbusLDA(mu_scale=mu_scale)
clf.fit(X_train, y_train)
accuracy = clf.score(X_test, y_test)
print(f"mu_scale: {mu_scale}, Accuracy: {accuracy*100:.1f}%")
models.append((mu_scale, clf, accuracy))
# Select best model
best_config, best_clf, best_acc = max(models, key=lambda x: x[2])
print(f"\nBest configuration: mu_scale={best_config} ({best_acc*100:.1f}%)")
```
```julia theme={null}
# Train multiple models and compare
models = []
for n_iter in [20, 50, 100]
model = train_model(NimbusLDA, train_data; iterations=n_iter)
results = predict_batch(model, test_data)
accuracy = sum(results.predictions .== test_labels) / length(test_labels)
println("Iterations: $n_iter, Accuracy: $(round(accuracy*100, digits=1))%")
push!(models, (n_iter, model, accuracy))
end
```
## Advantages & Limitations
### Advantages
✅ **Fast Training**: Shared covariance estimation is efficient\
✅ **Fast Inference**: Analytical posterior computation (\<20ms)\
✅ **Interpretable**: Clear probabilistic formulation\
✅ **Memory Efficient**: Single shared precision matrix\
✅ **Robust**: Handles uncertainty naturally via Bayesian inference\
✅ **Production-Ready**: Battle-tested in real BCI applications
### Limitations
❌ **Shared Covariance Assumption**: May not fit well if classes have very different spreads\
❌ **Linear Decision Boundary**: Cannot capture non-linear class boundaries\
❌ **Gaussian Assumption**: Assumes normal class distributions\
❌ **Not Ideal for Overlapping Classes**: Use NimbusQDA for complex distributions
## Model Selection Context
Use `NimbusLDA` when classes are mostly stationary and well separated, and speed or interpretability matter. If classes overlap with different covariance structure, consider `NimbusQDA`. If the decision boundary is non-Gaussian, consider `NimbusSoftmax` in Python or `NimbusProbit` in Julia. If distributions drift over time, consider `NimbusSTS` in Python.
For the canonical side-by-side comparison, see [Model Specification](/model-specification).
## Next Read
More flexible classifier with class-specific covariances
Python non-Gaussian static classifier for complex decision boundaries
Julia non-Gaussian static classifier for complex decision boundaries
Adaptive model for non-stationary data and long sessions
Complete training walkthrough
Working examples
## References
**Implementation:**
* RxInfer.jl: [https://rxinfer.com/](https://rxinfer.com/)
* Source code: `src/models/nimbus_lda/` in NimbusSDKCore
**Theory:**
* Fisher, R. A. (1936). "The use of multiple measurements in taxonomic problems"
* Bishop, C. M. (2006). "Pattern Recognition and Machine Learning" (Chapter 4)
* Pooled Gaussian Classifier (PGC) with shared covariance structure
**BCI Applications:**
* Blankertz et al. (2008). "Optimizing spatial filters for robust EEG single-trial analysis"
* Lotte et al. (2018). "A review of classification algorithms for EEG-based BCI"
# NimbusSoftmax (Python)
Source: https://docs.nimbusbci.com/models/rxpolya
NimbusSoftmax for Python: Bayesian multinomial logistic regression with Polya-Gamma variational inference for non-Gaussian BCI decision boundaries.
**Python**: `NimbusSoftmax` | **Julia equivalent**: [`NimbusProbit`](/models/nimbusprobit)
**Mathematical model**: Bayesian multinomial logistic regression with Polya-Gamma variational inference
`NimbusSoftmax` is the Python SDK's flexible non-Gaussian static classifier. Use it when `NimbusLDA` and `NimbusQDA` are too restrictive, but you still want posterior uncertainty, sklearn compatibility, and active-learning support.
**Python Personalizer head:** use as `Personalizer(head="softmax")` (requires `pip install nimbus-bci[softmax]`), or standalone as `NimbusSoftmax`. See [Personalizer & Middleware](/personalizer/overview).
**Availability**
* **Python SDK**: ✅ `NimbusSoftmax` via the optional `softmax` extra
* **Julia SDK**: ❌ Use [`NimbusProbit`](/models/nimbusprobit) for Julia's non-Gaussian static classifier
## Install
`NimbusSoftmax` depends on the optional JAX-based softmax backend:
```bash theme={null}
pip install nimbus-bci[softmax]
```
## Quick Start
```python theme={null}
from nimbus_bci import NimbusSoftmax
clf = NimbusSoftmax(
w_scale=1.0,
num_steps=50,
num_posterior_samples=50,
)
clf.fit(X_train, y_train)
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
```
`NimbusSoftmax` expects preprocessed feature rows shaped `(n_trials, n_features)`, not raw EEG. Use CSP, ERP amplitude, bandpower, or your own feature extraction before fitting.
## When to Use NimbusSoftmax
* You are using the **Python SDK** and need a flexible static classifier.
* Class boundaries are non-Gaussian or not well represented by class-conditional means/covariances.
* You need uncertainty-aware outputs for rejection policies, active learning, or calibration analysis.
* `NimbusLDA` / `NimbusQDA` accuracy has plateaued on a complex multinomial task.
## When Not to Use It
* If latency is the top priority: start with `NimbusLDA`, then `NimbusQDA`.
* If class centers and Mahalanobis distance are important for interpretability or outlier diagnostics: use `NimbusLDA` or `NimbusQDA`.
* If the session is drifting over time: use `NimbusSTS`.
* If you are using Julia: use [`NimbusProbit`](/models/nimbusprobit).
## Model Architecture
`NimbusSoftmax` fits a Bayesian multinomial logistic regression model. It uses a reference-class parameterization, so one class has zero logits and the remaining classes are modeled relative to it.
```text theme={null}
beta_k ~ Normal(beta_mean_k, beta_cov_k)
logits = X_aug @ beta_mean.T
p(y = k | x) = softmax(logits)_k
```
The fitted model stores posterior Gaussian approximations for non-reference class weights. Predictions can draw posterior samples to quantify uncertainty.
## Hyperparameters
| Parameter | Default | Description |
| ----------------------- | ------- | ----------------------------------------------- |
| `w_loc` | `0.0` | Prior mean for feature weights |
| `w_scale` | `1.0` | Prior scale for feature weights |
| `b_loc` | `0.0` | Prior mean for bias terms |
| `b_scale` | `1.0` | Prior scale for bias terms |
| `learning_rate` | `0.2` | Damping factor for variational updates |
| `num_steps` | `50` | Number of coordinate-ascent update sweeps |
| `num_posterior_samples` | `50` | Posterior samples used for prediction |
| `rng_seed` | `0` | Random seed for reproducible posterior sampling |
## Usage
### Train and Predict
```python theme={null}
from nimbus_bci import NimbusSoftmax
clf = NimbusSoftmax(w_scale=1.0, num_steps=50)
clf.fit(X_train, y_train)
probs = clf.predict_proba(X_test)
preds = clf.predict(X_test)
```
### Tune with sklearn
```python theme={null}
from sklearn.model_selection import GridSearchCV, train_test_split
from nimbus_bci import NimbusSoftmax
param_grid = {
"w_scale": [0.5, 1.0, 2.0],
"num_steps": [50, 100],
}
grid = GridSearchCV(
NimbusSoftmax(),
param_grid,
cv=5,
scoring="accuracy",
)
grid.fit(X_train, y_train)
print(grid.best_params_)
```
### Online Updates
```python theme={null}
clf = NimbusSoftmax()
clf.fit(X_seed, y_seed)
for X_new, y_new in calibration_batches:
clf.partial_fit(X_new, y_new)
```
### Active Learning
`NimbusSoftmax` supports BALD through posterior predictive samples, so it can drive label-efficient calibration loops.
```python theme={null}
from nimbus_bci.active_learning import CalibrationSession
session = CalibrationSession(
clf,
X_pool,
pool_strategy="bald",
batch_size=4,
stopping_threshold=0.02,
num_posterior_samples=256,
)
ranked = session.suggest_next_trial()
global_indices = session.remaining_indices[ranked.indices]
y_new = collect_labels_for(global_indices)
session.update(ranked.indices, y_new)
status = session.calibration_sufficient()
```
See [Active Learning](/python-sdk/active-learning) for the full calibration workflow.
## Training Requirements
* **Minimum**: at least 2 observations are required.
* **Recommended**: 40+ trials per class for stable estimates.
* **Feature normalization**: strongly recommended for cross-session stability.
* **Input shape**: `(n_trials, n_features)`.
```python theme={null}
from sklearn.preprocessing import StandardScaler
from nimbus_bci import NimbusSoftmax
scaler = StandardScaler()
X_train_norm = scaler.fit_transform(X_train)
X_test_norm = scaler.transform(X_test)
clf = NimbusSoftmax()
clf.fit(X_train_norm, y_train)
predictions = clf.predict(X_test_norm)
```
## Performance Characteristics
| Operation | Typical cost | Notes |
| --------------- | ------------------- | --------------------------------------------- |
| Training | Moderate | More expensive than `NimbusLDA` / `NimbusQDA` |
| Batch inference | \~15-25ms per trial | Depends on posterior sample count |
| Streaming chunk | \~15-25ms | Use lower sample counts if latency is binding |
`NimbusSoftmax` is usually slower than `NimbusLDA` and `NimbusQDA`, but can improve accuracy when class boundaries are not well represented by Gaussian class-conditionals.
## Model Inspection
```python theme={null}
params = clf.model_.params
print("Posterior weight means:")
print(params["beta_mean"])
print("Posterior covariance Cholesky factors:")
print(params["beta_cov_chol"].shape)
print("Reference class:")
print(params["ref_class"])
```
## Model Selection Context
Use `NimbusSoftmax` when you are in Python and need a non-Gaussian static classifier with posterior sampling support. If you need explicit class centers or Mahalanobis diagnostics, use `NimbusLDA` or `NimbusQDA`. If the session drifts over time, use `NimbusSTS`.
For the canonical side-by-side comparison, see [Model Specification](/model-specification).
## Next Read
Julia's non-Gaussian static classifier.
Full `NimbusSoftmax` constructor and method reference.
Use posterior samples to reduce calibration labels.
Compare Nimbus model families.
## References
**Implementation:**
* Python source code: `nimbus_bci/models/nimbus_softmax/` in `nimbus-bci`
**Theory:**
* Polson, N. G., Scott, J. G., & Windle, J. (2013). "Bayesian inference for logistic models using Pólya-Gamma latent variables"
* Windle, J., Polson, N. G., & Scott, J. G. (2014). "Sampling Pólya-Gamma random variates: alternative and approximate techniques"
* Bayesian multinomial logistic regression with Polya-Gamma augmentation and variational inference
# Bayesian STS
Source: https://docs.nimbusbci.com/models/rxsts
Stateful Bayesian STS for non-stationary BCI data, combining drift adaptation, temporal dynamics, and uncertainty-aware inference in 20-30ms.
**Python**: `NimbusSTS` | **Julia**: Not currently available\
**Mathematical Model**: State-Space Model with Extended Kalman Filter (EKF)
Bayesian STS is a **stateful** Bayesian classification model designed for **non-stationary BCI data**. It combines feature-based classification with latent state dynamics, allowing it to adapt to temporal drift, electrode changes, and long-session fatigue.
**Python head:** prefer standalone `NimbusSTS` / streaming sessions for latent-state adaptation. Personalizer currently ships `lda` / `qda` / `softmax` heads; STS remains the classical path for non-stationary sessions. See [Personalizer & Middleware](/personalizer/overview).
**Available in Python SDK:**
* **Python SDK**: `NimbusSTS` class (sklearn-compatible with state management)
* **Julia SDK**: Not currently available
This is the **only model in the SDK** that explicitly handles temporal dynamics and non-stationary distributions.
## Start Here
Start with SDK setup and first inference workflow.
Compare Nimbus models by data characteristics and use case.
See practical BCI examples for training and inference.
## Overview
Bayesian STS extends beyond traditional static classifiers by modeling **latent state evolution** over time:
✅ **Temporal state dynamics** with Extended Kalman Filter
✅ **Drift adaptation** for non-stationary data
✅ **State management API** for explicit time propagation
✅ **Online learning** with delayed feedback support
✅ **Cross-session transfer** with state persistence
✅ **Uncertainty quantification** for predictions and states
✅ **Fast inference** (\~20-30ms per trial)
## Quick Start
```python theme={null}
from nimbus_bci import NimbusSTS
import numpy as np
# Create and fit classifier
clf = NimbusSTS(transition_cov=0.01, num_steps=100)
clf.fit(X_train, y_train)
# Stateful prediction with time propagation
for x_new in streaming_data:
clf.propagate_state() # Advance time
prediction = clf.predict(x_new.reshape(1, -1))
# After feedback arrives
clf.partial_fit(x_new.reshape(1, -1), [true_label])
# Inspect and save state for next session
z_mean, z_cov = clf.get_latent_state()
```
### When to Use Bayesian STS
**Bayesian STS is ideal for:**
* Non-stationary data with temporal drift
* Long BCI sessions (>30 minutes) with fatigue effects
* Cross-day experiments with electrode position changes
* Adaptive BCI systems with delayed feedback
* Online learning scenarios with continuous adaptation
* Environments with changing noise characteristics
**Use [Bayesian LDA](/models/rxlda) or [Bayesian QDA](/models/rxgmm) instead if:**
* Data is stationary (class distributions don't change over time)
* Sessions are short (\<10 minutes)
* You need the absolute fastest inference (\<15ms)
* Complexity of state management is not warranted
## Model Architecture
### Mathematical Foundation (State-Space Model)
Bayesian STS implements a state-space model with Extended Kalman Filter inference:
**Latent Dynamics:**
```
z_t = A @ z_{t-1} + w_t, where w_t ~ N(0, Q)
```
**Observation Model:**
```
logits = W @ x_t + H @ z_t + b
p(y=k | x_t, z_t) = softmax(logits)_k
```
Where:
* `z_t` = latent state at time t (captures temporal patterns like class prior drift)
* `A` = state transition matrix (default: identity for random walk)
* `Q` = process noise covariance (controls drift speed)
* `W` = feature weight matrix
* `H` = state-to-logit projection matrix
* `x_t` = observed features
**Key Innovation**: The latent state `z` captures temporal patterns that persist across samples, such as gradual shifts in class priors due to fatigue or electrode drift.
### Inference with Extended Kalman Filter
During training, the EKF updates the latent state using observed labels (measurement update). During inference:
* `propagate_state()`: Advance the prior without labels (time update only)
* `partial_fit()`: Update state with new label (measurement update)
* `predict_proba()`: Never mutates state (consistent with sklearn API)
### Hyperparameters
Bayesian STS supports configurable hyperparameters for optimal performance:
**Available Hyperparameters:**
| Parameter | Type | Default | Range | Description |
| ------------------- | --------------- | ------- | ------------------------ | --------------------------------------------------- |
| `state_dim` | int or None | None | \[1, n\_classes] | Dimension of latent state (default: n\_classes - 1) |
| `w_loc` | float | 0.0 | \[-10, 10] | Prior mean for feature weights |
| `w_scale` | float | 1.0 | \[0.1, 10] | Prior scale for feature weights |
| `transition_cov` | float or None | None | \[0.001, 1.0] | Process noise covariance Q (drift speed) |
| `observation_cov` | float | 1.0 | \[0.1, 10] | Observation noise covariance R |
| `transition_matrix` | ndarray or None | None | (state\_dim, state\_dim) | State transition matrix A (default: identity) |
| `learning_rate` | float | 0.1 | \[0.01, 1.0] | Step size for parameter updates |
| `num_steps` | int | 50 | \[20, 200] | Number of learning iterations |
| `rng_seed` | int | 0 | any | Random seed for reproducibility |
**Critical Parameter: `transition_cov` (Q)**
This controls how fast the latent state can drift:
* **0.001**: Very slow drift (multi-day stability)
* Use for: Short sessions, stable recording conditions
* Example: 10-minute calibration sessions
* **0.01**: Moderate drift (within-session adaptation)
* Use for: Standard BCI sessions (30-60 minutes)
* Example: Motor imagery with gradual fatigue
* **0.1**: Fast drift (rapid environmental changes)
* Use for: Highly non-stationary environments
* Example: Mobile BCI, changing electrode impedance
**Rule of thumb**: Set to 1% of expected signal variance. If `None`, auto-estimated from data.
### Model Structure
The NimbusSTS classifier maintains:
* Feature weights `W` (learned during training)
* State projection `H` (learned during training)
* Current state mean `z_mean` (updated online)
* Current state covariance `z_cov` (updated online)
* Initial state `z_mean_init`, `z_cov_init` (for reset)
## Usage
### 1. Basic Training and Prediction
```python theme={null}
from nimbus_bci import NimbusSTS
import numpy as np
# Generate sample data (in practice, use real EEG features)
np.random.seed(42)
n_samples, n_features = 100, 16
X_train = np.random.randn(n_samples, n_features)
y_train = np.random.randint(0, 4, n_samples)
# Train with moderate drift tracking
clf = NimbusSTS(
transition_cov=0.05, # Moderate drift
num_steps=100,
verbose=True
)
clf.fit(X_train, y_train)
# Standard prediction (each sample treated independently)
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
print(f"Predictions: {predictions}")
print(f"Probabilities shape: {probabilities.shape}")
```
**Important**: `predict()` and `predict_proba()` never mutate the state (sklearn API compatibility). For time-ordered evaluation, use `propagate_state()` explicitly.
### 2. Stateful Prediction with Time Propagation
For **time-ordered streaming data**, explicitly propagate state between samples:
```python theme={null}
from nimbus_bci import NimbusSTS
import numpy as np
# Train model
clf = NimbusSTS(transition_cov=0.05)
clf.fit(X_train, y_train)
# Streaming prediction with state propagation
predictions = []
for x_t in X_stream: # Time-ordered samples
# Advance state one step (EKF time update)
clf.propagate_state(n_steps=1)
# Predict using current state
pred = clf.predict(x_t.reshape(1, -1))[0]
predictions.append(pred)
print(f"Prediction: {pred}")
# Compare with vs without state propagation
acc_with_state = np.mean(predictions == y_stream)
acc_without_state = clf.score(X_stream, y_stream) # Treats samples independently
print(f"Accuracy with state propagation: {acc_with_state:.2%}")
print(f"Accuracy without state propagation: {acc_without_state:.2%}")
```
### 3. Online Learning with Delayed Feedback
The canonical BCI paradigm: predict → user acts → receive feedback → update
```python theme={null}
from nimbus_bci import NimbusSTS
# Initial training (calibration phase)
clf = NimbusSTS(transition_cov=0.05, learning_rate=0.1)
clf.fit(X_calibration, y_calibration)
# Online session with delayed feedback
for trial in online_session:
# 1. Advance time (no measurement)
clf.propagate_state()
# 2. Predict using current state
prediction = clf.predict(trial.features.reshape(1, -1))[0]
# 3. User performs action based on prediction
execute_action(prediction)
# 4. Get feedback (true label) after user completes action
true_label = wait_for_feedback()
# 5. Update state with measurement (EKF update)
clf.partial_fit(trial.features.reshape(1, -1), [true_label])
print(f"Trial: pred={prediction}, true={true_label}")
```
**Why this matters**: In real BCI, labels aren't available at prediction time. The model must predict using only the prior, then update when feedback arrives.
### 4. State Inspection and Transfer
Save and restore latent state across sessions:
```python theme={null}
from nimbus_bci import NimbusSTS
import pickle
# Day 1: Train and save state
clf_day1 = NimbusSTS()
clf_day1.fit(X_day1, y_day1)
# Get final state
z_final, P_final = clf_day1.get_latent_state()
print(f"Final state mean: {z_final}")
print(f"Final state uncertainty: {np.diag(P_final)}")
# Save model and state
with open("model_day1.pkl", "wb") as f:
pickle.dump({'model': clf_day1, 'state': (z_final, P_final)}, f)
# Day 2: Transfer state with increased uncertainty
with open("model_day1.pkl", "rb") as f:
saved = pickle.load(f)
clf_day2 = saved['model']
# Quick calibration (just a few trials)
clf_day2.fit(X_day2_calib, y_day2_calib)
# Transfer previous state (with decay)
z_prior, P_prior = saved['state']
clf_day2.set_latent_state(
z_mean=z_prior * 0.5, # Decay mean toward 0
z_cov=P_prior * 2.0 # Increase uncertainty
)
# Use model with transferred state
predictions = clf_day2.predict(X_day2_test)
```
**Use case**: Cross-day transfer learning. Start with informed prior from previous session, but increase uncertainty to allow adaptation.
### 5. State Reset and Management
Reset state to initial values from training:
```python theme={null}
from nimbus_bci import NimbusSTS
clf = NimbusSTS()
clf.fit(X_train, y_train)
# Run one session
for _ in range(100):
clf.propagate_state()
# ... predictions ...
# Reset for new session (clean slate)
clf.reset_state()
# State is now back to initial values
z_reset, P_reset = clf.get_latent_state()
print(f"Reset state mean: {z_reset}") # Near zero
print(f"Reset state cov diagonal: {np.diag(P_reset)}") # Identity
```
### 6. Batch Inference
Standard sklearn-compatible batch inference:
```python theme={null}
from nimbus_bci import NimbusSTS
import numpy as np
clf = NimbusSTS()
clf.fit(X_train, y_train)
# Batch inference (samples treated as independent)
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
confidences = np.max(probabilities, axis=1)
# Calculate accuracy
accuracy = np.mean(predictions == y_test)
print(f"Accuracy: {accuracy * 100:.1f}%")
print(f"Mean confidence: {np.mean(confidences):.3f}")
```
### 7. Streaming Inference with StreamingSessionSTS
Real-time chunk-by-chunk processing with state management:
For detailed streaming examples, see [Python SDK Streaming Inference](/python-sdk/streaming-inference#nimbussts-streaming).
```python theme={null}
from nimbus_bci import NimbusSTS
from nimbus_bci.inference import StreamingSessionSTS
from nimbus_bci.data import BCIMetadata
# Train model
clf = NimbusSTS(transition_cov=0.05)
clf.fit(X_train, y_train)
# Setup streaming session
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=16,
n_classes=4,
chunk_size=125, # 500ms chunks
temporal_aggregation="logvar"
)
session = StreamingSessionSTS(clf, metadata)
# Process chunks with automatic state propagation
for chunk in eeg_stream: # (n_features, chunk_size)
result = session.process_chunk(chunk)
print(f"Chunk: pred={result.prediction}, conf={result.confidence:.3f}")
# Finalize trial
final_result = session.finalize_trial()
print(f"Final: pred={final_result.prediction}")
# Reset for next trial
session.reset()
```
**Key feature**: `StreamingSessionSTS` automatically calls `propagate_state()` between chunks, properly handling temporal dynamics.
## Hyperparameter Tuning
Fine-tune NimbusSTS for your specific drift characteristics.
### When to Tune Hyperparameters
Consider tuning when:
* Default performance is unsatisfactory on non-stationary data
* You observe significant drift or fatigue effects
* You need to balance adaptation speed vs stability
* Cross-session performance is poor
### Tuning `transition_cov` (Critical Parameter)
The process noise covariance controls drift speed:
#### For Stable, Short Sessions
```python theme={null}
from nimbus_bci import NimbusSTS
# Minimal drift (short sessions, stable conditions)
clf = NimbusSTS(
transition_cov=0.001, # Very slow drift
num_steps=50
)
clf.fit(X_train, y_train)
```
**Use when:**
* Session duration \< 15 minutes
* Excellent electrode stability
* Controlled lab environment
* Minimal user fatigue
#### For Standard Sessions with Gradual Drift
```python theme={null}
from nimbus_bci import NimbusSTS
# Moderate drift (typical BCI session)
clf = NimbusSTS(
transition_cov=0.01, # Moderate drift (default behavior)
num_steps=100,
learning_rate=0.1
)
clf.fit(X_train, y_train)
```
**Use when:**
* Session duration 30-60 minutes
* Standard BCI recording conditions
* Gradual fatigue or attention changes
* This is the recommended starting point
#### For Highly Non-Stationary Environments
```python theme={null}
from nimbus_bci import NimbusSTS
# Fast drift (rapid environmental changes)
clf = NimbusSTS(
transition_cov=0.1, # Fast drift
observation_cov=2.0, # Increase measurement noise tolerance
num_steps=100,
learning_rate=0.2 # Faster adaptation
)
clf.fit(X_train, y_train)
```
**Use when:**
* Mobile BCI or changing environments
* Electrode impedance changes during session
* Rapid user state changes
* Real-world deployment scenarios
### Auto-Estimation
If unsure, let the model estimate `transition_cov` from data:
```python theme={null}
from nimbus_bci import NimbusSTS
# Auto-estimate process noise
clf = NimbusSTS(
transition_cov=None, # Auto-estimate
num_steps=100
)
clf.fit(X_train, y_train)
# Check estimated value
print(f"Estimated transition_cov: {clf.model_.params['Q'][0, 0]:.4f}")
```
### Hyperparameter Search Example
Systematically search for optimal hyperparameters:
```python theme={null}
from sklearn.model_selection import GridSearchCV
from nimbus_bci import NimbusSTS
# Define parameter grid
param_grid = {
'transition_cov': [0.001, 0.01, 0.05, 0.1],
'learning_rate': [0.05, 0.1, 0.2],
'num_steps': [50, 100]
}
# Grid search
grid = GridSearchCV(
NimbusSTS(),
param_grid,
cv=3, # Use fewer folds (STS is stateful)
scoring='accuracy',
n_jobs=-1,
verbose=1
)
grid.fit(X_train, y_train)
print(f"\nBest hyperparameters:")
print(f" transition_cov: {grid.best_params_['transition_cov']}")
print(f" learning_rate: {grid.best_params_['learning_rate']}")
print(f" num_steps: {grid.best_params_['num_steps']}")
print(f" CV accuracy: {grid.best_score_*100:.1f}%")
# Use best model
best_clf = grid.best_estimator_
```
### Quick Tuning Guidelines
| Scenario | `transition_cov` | `learning_rate` | `num_steps` | Notes |
| ---------------------------- | ---------------- | --------------- | ----------- | --------------------- |
| **Short, stable sessions** | 0.001 | 0.1 | 50 | Minimal drift |
| **Standard BCI (30-60 min)** | 0.01-0.05 | 0.1 | 100 | Recommended default |
| **Long sessions (>1 hour)** | 0.05-0.1 | 0.1-0.2 | 100 | Allow more adaptation |
| **Mobile/real-world** | 0.1-0.5 | 0.2 | 100-200 | Fast adaptation |
| **Cross-day transfer** | 0.01 | 0.05 | 50 | Start conservative |
**Pro Tip**: Start with `transition_cov=0.01` and `num_steps=100`. If you observe drift (accuracy degrades over time), increase `transition_cov`. If predictions are too noisy, decrease it.
## Training Requirements
### Data Requirements
* **Minimum**: 40 trials per class
* **Recommended**: 80+ trials per class for stable initialization
* **For fine-tuning**: 10-20 trials with `partial_fit()`
NimbusSTS requires at least 2 samples to initialize state statistics. Training will raise an error if any class has fewer than 2 samples.
### Feature Normalization
**Critical for STS models!**
Normalization is even more important for NimbusSTS than static models, as drift can amplify scale differences.
```python theme={null}
from sklearn.preprocessing import StandardScaler
import pickle
# Estimate normalization from training data
scaler = StandardScaler()
X_train_norm = scaler.fit_transform(X_train)
# Train on normalized features
clf = NimbusSTS()
clf.fit(X_train_norm, y_train)
# Save scaler with model
with open("model_with_scaler.pkl", "wb") as f:
pickle.dump({'model': clf, 'scaler': scaler}, f)
# Always apply same normalization
X_test_norm = scaler.transform(X_test)
predictions = clf.predict(X_test_norm)
```
See [Feature Normalization](/inference-configuration/feature-normalization) for the recommended train/test scaling workflow.
### Feature Requirements
NimbusSTS expects **preprocessed features**, not raw EEG:
✅ **Required preprocessing:**
* Bandpass filtering (paradigm-specific)
* Artifact removal (ICA recommended)
* Spatial filtering (CSP for motor imagery)
* Feature extraction (log-variance, bandpower, etc.)
* Temporal aggregation (for batch training)
❌ **NOT accepted:**
* Raw EEG channels
* Unfiltered data
See [Preprocessing Requirements](/inference-configuration/preprocessing-requirements).
## Performance Characteristics
### Computational Performance
| Operation | Latency | Notes |
| ---------------------- | ----------------- | ------------------------------------ |
| **Training** | 15-40 seconds | 100 iterations, 100 trials per class |
| **Batch Inference** | 20-30ms per trial | Slightly slower than static models |
| **propagate\_state()** | \<1ms | Fast time update |
| **partial\_fit()** | 5-15ms | Online EKF update |
| **Streaming Chunk** | 20-30ms | Includes state propagation |
All measurements on standard CPU (no GPU required).
### Classification Accuracy
| Scenario | Typical Accuracy | Comparison to Static Models |
| --------------------------------------- | ------------------ | ------------------------------------ |
| **Stationary data (short sessions)** | 70-85% | Similar to NimbusLDA/NimbusQDA |
| **Non-stationary data (long sessions)** | 75-90% | **+5-15% vs static models** |
| **Cross-day with state transfer** | 65-80% | **+10-20% vs training from scratch** |
| **Online adaptation** | Improves over time | Adapts to user state changes |
**Key Insight**: NimbusSTS shines on **non-stationary data** where static models degrade over time. For short, stationary sessions, use NimbusLDA for faster inference.
### Latency Trade-offs
```
NimbusLDA: ~10-15ms ✓ Fastest
NimbusQDA: ~15-25ms ✓ Fast
NimbusSTS: ~20-30ms ✓ Still real-time capable
NimbusSoftmax: ~15-25ms ✓ Fast
```
The \~5-10ms overhead of NimbusSTS is worth it for non-stationary scenarios where static models would degrade.
## Model Inspection
### View Current State
```python theme={null}
import numpy as np
# Get latent state
z_mean, z_cov = clf.get_latent_state()
print("Latent state:")
print(f" Mean: {z_mean}")
print(f" Covariance diagonal: {np.diag(z_cov)}")
print(f" Uncertainty (trace): {np.trace(z_cov):.3f}")
```
### Monitor State Evolution
```python theme={null}
from nimbus_bci import NimbusSTS
import matplotlib.pyplot as plt
clf = NimbusSTS(transition_cov=0.05)
clf.fit(X_train, y_train)
# Track state over time
z_history = []
uncertainty_history = []
for x_t in X_stream:
clf.propagate_state()
z_mean, z_cov = clf.get_latent_state()
z_history.append(z_mean.copy())
uncertainty_history.append(np.trace(z_cov))
pred = clf.predict(x_t.reshape(1, -1))
# Plot state evolution
z_history = np.array(z_history)
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.plot(z_history)
plt.xlabel('Time (trials)')
plt.ylabel('Latent State')
plt.title('State Evolution Over Time')
plt.grid(True)
plt.subplot(1, 2, 2)
plt.plot(uncertainty_history)
plt.xlabel('Time (trials)')
plt.ylabel('Uncertainty (trace of cov)')
plt.title('State Uncertainty Over Time')
plt.grid(True)
plt.tight_layout()
plt.show()
```
### View Model Parameters
```python theme={null}
# Feature weights (W)
W = clf.model_.params['W']
print(f"Feature weights shape: {W.shape}") # (n_classes, n_features)
# State projection (H)
H = clf.model_.params['H']
print(f"State projection shape: {H.shape}") # (n_classes, state_dim)
# Transition matrix (A)
A = clf.model_.params['A']
print(f"Transition matrix:\n{A}")
# Process noise (Q)
Q = clf.model_.params['Q']
print(f"Process noise covariance:\n{Q}")
```
## Advantages & Limitations
### Advantages
✅ **Handles Non-Stationarity**: Explicitly models temporal drift\
✅ **Adaptive**: Continuously learns from feedback\
✅ **Cross-Session Transfer**: State persistence across days\
✅ **Uncertainty Quantification**: For both predictions and states\
✅ **Delayed Feedback Support**: Natural for BCI paradigms\
✅ **Production-Ready**: Real-time capable with \<30ms latency\
✅ **sklearn-Compatible**: Works with pipelines and CV
### Limitations
❌ **More Complex API**: State management requires careful usage\
❌ **Slightly Slower**: 5-10ms overhead vs static models\
❌ **Requires More Tuning**: `transition_cov` is critical\
❌ **Not Ideal for Stationary Data**: Use NimbusLDA if data is stable\
❌ **Memory**: Maintains state history (minimal overhead)
## Model Selection Context
Use `NimbusSTS` when prediction quality degrades over a long session, across days, or after electrode/user-state drift. Static models (`NimbusLDA`, `NimbusQDA`, `NimbusSoftmax`, `NimbusProbit`) are usually simpler and faster for stable sessions.
**Rule of thumb**: If accuracy degrades by more than 10% from start to end of session, evaluate `NimbusSTS`.
For the canonical side-by-side comparison, see [Model Specification](/model-specification).
## Practical Examples
### Example 1: Detecting and Adapting to Drift
```python theme={null}
from nimbus_bci import NimbusSTS, NimbusLDA
import numpy as np
# Compare static vs adaptive on drifting data
def generate_drifting_data(n_samples, n_features, drift_rate=0.05):
X, y = [], []
means = np.random.randn(4, n_features) * 2.0
for t in range(n_samples):
means += np.random.randn(4, n_features) * drift_rate # Drift!
label = t % 4
x = np.random.randn(n_features) + means[label]
X.append(x)
y.append(label)
return np.array(X), np.array(y)
# Generate non-stationary data
X_train, y_train = generate_drifting_data(200, 16, drift_rate=0.03)
X_test, y_test = generate_drifting_data(100, 16, drift_rate=0.03)
# Static model
clf_static = NimbusLDA()
clf_static.fit(X_train, y_train)
acc_static = clf_static.score(X_test, y_test)
# Adaptive model
clf_sts = NimbusSTS(transition_cov=0.05, num_steps=100)
clf_sts.fit(X_train, y_train)
# Evaluate with state propagation
preds = []
for x in X_test:
clf_sts.propagate_state()
pred = clf_sts.predict(x.reshape(1, -1))[0]
preds.append(pred)
acc_sts = np.mean(preds == y_test)
print(f"Static model accuracy: {acc_static:.1%}")
print(f"Adaptive model accuracy: {acc_sts:.1%}")
print(f"Improvement: {(acc_sts - acc_static)*100:+.1f}%")
```
### Example 2: Cross-Day Transfer Learning
```python theme={null}
from nimbus_bci import NimbusSTS
import pickle
# Day 1: Initial session
clf_day1 = NimbusSTS(transition_cov=0.01)
clf_day1.fit(X_day1_full, y_day1_full) # Full training
# Save state
z_day1, P_day1 = clf_day1.get_latent_state()
# Day 2: Start with minimal calibration
clf_day2 = NimbusSTS(transition_cov=0.01)
clf_day2.fit(X_day2_calib, y_day2_calib) # Just 20 trials
# Option A: No transfer (baseline)
acc_no_transfer = clf_day2.score(X_day2_test, y_day2_test)
# Option B: Transfer state from day 1
clf_day2.set_latent_state(
z_mean=z_day1 * 0.7, # Partial transfer
z_cov=P_day1 * 1.5 # Increase uncertainty
)
acc_with_transfer = clf_day2.score(X_day2_test, y_day2_test)
print(f"Day 2 accuracy (no transfer): {acc_no_transfer:.1%}")
print(f"Day 2 accuracy (with transfer): {acc_with_transfer:.1%}")
print(f"Improvement: {(acc_with_transfer - acc_no_transfer)*100:+.1f}%")
```
### Example 3: Real-Time Adaptive BCI Loop
```python theme={null}
from nimbus_bci import NimbusSTS
import time
# Setup
clf = NimbusSTS(transition_cov=0.05, learning_rate=0.1)
clf.fit(X_calibration, y_calibration)
# Real-time loop
accuracy_history = []
window_size = 10
for i, (features, true_label) in enumerate(online_trials):
# 1. Propagate state forward in time
clf.propagate_state(n_steps=1)
# 2. Make prediction
t0 = time.time()
prediction = clf.predict(features.reshape(1, -1))[0]
latency_ms = (time.time() - t0) * 1000
# 3. Execute action (not shown)
execute_bci_command(prediction)
# 4. Get feedback after action completes
feedback_label = wait_for_user_feedback()
# 5. Update model
clf.partial_fit(features.reshape(1, -1), [feedback_label])
# Track performance
correct = (prediction == feedback_label)
accuracy_history.append(correct)
if len(accuracy_history) > window_size:
accuracy_history.pop(0)
recent_acc = np.mean(accuracy_history)
print(f"Trial {i+1}: pred={prediction}, true={feedback_label}, "
f"correct={correct}, recent_acc={recent_acc:.1%}, "
f"latency={latency_ms:.1f}ms")
```
## Next Read
Faster static model for stationary data
Static model with class-specific covariances
Real-time streaming with NimbusSTS
Complete tutorials and use cases
## References
**Implementation:**
* Extended Kalman Filter: [https://en.wikipedia.org/wiki/Extended\_Kalman\_filter](https://en.wikipedia.org/wiki/Extended_Kalman_filter)
* Source code: `/nimbus_bci/models/nimbus_sts/` in nimbus-bci
**Theory:**
* Durbin, J., & Koopman, S. J. (2012). "Time Series Analysis by State Space Methods"
* Harvey, A. C. (1990). "Forecasting, Structural Time Series Models and the Kalman Filter"
* Särkkä, S. (2013). "Bayesian Filtering and Smoothing"
**BCI Applications:**
* Vidaurre, C., et al. (2011). "Co-adaptive calibration to improve BCI efficiency"
* Shenoy, P., et al. (2006). "Towards adaptive classification for BCI"
* Kirchner, E. A., et al. (2013). "On the applicability of brain reading for predictive human-machine interfaces in robotics"
# BrainState
Source: https://docs.nimbusbci.com/personalizer/brain-state
App-facing BrainState fields, decision presets, and bridges from BatchResult / StreamingResult / upstream probabilities.
`BrainState` is the app-facing output. Key fields:
| Field | Meaning |
| ----------------------------------- | ----------------------------------- |
| `intent` | Top semantic label (from `classes`) |
| `confidence` | Top-class posterior mass |
| `posterior` | Full class → probability map |
| `uncertainty` | Fused uncertainty used for gating |
| `alternatives` | Other `IntentHypothesis` entries |
| `need_more_data` | Ask for more evidence / calibration |
| `rejected` / `rejection_reason` | Decision gate outcome |
| `embedding` | Optional embedding snapshot |
| `user_id` / `model_id` / `paradigm` | Provenance stamps |
Use `BrainState.to_dict()` for JSON-friendly export (`brain_state_version` is currently `1`).
## Trial gate vs adapt recommendation
These are **different** signals:
| Signal | API | Question |
| ------------------------ | -------------------------------------------------------------- | ------------------------------------ |
| **Trial gate** | `rejected`, `need_more_data`, presets | Accept this intent for the app? |
| **Adapt gateway** | `Personalizer.adapt(...)` / `partial_fit` | Update the head / transform / trunk? |
| **Adapt recommendation** | `Personalizer.recommend_adapt(..., tau=...)` → `AdaptDecision` | Spend labels on a stream update? |
Prefer `Personalizer.adapt(...)` as the label-agnostic adaptation gateway (head update, unsupervised transform alignment, or trunk PEFT). `recommend_adapt` remains a thin mean-shift helper against a user-supplied `tau` (L0 stay vs L1 stream update only). It does **not** auto-escalate to affine maps or full fine-tune. Optional session metadata:
```python theme={null}
dec = adapter.recommend_adapt(X_stream, tau=0.25)
states = adapter.predict(X_eval)
for s in states:
s.extras.update(dec.to_extras()) # should_adapt, adapt_score, adapt_tau, …
```
After `Personalizer.load`, pass `X_ref=` (calibration embeddings are not serialized).
## Decision presets
| Preset | Intent |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| `strict` | Stricter gating; prefer `need_more_data` / reject when unsure (deployment default via `for_deployment`) |
| `permissive` | Lower confidence bar; more accepts (research / classical defaults) |
| `aggressive` | Lowest bar — accept almost everything |
**0.6 rename:** `"research"` → `"strict"`, `"consumer"` → `"permissive"`. Old strings raise `ValueError`.
Uncertainty fusion: `BrainState.uncertainty` is `max(normalized_entropy, upstream)` when upstream is set. Gating checks entropy and upstream thresholds independently.
Optional `mc_samples>=2` on `Personalizer` draws posterior samples and sets normalized epistemic uncertainty (BALD / log2 K) as `Prediction.uncertainty`. MAP intent and confidence still use closed-form `predict_proba`. Breakdown bits land in `BrainState.extras` (`total_entropy_bits`, `aleatoric_bits`, `epistemic_bits`).
## Dual API & bridges
| Object | Audience | Role |
| ------------- | -------------------- | --------------------------------------------------------------- |
| `BatchResult` | researchers, benches | Posteriors, entropy, Mahalanobis, ECE, ITR sweeps |
| `BrainState` | apps, devices | Intent, confidence, uncertainty, alternatives, `need_more_data` |
```python theme={null}
from nimbus_bci import (
predict_batch,
brain_states_from_batch,
brain_state_from_streaming_result,
)
# Research batch → app states
states = brain_states_from_batch(predict_batch(clf, X), classes=["left", "right"])
# StreamingSession final → BrainState
state = brain_state_from_streaming_result(final, classes=["left", "right"])
```
Upstream probabilities only (no head required):
```python theme={null}
from nimbus_bci import Prediction, decide
state = decide(
Prediction(classes=["left", "right"], probabilities=[0.7, 0.3], uncertainty=0.2),
preset="strict",
user_id="subject_001",
)
```
See also [Python Streaming Inference](/python-sdk/streaming-inference) for the streaming bridge in context.
## Next Read
# Encoder contract
Source: https://docs.nimbusbci.com/personalizer/encoder-contract
The encode(X)→Z product boundary: wrap, FrozenEncoder, Personalizer fit/partial_fit/save/load, and optional trunk shortcuts.
The encode hook is not a gap — it **is** the product boundary. Nimbus does not host DL architectures. Integrators (or thin shortcuts) supply embeddings.
| Layer | Owner | Responsibility |
| ------------ | --------------------- | --------------------------------------------------------------------- |
| Frozen trunk | You / partner / bench | `encode(X) → (n, d)` pre-classifier features |
| Wrap | Nimbus | `wrap` / `FrozenEncoder` (+ optional named shortcuts) |
| Head | Nimbus | `Personalizer` — LDA / QDA / Softmax, `fit` / `adapt` / `partial_fit` |
| App | You | `BrainState` + presets (`strict` / `permissive`) |
**Rules:**
* Freeze the trunk before personalizing
* Feed **embeddings**, never class logits
* `Personalizer.load` / `from_profile` require **re-attaching** the encoder
* Optional `embedding_dim=` validates embedding width
* Prefer factories (`for_deployment`, `for_features`, …) over raw constructors
## Canonical recipe
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64,
training_sfreq=250.0) # declare trunk rate when known
adapter = Personalizer.for_deployment(
enc, ["left", "right"],
user="subject_001",
paradigm="motor_imagery",
)
# Pin accept rate for your trunk (optional):
# adapter.fit_calibrated(X_cal, y_cal, X_val, y_val, target_accept=0.7)
adapter.fit(X_cal, y_cal, input_sfreq=250.0)
states = adapter.predict(X_test, input_sfreq=250.0) # list[BrainState] / BrainStateBatch
adapter.save("subject_001_profile")
loaded = Personalizer.load("subject_001_profile", encoder=enc)
# Portable \<50 KB profile (head + transform + thresholds; no raw EEG):
profile = adapter.export_profile()
warm = Personalizer.from_profile(profile, encoder=enc)
```
Already have features / CSP / Riemann vectors? Skip the trunk:
```python theme={null}
from nimbus_bci import Personalizer
adapter = Personalizer.for_features(["left", "right"])
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test)
```
Riemann / P300 paradigms (`pip install nimbus-bci[riemann]`):
```python theme={null}
p = Personalizer.for_riemann(["left", "right"]) # geodesic tracking α=0.05 default
# p = Personalizer.for_p300(["NonTarget", "Target"], tracking_alpha=0.05)
```
Online head updates default to cumulative (`λ=1`, byte-identical to 0.5). Optional forgetting on LDA/QDA uses `λ\<1` (recency factor). Prefer session calibration and short stream budgets unless you intentionally enable forgetting.
**Foundation pooling:** REVE / EEGPT adapters mean-pool (or flatten) token / patch features to flat `(n, d)` so LDA/QDA heads can consume them. Spatial/temporal structure is collapsed at the encoder boundary, not inside the Bayesian head.
## Ladder levels under one API
**Shipped Personalizer path** (same trunk, no architecture change):
| Level | What it is | When to think about it |
| -------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
| **L0 — cal-only** | Bayesian head fit on calibration embeddings only; no stream labels | Default when clean already transfers (cal-only-when-clean) |
| **L1 — stream head** | L0 + stream `partial_fit` | Mid / severe shift when head capacity exists |
**Opt-in / priced (not deployment defaults):**
| Level | What it is | Where it lives |
| ------------------------ | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| **L2 — affine-Z + head** | Supervised affine map on embeddings, then the same head | `transform="affine"` / `fit_affine()` (research / offline); also Paper 1 / NimbusBench protocol |
| **L3 — full fine-tune** | Warm-start fine-tune of the encoder | Escape hatch (outside Personalizer; priced); `[train]` PEFT / BaLoRA opt-in |
Linear probe (**LP**) in Evidence is a matched sklearn control on the same frozen `Z`, not a ladder level. Plain-language legend: [Overview](/personalizer/overview#ladder-legend-plain-language). Evidence for when L1 helps and how L3 is priced: [Evidence](/personalizer/evidence).
**Heads:** product default is LDA (`head="lda"`). QDA is a documented severe-shift option behind the same API; Softmax stays optional (`nimbus-bci[softmax]`). The multi-trunk map in Paper 1 locks LDA for comparability — numbers for LDA vs QDA vs Softmax (EEGNet): [Evidence → Which head?](/personalizer/evidence#which-head-lda-vs-qda-vs-softmax).
## Optional shortcuts
Named helpers only reduce boilerplate. Architectures stay outside core:
| Shortcut | Trunk lives in | Notes |
| ---------------------------------------- | ------------------------------ | ------------------------------------------------------- |
| `wrap(...)` | Your code | Canonical — any callable / sklearn / torch |
| `wrap_eegnet(clf)` | `nimbusbench.models.eegnet` | Convenience over `.encode` / `.freeze` |
| `wrap_braindecode(..., kind="eegnetv4")` | `braindecode` (optional) | Or pass any `encode_fn` |
| `wrap_braindecode(..., kind="reve")` | `braindecode` REVE (optional) | Foundation EEG; electrode positions + pooled embeddings |
| `wrap_braindecode(..., kind="eegpt")` | `braindecode` EEGPT (optional) | Patch-pooled foundation embeddings |
```python theme={null}
from nimbus_bci import wrap_braindecode, wrap_eegnet
enc = wrap_eegnet(clf, freeze=True)
enc = wrap_braindecode(module, kind="eegnetv4")
enc = wrap_braindecode(reve_module, kind="reve", channel_names=chans)
enc = wrap_braindecode(eegpt_module, kind="eegpt")
enc = wrap_braindecode(my_encode_fn) # any other BrainDecode / custom net
```
Claim for second trunks: **API interchangeability** under the same `Personalizer` / `BrainState` contract — not a confirmatory accuracy bake-off.
## Active learning → personalization
Prefer Personalizer wrappers (encode for you), or `CalibrationSession` on the head:
```python theme={null}
ranked = adapter.suggest_calibration_trials(X_pool, n=4, strategy="bald")
adapter.partial_fit(X_pool[ranked.indices], y_new)
status = adapter.calibration_status(X_probe, criterion="posterior_stability")
```
See [Active Learning](/python-sdk/active-learning).
## Next Read
# Evidence
Source: https://docs.nimbusbci.com/personalizer/evidence
Exploratory Personalizer evidence from Papers 1 and 1b: head vs probe vs FT, LDA/QDA/Softmax, severity sweep, REVE, transfer map.
Figures and tables below come from two exploratory manuscripts:
1. **Universal Personalizer** — *Universal BCI Personalization: One API for Frozen EEG Trunks and Foundation Models* (Paper 1)
2. **Resource Ladder** — *When Should a Deployed BCI Decoder Spend Adaptation Resources? A Resource Ladder for Frozen Encoders* (Paper 1b)
All numbers are **exploratory**, protocol-local, and locked to the evaluation suite CSVs. Do not treat them as suite-wide constants.
**Headline (Paper 1):** ship one Personalizer; treat fine-tune as a priced escape hatch. Use the head where capacity exists; stay cal-only when clean wins.
Labels **L0 / L1 / LP / L2 / L3** — see the [plain-language legend](/personalizer/overview#ladder-legend-plain-language). **Shipped path = L0/L1**; LP is a bench control; L2 is protocol/bench (affine); L3 is the FT hatch.
## How the experiments work (data flow)
Before the numbers: this is the shared Paper 1 / 1b experimental pipeline.
*Papers 1 & 1b — end-to-end data flow. Calibration stays clean; synthetic stress hits adaptation and evaluation EEG only.*
| Step | What happens |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Datasets** | Motor imagery: BNCI2014-004 (9 subjects), Zhou2016 (4), Kumar2024 (18; post-first sessions online), Shin2017-A (9; short adaptation session) |
| **Preprocess** | Bandpass **8–30 Hz**, epoch window **\[0.5, 2.5] s** after cue (classical trunks). Foundation REVE cells may use **1–40 Hz** under the same window |
| **Sessions** | **Calibration** = first → **adaptation** = middle → **evaluation** = last |
| **Encoder** | Train on clean calibration only, freeze, map trials to embeddings |
| **Stress** | Optional synthetic **channel-mix** on concatenated adaptation + evaluation raw EEG (not on embeddings): mix toward a random channel permutation + signed bias at levels `{0, 0.25, 0.5, 0.75}`. Level 0 = no synthetic stress (natural session layout may still differ). Calibration stays clean |
| **Adapt** | L0 calibration-only head (Paper 1) / frozen trunk softmax (Paper 1b); L1 online head updates (\~40 labels, Shin \~20); linear probe = control; L2 affine map (bench); L3 warm-start fine-tune |
| **Score** | Evaluation accuracy + adaptation fit time under utility = accuracy − fit-time and label costs |
Not claimed as covered by this stress: physical electrode displacement, SNR drop, or every clinical shift type — only controlled synthetic channel mixing.
## Core: L0 / Bayesian head / linear probe / full FT
Main multi-trunk accuracy–cost block (Paper 1): under the same transfer cells (severe channel-mix stress, n=40, CUDA), calibration-only Personalizer (L0), stream Personalizer (L1), a matched linear probe on the same frozen embeddings, and warm-start full fine-tune (L3).
*Paper 1 — top: L0 / L1 / linear probe / L3 accuracy; blue `rec` labels = (acc\_L1 − acc\_L0) / (acc\_L3 − acc\_L0), or “≥FT” when L1 matches or beats L3. Bottom: adapt fit wall (L0 stream wall = 0) with L3/L1 ratios.*
| Dataset | Trunk | Acc L0 | Acc L1 | Acc LP | Acc L3 | Recovery | Wall L3/L1 |
| ------- | --------- | ------ | ------ | ------ | ------ | -------- | ---------- |
| BNCI | EEGNet | 0.514 | 0.550 | 0.514 | 0.548 | ≥FT | \~31× |
| BNCI | Shallow | 0.495 | 0.572 | 0.580 | 0.553 | ≥FT | \~2× |
| BNCI | Deep | 0.497 | 0.573 | 0.536 | 0.594 | 78% | \~24× |
| BNCI | Conformer | 0.500 | 0.578 | 0.537 | 0.618 | 66% | \~33× |
| BNCI | ATCNet | 0.501 | 0.508 | 0.510 | 0.602 | 8% | \~40× |
| Zhou | EEGNet | 0.333 | 0.540 | 0.422 | 0.498 | ≥FT | \~21× |
| Zhou | Shallow | 0.390 | 0.425 | 0.445 | 0.543 | 23% | \~2× |
| Zhou | Deep | 0.335 | 0.470 | 0.462 | 0.482 | 92% | \~14× |
| Zhou | Conformer | 0.338 | 0.398 | 0.377 | 0.488 | 40% | \~29× |
| Zhou | ATCNet | 0.333 | 0.425 | 0.453 | 0.527 | 47% | \~39× |
Recovery = (acc\_L1 − acc\_L0) / (acc\_L3 − acc\_L0). On most trunks the Bayesian head recovers \~65–90% of the FT gain over cal-only and beats the matched probe (EEGNet / Deep / Conformer). Shallow and ATCNet are probe-competitive (LP can edge L1 by a few points) while both stay far below L3 wall — Personalizer remains the product default (same API + UQ / `BrainState`), not a claim that L1 always wins raw accuracy. BNCI Shallow is still the Pareto win vs FT (\~2× wall); ATCNet on BNCI is the hatch case (8% recovery).
**Mid severity strengthens “head first”:** across BNCI+Zhou subject cells, stream L1 matches or beats warm-start L3 on accuracy in about half of cells at severity 0.25 / 0.5 (\~49% / \~48%) versus about a third at 0.75 (\~35%). Wall L3/L1 ratios stay expensive at every severity — severe is where FT most often still buys residual pp.
## Which head? (LDA vs QDA vs Softmax)
The multi-trunk map locks **LDA** for comparability. On the EEGNet path, Paper 1 also compares QDA and Softmax under the same bake-off geometry (severe and clean, n=40).
*Paper 1 — top: accuracy (Δacc vs LDA). Bottom: adapt fit wall with mean utility labels.*
| Dataset | Sev | Head | Acc | Wall (s) | ΔU vs LDA |
| ------- | ---- | ------------------- | ----- | --------- | --------- |
| BNCI | 0.75 | LDA | 0.556 | 0.36 | — |
| BNCI | 0.75 | QDA | 0.644 | 0.36 | +0.085 |
| BNCI | 0.75 | Softmax | 0.589 | 0.59 | +0.031 |
| BNCI | 0.00 | LDA / QDA / Softmax | ≈0.71 | 0.35–0.71 | ≈0 |
| Zhou | 0.75 | LDA | 0.462 | 0.43 | — |
| Zhou | 0.75 | QDA | 0.452 | 0.43 | −0.010 |
| Zhou | 0.75 | Softmax | 0.457 | 0.71 | −0.005 |
* **Product default:** LDA (`head="lda"`) — no optional dependency; locked ladder / FT narrative.
* **BNCI severe:** QDA lifts utility (\~+0.085–0.088) at essentially the same wall; Softmax gains accuracy but pays \~1.7× LDA wall.
* **Zhou severe (n=4):** inconclusive — QDA / Softmax at or below LDA. Clean BNCI/Zhou: heads nearly tied on U.
* Softmax stays optional (`nimbus-bci[softmax]`) for UQ-oriented deployments. The 18-cell transfer map is not re-run for every head.
API knobs: [Encoder contract](/personalizer/encoder-contract).
## More evidence
Stream Personalizer (L1) vs calibration-only (L0) across synthetic channel-mix stress levels on an 18-cell trunk×dataset map:
| Severity | Cells with ΔU(L1−L0) > 0 |
| ------------- | ------------------------ |
| 0 (clean) | **4 / 18** |
| 0.25 | **13 / 18** |
| 0.50 | **16 / 18** |
| 0.75 (severe) | **12 / 18** |
*Paper 1 — mid shift already recovers; severe is a detailed slice, not the only supporting point. Kumar stays near zero (boundary).*
At severity 0.75, which ladder level wins by mean utility (L0 / L1 / **L2 protocol affine+head** — L2 is bench-only):
| Dataset | n | EEGNet | Shallow | Deep | Conformer | ATCNet |
| ------- | -- | ------ | ------- | ---- | --------- | ------ |
| BNCI | 9 | L2 | L2 | L1 | L2 | L0 |
| Zhou | 4 | L2 | L1 | L1 | L2 | L2 |
| Kumar | 18 | L1 | L2 | — | L0 | — |
| Shin | 9 | L0 | L0 | L1 | L2 | L2 |
Zhou: head helps on every trunk. BNCI: often useful, not automatic (ATCNet stays L0). Kumar/Shin: boundaries — Personalizer is the default *integration* path, not a guarantee that L1 beats L0 everywhere.
Controlled bake-off on one classical reference trunk (BNCI primary, Paper 1b). Under severe shift with n=40 labeled stream trials, the Bayesian head can **beat** full FT on BNCI in this protocol; Zhou is the honest softer boundary.
*Paper 1b — BNCI2014-004, severity 0.75, n=40 (mean over 9 subjects).*
| Arm | Acc | ECE | Fit time (s) | Recovery vs full FT |
| ----------------- | --------- | --------- | ------------ | ------------------- |
| Frozen EEGNet | 0.497 | 0.347 | 0 | — |
| **Bayesian head** | **0.556** | **0.142** | **0.35** | **1.59** |
| Linear probe | 0.514 | 0.117 | 0.35 | 0.46 |
| LoRA | 0.463 | 0.224 | 1.08 | −0.92 |
| Last-layer FT | 0.496 | 0.329 | 1.31 | −0.03 |
| Full FT | 0.534 | 0.191 | 1.78 | 1.00 |
Recovery = Δacc / Δacc\_fullFT (values > 1 mean the head beats FT). Treat head-beats-FT as **BNCI-primary** in this protocol, not a suite-wide constant. On Zhou2016 (supporting, 4 subjects): frozen 0.317 → head 0.462 → full FT 0.540 (\~0.43× of FT gain at \~5.5× lower fit time).
Paper 1 EEGNet arm zoo (same denser grammar; bake-off walls \~5× ≠ ladder CUDA L3/L1 \~20–30×):
| Dataset | Acc frozen | Acc head | Acc FT | Recovery | Bake-off wall FT/head |
| ------- | ---------- | -------- | ------ | --------- | --------------------- |
| BNCI | 0.497 | 0.556 | 0.534 | **1.59×** | **5.0×** |
| Zhou | 0.317 | 0.462 | 0.540 | **0.43×** | **5.5×** |
The same Personalizer contract runs on Hub REVE. Under severe shift, adapt wall stays sub-second while last-layer / full FT cost about **20×–40×** more on Zhou/Kumar.
*Paper 1 — REVE severe n=40; accuracy × price.*
| Dataset | Acc frozen | Acc head | Acc LoRA | Acc last | Acc full | Wall LoRA/head | Wall full/head |
| ------- | ---------- | -------- | -------- | -------- | -------- | -------------- | -------------- |
| Zhou | 0.365 | 0.412 | 0.452 | 0.395 | 0.393 | **\~8×** | **\~36×** |
| Kumar | 0.490 | 0.548 | 0.539 | 0.511 | 0.528 | **\~16×** | **\~51×** |
* **Kumar:** head Pareto-dominates short FT (+5.8 pp vs last +5.1 pp at far lower wall).
* **Zhou:** last-layer can buy extra pp (+13 vs head +4.7) at \~21× wall — priced purchase, not the default.
* **Not claimed:** frozen REVE + Personalizer matches cal-trained EEGNet absolute accuracy. BNCI REVE cost is a 3-subject smoke — not a headline.
Soft operational default vs strict ordinal schedule (18 cells; exploratory). “Severe → L2” means the **protocol** affine+head winner — in 0.6 affine is available as an opt-in `transform=` / `fit_affine()`, not a deployment default.
| Slice | Clean → L0 | Severe → L2 | Strict ordinal |
| ---------- | ---------- | ----------- | -------------- |
| Zhou | 5/5 | 3/5 | 3/5 |
| BNCI | 3/5 | 3/5 | 1/5 |
| Kumar | 3/3 | 1/3 | 1/3 |
| Shin | 1/5 | 2/5 | 0/5 |
| **Pooled** | **12/18** | **9/18** | **5/18** |
*Paper 1 — cal-only-when-clean transfers often (12/18); strict ordinal (clean→L0 and severe→L2) is conditional (5/18) — boundary, not the product headline.*
## Product claim (honest boundaries)
| Regime | When | What happens |
| ------------------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Factory works | Clean / weak natural transfer | Stay cal-only (L0) — 12/18 cells |
| Factory breaks | Mid / severe controlled stress | Stream head (L1) often lifts utility; usually beats matched linear probe |
| Full retrain unnecessary | Same budgets as stress recovery | Classical: L1 recovers most of L3 at \~14–40× wall; EEGNet BNCI head can beat FT (\~5× bake-off wall); REVE \~20–40× vs short FT |
| Head choice | Same Personalizer API | Default LDA; QDA optional under severe shift (BNCI); Softmax optional for UQ |
Not claimed: L1 always beats L0 or LP; QDA always beats LDA; universal ordinal severity→level law; coverage of session / electrode / SNR shift (evidence is synthetic channel mixing); foundation SOTA; suite-wide 30× ratios from other protocols; shipped “Active Inference for EEG”; affine maps as a **deployment default** (they remain opt-in / research via `transform=`).
## Next Read
# Overview
Source: https://docs.nimbusbci.com/personalizer/overview
Wrap any frozen encoder, personalize online with a Bayesian head, and ship apps on BrainState — the nimbus-bci product wedge.
Nimbus middleware is the **product wedge**: a Bayesian personalization head for **frozen neural / foundation embeddings**. Encoders stay external. Apps consume thin `BrainState` outputs.
**One-liner:** anything that maps trials to embeddings plugs in; Nimbus owns personalization, uncertainty gating, and cheap online updates — not the trunk.
**Product takeaway (Paper 1):** ship one Personalizer; treat fine-tune as a **priced escape hatch**, not the default integration path. Use the head where embedding capacity exists; stay cal-only when clean already wins.
*One Personalizer API across trunks — from the Universal Personalizer manuscript (Paper 1).*
*Why the hatch stays priced: across classical trunks, stream Personalizer (L1) recovers most of the L0→L3 gain and usually beats a matched linear probe — details on [Evidence](/personalizer/evidence).*
## Ladder legend (plain language)
| Label | What it is | Shipped? |
| -------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| **L0 — calibration-only** | Fit the Bayesian head on calibration data once; no stream updates | Yes — fit once |
| **L1 — stream head** | L0 + online head updates on new labeled trials | Yes — default product path |
| **LP — linear probe** | Logistic regression on the same frozen embeddings | Bench control (not a Personalizer head) |
| **L2 — affine map + head** | Supervised affine map on embeddings, then the head | Opt-in via `transform="affine"` / `fit_affine()` (research / offline; not deployment default) |
| **L3 — full fine-tune** | Warm-start fine-tune of the encoder | Escape hatch (outside Personalizer; priced); `[train]` PEFT / BaLoRA opt-in |
Blue `rec %` on the figure = how much of the **L0→L3 accuracy gain** L1 recovers: `(acc_L1 − acc_L0) / (acc_L3 − acc_L0)`. `≥FT` means L1 matches or beats L3.
How the Paper 1 / 1b numbers were produced (datasets, bandpass, session split, synthetic stress): [Evidence → How the experiments work](/personalizer/evidence#how-the-experiments-work-data-flow).
Classical CSP / bandpower / Riemann features still work. Use `Personalizer.for_features(...)` or the standalone heads (`NimbusLDA`, …) documented under [Models](/model-specification). Upgrading from 0.5? See [Migration to 0.6](/python-sdk/migration-0.6).
## Start here
`wrap` → Personalizer → save/load rules.
App-facing intents, presets, and bridges.
Head vs probe vs FT, LDA/QDA/Softmax, REVE, transfer map.
MI / P300 / Riemann recipes and day-2 profiles.
Embedding-space alignment before the head.
Fit a Personalizer in a few minutes.
## Minimal example
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64)
# for_deployment = strict gating + LDA (common production case).
# Pin accept rate with fit_calibrated(...) when default gates don't match your trunk.
adapter = Personalizer.for_deployment(
enc, ["left", "right"], paradigm="motor_imagery"
)
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test) # list[BrainState] or BrainStateBatch
```
Other factories: `for_research` (permissive + knobs), `for_features` (no trunk), `for_riemann` / `for_p300` (`nimbus-bci[riemann]`), `for_balora` (`nimbus-bci[train]`). Paradigm recipes and day-2 profiles: [Paradigms & profiles](/personalizer/paradigms).
## Integrator checklist
1. **When to adapt** — prefer the adaptation gateway, or the thin mean-shift helper with a **user-supplied** threshold:
```python theme={null}
# Label-agnostic gateway (head / transform / trunk PEFT)
adapter.adapt(X_new, y_new) # or y=None for unsupervised paths
# Optional: decide whether to spend labels on a stream update
dec = adapter.recommend_adapt(X_stream, tau=your_tau)
if dec.should_adapt:
adapter.partial_fit(X_lab, y_lab)
```
Policy: stay on the current head when clean; stream-update when shift clears `tau`; full fine-tune stays a manual escape hatch. Portable day-2 warm-start: `export_profile()` → `from_profile(...)`. See [Evidence](/personalizer/evidence).
2. **Trial gate (different signal)** — `preset="strict"` / `"permissive"` (0.6 rename; was `research` / `consumer`); inspect `BrainState.rejected`, `need_more_data`, `uncertainty`. See [BrainState](/personalizer/brain-state).
3. **Head stability** — after online updates, use `calibration_sufficient` / `calibration_status` on an unlabeled pool.
4. **Encoder swap** — freeze trunk; match `embedding_dim`; never pass logits as embeddings; keep `model_id` consistent across `save` / `load` / profiles. See [Encoder contract](/personalizer/encoder-contract).
## Next read
Wrap rules and save/load.
App-facing intents and presets.
Personalizer and middleware symbols.
LDA, QDA, Softmax, STS.
# Paradigms & profiles
Source: https://docs.nimbusbci.com/personalizer/paradigms
Motor imagery, P300, and Riemannian recipes plus portable UserProfile day-2 warm-start for nimbus-bci 0.6.
0.6 ships **paradigm factories** and **portable profiles** so you can go from calibration epochs to a deployable Personalizer without owning a deep trunk — and warm-start day 2 without raw EEG.
Requires `pip install nimbus-bci[riemann]` for Riemann / P300 geometry paths.
## Choose a path
| Goal | Entry point | Notes |
| -------------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
| Frozen neural trunk | `Personalizer.for_deployment(enc, classes)` | Strict gating; pin accept rate with `fit_calibrated` |
| Classical features | `Personalizer.for_features(classes)` | CSP / bandpower / ERP vectors |
| Weak-Z / MI geometry | `Personalizer.for_riemann(classes)` or `fit_mi_session` | Default geodesic tracking `α=0.05` |
| P300 speller | `Personalizer.for_p300(classes)` or `fit_p300_session` | xDAWN→tangent; tracking **opt-in** (`α=0.05` when enabling) |
| Day-2 warm-start | `export_profile` / `from_profile` | \<50 KB; no raw EEG |
| Session portability | `MISessionBundle` / `P300SessionBundle` | Profile + trunk sidecar; trust contract |
## Portable profiles
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner", embedding_dim=64)
p = Personalizer.for_deployment(enc, ["left", "right"])
p.fit(X_cal, y_cal)
profile = p.export_profile() # head + transform + thresholds + cal Z
warm = Personalizer.from_profile(profile, encoder=enc)
warm.partial_fit(X_day2[:40], y_day2[:40]) # optional top-up
```
Day-2 warm-start (profile + `partial_fit` with n≈40) beats frozen day-1 in the locked readout (**+1.03 pp**, CI \[+0.11, +2.33], 0/9 no-regret). Re-attach the same encoder on `from_profile` / `load`.
Also: `Personalizer.save` / `load` for the full artifact layout.
## Motor imagery
### Quick factory
```python theme={null}
from nimbus_bci import Personalizer
# OAS / tangent features from epochs — needs [riemann]
p = Personalizer.for_riemann(["left", "right"]) # tracking_alpha=0.05 default
p.fit(X_cal, y_cal) # epochs (n, ch, t) or already-mapped features per API
p.adapt(epoch) # label-free reference update when tracking is on
```
### Session recipe (geometry gateway)
```python theme={null}
from nimbus_bci.middleware.mi_session import fit_mi_session, MISessionBundle
bundle = fit_mi_session(X_cal, y_cal, classes=["left", "right"], sfreq=250.0)
# bundle.personalizer is fitted; bundle.path is deployment_z (CSP) or features_riemann
bundle.save("subject_mi")
# Day 2 — pickle sidecars require trust + digest check:
loaded = MISessionBundle.load("subject_mi", trusted=True)
```
Optional BALD cal shrink: `calibrate_mi_bald(...)` — **do not default** it for production cal-time claims (EP-CAL-TIME NO-GO).
## P300
```python theme={null}
from nimbus_bci import Personalizer
from nimbus_bci.middleware.p300_session import fit_p300_session, P300SessionBundle
# Factory — tracking off by default; enable with tracking_alpha=0.05
p = Personalizer.for_p300(["NonTarget", "Target"], tracking_alpha=0.05)
# Or one-call session fit (xDAWN→tangent → LDA)
bundle = fit_p300_session(
X_cal, y_cal,
classes=["NonTarget", "Target"],
tracking_alpha=0.05, # recommend 0.05 when enabling; 0.02 not stability-cleared
)
bundle.save("subject_p300")
loaded = P300SessionBundle.load("subject_p300", trusted=True)
```
Claim framing for BNCI2014\_008: **parity + delivered ITR**, not accuracy superiority.
## Session-bundle trust contract
`MISessionBundle` / `P300SessionBundle` joblib sidecars are **pickle** payloads:
* `load(..., trusted=True)` is required
* `save` records `sidecar_sha256`; `load` verifies before unpickle
* Tampered sidecars are refused even with `trusted=True`
* Riemann `.npz` path never unpickles
## Embedding transforms (brief)
Full guide: [Embedding transforms](/personalizer/transforms).
| Transform | Role |
| ------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `standardize` / `whiten` / `euclidean_alignment` | Unsupervised; fit from cal |
| `coral` / `rpa` / `affine` | Two-distribution / supervised maps — **offline/research**, not deployment defaults |
```python theme={null}
p = Personalizer.for_research(enc, ["left", "right"], transform="whiten")
p.fit(X_cal, y_cal)
# coral / affine need a stream slice after fit:
# p.fit_coral(X_stream) / p.fit_affine(X_stream, y_stream)
```
## Gate calibration
Default decision gates accept **7–78%** of trials across trunks. Pin the accept rate:
```python theme={null}
p = Personalizer.for_deployment(enc, ["left", "right"])
p.fit_calibrated(X_cal, y_cal, X_val, y_val, target_accept=0.7)
```
## Next read
wrap, sfreq, save/load, profiles
strict / permissive gates
Breaking changes from 0.5
Compact MI / P300 recipes
# Research / PEFT
Source: https://docs.nimbusbci.com/personalizer/research-peft
Opt-in BaLoRA, LoRA/FiLM trunk arms, and research-only adaptation behind nimbus-bci[train].
Everything on this page is an **opt-in research surface**. No certified accuracy claims. Production default remains frozen trunk + Bayesian head (`for_deployment`).
Install:
```bash theme={null}
pip install nimbus-bci[train] # torch >= 2.14
```
## BaLoRA + composed uncertainty
Bayesian LoRA on the trunk with composed trunk+head BALD sampling:
```python theme={null}
from nimbus_bci import Personalizer, Recipe, MCSamplingConfig
# Auto-wraps a raw torch trunk (rank=4 default)
p = Personalizer.for_balora(torch_model, ["left", "right"], rank=4)
p.fit(X_cal, y_cal)
# Escalate trunk adaptation when your protocol calls for it:
# p.adapt_trunk(...) # see API / AdaptArm registry
# Or Recipe.BALORA_COMPOSED / MCSamplingConfig.balora() presets:
# MCSamplingConfig.balora(n_trunk=8, n_head=32)
# MCSamplingConfig.balora_fast() / .balora_standard()
```
`mc_samples=(n_trunk, n_head)` marginalises over the BaLoRA α-posterior and the Bayesian head.
## Trunk-arm PEFT
| Tool | Role |
| ---------------------------- | ----------------------------------------------------------- |
| `wrap_lora` | LoRA on Linear + Conv2d |
| FiLM rung | Feature-wise linear modulation arm |
| `AdaptArm` registry | Named arms with measured cost estimates |
| `Personalizer.for_lora(...)` | Convenience factory when the trunk already has / needs LoRA |
```python theme={null}
from nimbus_bci.middleware.adapters.lora import wrap_lora
from nimbus_bci import Personalizer, available_arms
enc = wrap_lora(torch_model, ...)
p = Personalizer.for_research(enc, ["left", "right"])
print(available_arms()) # cost estimates for trainer arms
```
Trainer arms accept hyperparameter overrides. Prefer measuring cost on your hardware before comparing to head-only `partial_fit`.
## Other research-only mechanisms
| API | Role |
| --------------------- | ---------------------------------------------------------------- |
| `adapt_pseudo` | Gated self-training on an unlabeled stream |
| `fit_tent` | Entropy minimization over trunk norm affine params |
| `T3APrototypeChannel` | Optimization-free recalibration on verified labels |
| `adapt_errp` | Soft-negative conjugate update from an external ErrP probability |
| `fit_population` | Warm-start from pooled LOSO posteriors + prior-predictive gate |
These ship for experimentation — confirmation-drift and protocol risks apply.
## Next read
When head-only beats FT on cost
Embedding-space alignment
Breaking changes and adoption map
Personalizer methods and configs
# Embedding transforms
Source: https://docs.nimbusbci.com/personalizer/transforms
Align frozen embeddings before the Bayesian head: standardize, whiten, CORAL, RPA, affine, and Euclidean alignment.
The Personalizer can apply an **embedding-space transform** on `Z` before the head. Use this when cal and stream embeddings shift, without retraining the trunk.
## Families
| Family | Names | When |
| --------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
| **Cal-fitted (unsupervised)** | `standardize`, `whiten`, `euclidean_alignment` | Fit at `fit()` from calibration embeddings |
| **Two-distribution / supervised** | `coral`, `rpa`, `affine` | Need a stream (or second) slice after fit — **offline/research**, not deployment defaults |
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner", embedding_dim=64)
# Cal-fitted — common research path
p = Personalizer.for_research(enc, ["left", "right"], transform="whiten")
p.fit(X_cal, y_cal)
# Two-distribution maps — after fit, pass a shifted stream slice
p2 = Personalizer.for_research(enc, ["left", "right"], transform="coral")
p2.fit(X_cal, y_cal)
p2.fit_coral(X_stream) # real two-distribution CORAL (Sun & Saenko 2016)
# p2.fit_rpa(X_stream, trim_windows=...)
# p2.fit_affine(X_stream, y_stream) # supervised L2 ridge toward identity
```
Or pass a stream at fit time when supported: `fit(..., X_stream=...)`.
## Quick guidance
| Transform | Intent |
| --------------------- | ----------------------------------------------------- |
| `standardize` | Per-dim mean/std from cal |
| `whiten` | Decorrelate + unit variance from cal |
| `euclidean_alignment` | EA-style recentering in embedding space |
| `coral` | Align second-order stats to a target distribution |
| `rpa` | Re-centering with optional `trim_windows=` robustness |
| `affine` | Supervised affine map; ridge toward identity |
EMA-damped label-free CORAL refits use `ema_beta=` on supported paths.
Prefer **no transform** (or cal-fitted only) for deployment. Treat `coral` / `rpa` / `affine` as research / offline alignment — they are not the product default.
## Types
Public types: `StandardizeTransform`, `WhitenTransform`, `CoralTransform`, `RpaTransform`, `EuclideanAlignment` (also importable from `nimbus_bci`).
## Next read
MI / P300 recipes and day-2 profiles
wrap → Personalizer → save/load
BaLoRA and trunk-arm adaptation
Full symbol list
# Active Learning
Source: https://docs.nimbusbci.com/python-sdk/active-learning
Use nimbus-bci CalibrationSession and active learning helpers to reduce calibration time with pool-based trial ranking, streaming query gates, and label-free stopping.
Use active learning to collect labels for the trials that are most likely to improve your BCI model. The Python SDK exposes `CalibrationSession` for full calibration loops, plus helpers for pool ranking, streaming label requests, and label-free stopping.
Active learning answers **which trials to label**. That is different from `Personalizer.adapt` / `recommend_adapt` (whether / how to update online) and from `BrainState` trial rejection. See [Personalizer overview](/personalizer/overview).
Active learning operates on **preprocessed feature rows** (or already-encoded embeddings), not raw EEG. Use arrays shaped `(n_trials, n_features)` for pools and `(n_features,)` or `(1, n_features)` for single streaming trials. With Personalizer, prefer the wrappers `adapter.suggest_calibration_trials(...)` / `adapter.calibration_status(...)` (they encode first), or run AL on `adapter.head` with a pre-encoded pool.
## With Personalizer
Compose active learning with the product surface: choose labels, then update via `adapt` / `partial_fit`.
```python theme={null}
from nimbus_bci import Personalizer, wrap
from nimbus_bci.active_learning import CalibrationSession
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64)
adapter = Personalizer.for_deployment(enc, ["left", "right"])
adapter.fit(X_seed, y_seed)
# Personalizer wrappers encode the pool for you
ranked = adapter.suggest_calibration_trials(X_pool, n=4, strategy="bald")
y_new = collect_labels_for(ranked.indices)
adapter.partial_fit(X_pool[ranked.indices], y_new)
status = adapter.calibration_status(
X_probe, # never-labeled probe set
criterion="posterior_stability",
previous=None, # or a prior Personalizer / head snapshot
threshold=0.02,
)
# Or CalibrationSession on the head with a pre-encoded pool
session = CalibrationSession(
adapter.head,
Z_pool,
pool_strategy="bald",
batch_size=4,
stopping_threshold=0.02,
)
```
## Core Workflow
Start with a small seed calibration set, rank an unlabeled feature pool, collect labels for the most informative trials, update with `partial_fit()`, and stop when the posterior stops changing. For most applications, use `CalibrationSession` so pool bookkeeping and model snapshots stay consistent.
```mermaid theme={null}
flowchart LR
seedModel["Seed model"] --> unlabeledPool["Unlabeled feature pool"]
unlabeledPool --> rankTrials["CalibrationSession.suggest_next_trial"]
rankTrials --> labelTrials["Collect labels"]
labelTrials --> updateModel["partial_fit"]
updateModel --> stopCheck["calibration_sufficient"]
stopCheck -->|"not stable"| rankTrials
stopCheck -->|"stable"| calibratedModel["Calibrated model"]
```
```python theme={null}
from nimbus_bci import NimbusLDA
from nimbus_bci.active_learning import CalibrationSession
clf = NimbusLDA().fit(X_seed, y_seed)
session = CalibrationSession(
clf,
X_pool,
pool_strategy="bald",
batch_size=4,
stopping_threshold=0.02,
num_posterior_samples=256,
)
for _ in range(max_rounds):
ranked = session.suggest_next_trial()
global_indices = session.remaining_indices[ranked.indices]
y_new = collect_labels_for(global_indices)
session.update(ranked.indices, y_new)
status = session.calibration_sufficient()
if status.is_sufficient:
break
```
## CalibrationSession
Use `CalibrationSession` when you want the SDK to manage the active pool, selected index history, `partial_fit()` updates, and the previous model snapshot needed by `posterior_stability`.
```python theme={null}
from nimbus_bci.active_learning import CalibrationSession
session = CalibrationSession(
clf,
X_pool,
pool_strategy="bald",
streaming_strategy="entropy",
batch_size=4,
stopping_threshold=0.02,
streaming_threshold=1.0,
num_posterior_samples=256,
rng_seed=0,
)
ranked = session.suggest_next_trial()
global_indices = session.remaining_indices[ranked.indices]
y_new = collect_labels_for(global_indices)
session.update(ranked.indices, y_new)
status = session.calibration_sufficient()
decision = session.should_query(x_new)
```
`suggest_next_trial()` returns indices local to the current active pool. Use `session.remaining_indices[ranked.indices]` when labels are stored against the original pool. `update()` captures the pre-update model snapshot, calls `partial_fit()`, removes selected rows, and increments `round_index` and `n_labeled`.
For `posterior_stability`, call `session.calibration_sufficient()` only after at least one `session.update(...)`. Before then, there is no previous model snapshot to compare.
### Session State
Useful properties and history fields:
* `remaining_indices`: original pool indices still available for querying.
* `remaining_pool`: feature rows still available for querying.
* `n_remaining`: number of candidates left.
* `is_exhausted`: whether no candidates remain.
* `n_labeled`: number of labels applied through `update()`.
* `query_history`: `QueryResult` objects returned by session ranking calls.
* `stopping_history`: `CalibrationStatus` objects returned by stopping checks.
* `selected_global_indices`: original pool indices selected each round.
### When to Use Stateless Helpers
Use the lower-level helpers directly when you are building a custom loop, working with raw `NimbusModel` snapshots, or do not want the SDK to mutate a fitted classifier. `CalibrationSession.update(...)` requires a fitted Nimbus classifier with `partial_fit()`.
## Pool-Based Trial Ranking
Use `suggest_next_trial()` when you have an unlabeled pool of candidate feature rows and want the top `n` trials to label next.
```python theme={null}
from nimbus_bci.active_learning import suggest_next_trial
result = suggest_next_trial(
clf,
X_pool,
strategy="bald", # or "entropy", "margin", "least_confidence"
n=4,
num_posterior_samples=256,
rng_seed=0,
)
print(result.indices) # top pool indices, most informative first
print(result.scores) # one informativeness score per row in X_pool
```
`suggest_next_trial()` accepts either a fitted Nimbus classifier (`NimbusLDA`, `NimbusQDA`, `NimbusSoftmax`, `NimbusSTS`) or a raw `NimbusModel` snapshot. It returns a `QueryResult` dataclass with:
* `indices`: top-`n` indices into `X_pool`.
* `scores`: raw informativeness score for every row in `X_pool`.
* `strategy`: the strategy used.
* `n_posterior_samples`: posterior samples used for the score (`1` for cheap strategies).
`strategy="bald"` is supported for `NimbusLDA`, `NimbusQDA`, and `NimbusSoftmax`. It is not supported for `NimbusSTS` in this release because STS posterior sampling needs temporal-coupling support.
## Streaming Query Gate
Use `should_query()` when a single trial arrives during a live session and you need to decide whether asking for a label is worth the calibration cost.
```python theme={null}
from nimbus_bci.active_learning import should_query
decision = should_query(
clf,
x_new,
strategy="entropy",
threshold=1.0,
)
if decision.should_query:
request_label(x_new)
```
The result is a `StreamingQueryDecision` dataclass with:
* `should_query`: whether the score crossed the threshold.
* `score`: raw informativeness score.
* `threshold`: threshold used for the decision.
* `strategy`: strategy used.
`should_query()` intentionally supports only cheap strategies: `entropy`, `margin`, and `least_confidence`. Single-point BALD is too noisy; batch streaming trials and call `suggest_next_trial(strategy="bald")` if you need sample-based ranking.
## Stopping Calibration
Use `calibration_sufficient()` to stop collecting labels once additional cues are unlikely to change predictions over the pool.
```python theme={null}
from nimbus_bci.active_learning import calibration_sufficient
status = calibration_sufficient(
clf,
X_pool,
criterion="posterior_stability",
previous=previous_model_snapshot,
threshold=0.02,
)
if status.is_sufficient:
print("Calibration can stop")
```
`calibration_sufficient()` returns a `CalibrationStatus` dataclass with:
* `is_sufficient`: `True` when the criterion signal is below the threshold.
* `signal`: mean total variation for `posterior_stability`, or mean BALD for `expected_info_gain`.
* `threshold`: threshold used for the comparison.
* `criterion`: criterion used.
* `details`: extra diagnostic values such as max/min TV or BALD.
### Stopping Criteria
`posterior_stability` compares two consecutive model snapshots over the same `X_pool`. It measures the mean total-variation distance between `predict_proba` outputs and works for every Nimbus head, including `NimbusSTS`.
```python theme={null}
status = calibration_sufficient(
clf,
X_pool,
criterion="posterior_stability",
previous=previous,
threshold=0.02,
)
```
`expected_info_gain` measures mean BALD over the current pool. It does not use `previous`, and it is available for `NimbusLDA`, `NimbusQDA`, and `NimbusSoftmax`.
```python theme={null}
status = calibration_sufficient(
clf,
X_pool,
criterion="expected_info_gain",
threshold=0.05,
num_posterior_samples=256,
)
```
For conservative calibration, use `posterior_stability` as the primary stopping criterion and treat `expected_info_gain` as a supporting signal. Early models can be confidently wrong and may underestimate expected information gain.
## Strategy Guide
| Strategy | Query direction | Units | Works with STS? | Best use |
| ------------------ | -------------------------- | ---------------------------- | --------------- | -------------------------------------------- |
| `entropy` | Higher is more informative | Bits, `[0, log2(n_classes)]` | Yes | Fast default for uncertain predictions |
| `margin` | Lower is more informative | Probability gap, `[0, 1]` | Yes | Top-1 vs top-2 ambiguity |
| `least_confidence` | Higher is more informative | `1 - max(p)` | Yes | Simple max-confidence thresholding |
| `bald` | Higher is more informative | Bits, `[0, log2(n_classes)]` | No | Epistemic uncertainty from posterior samples |
## Practical Defaults
* Use `CalibrationSession` for end-to-end calibration loops.
* Start with `strategy="bald"` for pool-based calibration when using `NimbusLDA`, `NimbusQDA`, or `NimbusSoftmax`.
* Use `num_posterior_samples=256` for BALD ranking stability. Lower values can be faster but noisier.
* Use `strategy="entropy"` for streaming `should_query()` gates.
* Use `criterion="posterior_stability"` for label-free stopping, with a threshold near `0.02` as an initial tuning point.
* Keep `X_pool` fixed across a calibration round so scores and stability checks are comparable.
## Next Read
Function signatures and dataclass fields for active learning.
Combine real-time prediction with query gates and feedback.
Use Nimbus classifiers inside sklearn workflows.
Choose the right Bayesian head before calibration.
# Python SDK API Reference
Source: https://docs.nimbusbci.com/python-sdk/api-reference
Full nimbus-bci Python API reference for Personalizer, BrainState, wrap, NimbusLDA, NimbusQDA, NimbusSoftmax, NimbusSTS, and core inference utilities.
Complete reference for the nimbus-bci Python library.
## Start Here
Encoder contract and BrainState guide before diving into signatures.
Fit a Personalizer (or classical head) before diving into full API details.
Compare NimbusLDA, NimbusQDA, NimbusSoftmax, and NimbusSTS heads.
## Middleware
Product surface: frozen trunk → Bayesian head → `BrainState`. Narrative guide: [Personalizer & Middleware](/personalizer/overview).
### Personalizer
Bayesian personalization head over an optional frozen encoder.
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64)
# Preferred entry points (0.6):
adapter = Personalizer.for_deployment(enc, ["left", "right"], paradigm="motor_imagery")
# adapter = Personalizer.for_research(enc, ["left", "right"], transform="whiten")
# adapter = Personalizer.for_features(["left", "right"])
# adapter = Personalizer.for_riemann(["left", "right"]) # needs [riemann]
# adapter = Personalizer.for_p300(["NonTarget", "Target"]) # needs [riemann]
```
**Factories:** `for_deployment`, `for_research`, `for_features`, `for_riemann`, `for_p300`, `for_balora` (needs `[train]`).
**Methods:**
* `fit(X, y, *, input_sfreq=None)` — calibrate on labeled trials
* `fit_calibrated(X, y, X_val, y_val, *, target_accept=...)` — fit + pin decision-gate accept rate
* `partial_fit(X, y, classes=None)` — cumulative online head update
* `adapt(X, y=None, level=...)` — label-agnostic adaptation gateway
* `adapt_async(...)` — shadow trunk adaptation with atomic commit
* `predict(X)` → `list[BrainState]` or `BrainStateBatch`
* `predict_one(x)` → `BrainState`
* `export_profile()` / `Personalizer.from_profile(profile, encoder=...)` — portable profile
* `recommend_adapt(X_stream, *, tau, X_ref=None)` → `AdaptDecision` — thin mean-shift helper (L0 vs L1 only; **user-supplied** `tau`)
* `update_from_prediction(...)` — optional feedback path
* `save(path)` / `Personalizer.load(path, encoder=...)` — persist head; **re-attach encoder on load**
Also exported: `Recipe`, `UserProfile`, `BrainStateBatch`, `recommend_adapt`, `embedding_mean_shift`, `AdaptDecision`, transform types.
**Paradigm helpers** (see [Paradigms & profiles](/personalizer/paradigms)):
* `fit_mi_session` / `calibrate_mi_bald` / `MISessionBundle` — MI geometry gateway + day-2 bundle
* `fit_p300_session` / `P300SessionBundle` — xDAWN→tangent + day-2 bundle (`trusted=True` on load)
* Session bundles verify `sidecar_sha256` before unpickling joblib sidecars
**Also see:** [Transforms](/personalizer/transforms) · [Research / PEFT](/personalizer/research-peft) · [Changelog 0.6](/python-sdk/changelog-0.6)
**Notes:**
* Prefer factories over raw constructors for common cases
* Decision presets: `"strict"` / `"permissive"` / `"aggressive"` (0.6 rename; was `research` / `consumer`)
* `encoder=None` / `for_features` uses classical features already shaped `(n, d)`
* `head="softmax"` requires `pip install nimbus-bci[softmax]`
* Default `partial_fit` is cumulative (`λ=1`); optional forgetting uses `λ\<1` on LDA/QDA
* After `load` / `from_profile`, pass `X_ref=` to `recommend_adapt` when needed
* Trial rejection (`BrainState.rejected`) ≠ adapt recommendation (`should_adapt`)
### wrap / FrozenEncoder
```python theme={null}
from nimbus_bci import wrap
enc = wrap(
model.encode, # callable | sklearn transformer | torch Module
model_id="partner-encoder",
embedding_dim=64, # optional width check
)
Z = enc.encode(X) # (n, d)
```
Optional shortcuts: `wrap_eegnet(clf)`, `wrap_braindecode(module, kind="eegnetv4"|"reve"|"eegpt")`.
### BrainState
App-facing decision object.
**Fields:** `intent`, `confidence`, `posterior`, `uncertainty`, `alternatives`, `need_more_data`, `rejected`, `rejection_reason`, `embedding`, `user_id`, `model_id`, `paradigm`, `extras`
**Methods:** `to_dict()` — JSON-friendly export (`brain_state_version` currently `1`)
### Prediction / decide
```python theme={null}
from nimbus_bci import Prediction, decide
state = decide(
Prediction(
classes=["left", "right"],
probabilities=[0.7, 0.3],
uncertainty=0.2,
),
preset="strict",
user_id="subject_001",
)
```
### Bridges
```python theme={null}
from nimbus_bci import (
brain_states_from_batch,
brain_state_from_streaming_result,
predict_batch,
)
states = brain_states_from_batch(predict_batch(clf, X), classes=["left", "right"])
state = brain_state_from_streaming_result(final, classes=["left", "right"])
```
Also exported: `fuse_uncertainty`, `resolve_preset`, `DecisionPreset`, `IntentHypothesis`, `EncoderProtocol`.
## Classifiers
Bayesian heads used by `Personalizer(head=...)` and as standalone sklearn classifiers.
### NimbusLDA
Bayesian Linear Discriminant Analysis with shared covariance. Default Personalizer head (`head="lda"`).
```python theme={null}
from nimbus_bci import NimbusLDA
clf = NimbusLDA(
mu_loc=0.0,
mu_scale=3.0,
wishart_df=None,
class_prior_alpha=1.0,
# 0.6: prior_fraction= / forgetting= on LDA/QDA — see parameters below
)
```
**Parameters:**
* `mu_loc` (float, default=0.0): Prior mean location for class means
* `mu_scale` (float, default=3.0): Prior scale for class means (> 0)
* `wishart_df` (float or None, default=None): Wishart degrees of freedom. If None, set to `n_features + 2`
* `class_prior_alpha` (float, default=1.0): Dirichlet smoothing for class priors (≥ 0)
* `prior_fraction` (float or None, default=None on the classifier; product heads often use `0.05`): Conjugate prior weight as a fraction of `n` (`None` = identity prior)
* `forgetting` (float, default=1.0): Recency factor on online updates; `λ\<1` optional (`λ=1` is byte-identical to cumulative)
**Methods:**
* `fit(X, y)`: Fit the model
* `predict(X)`: Predict class labels
* `predict_proba(X)`: Predict class probabilities
* `partial_fit(X, y, classes=None)`: Incremental learning
* `score(X, y)`: Return accuracy score
**Attributes:**
* `classes_`: Unique class labels
* `n_classes_`: Number of classes
* `n_features_in_`: Number of features
* `model_`: Underlying Nimbus model
**Example:**
```python theme={null}
from nimbus_bci import NimbusLDA
import numpy as np
# Create and fit
clf = NimbusLDA(mu_scale=5.0)
clf.fit(X_train, y_train)
# Predict
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
# Online learning
clf.partial_fit(X_new, y_new)
```
### NimbusQDA
Bayesian QDA with class-specific covariances.
```python theme={null}
from nimbus_bci import NimbusQDA
clf = NimbusQDA(
mu_loc=0.0,
mu_scale=3.0,
wishart_df=None,
class_prior_alpha=1.0
)
```
**Parameters:**
* Same as NimbusLDA
**Methods:**
* Same as NimbusLDA
**Example:**
```python theme={null}
from nimbus_bci import NimbusQDA
# Better for overlapping distributions (e.g., P300)
clf = NimbusQDA()
clf.fit(X_train, y_train)
probs = clf.predict_proba(X_test)
```
### NimbusSoftmax
Bayesian Multinomial Logistic Regression (Polya-Gamma VI).
Install the optional extra before using this model:
```bash theme={null}
pip install nimbus-bci[softmax]
```
```python theme={null}
from nimbus_bci import NimbusSoftmax
clf = NimbusSoftmax(
w_loc=0.0,
w_scale=1.0,
b_loc=0.0,
b_scale=1.0,
learning_rate=0.2,
num_steps=50,
num_posterior_samples=50,
rng_seed=0
)
```
**Parameters:**
* `w_loc` (float, default=0.0): Prior mean for weights
* `w_scale` (float, default=1.0): Prior scale for weights
* `b_loc` (float, default=0.0): Prior mean for biases
* `b_scale` (float, default=1.0): Prior scale for biases
* `learning_rate` (float, default=0.2): Damping factor for variational updates
* `num_steps` (int, default=50): Number of variational update sweeps
* `num_posterior_samples` (int, default=50): Number of posterior samples for prediction
* `rng_seed` (int, default=0): Random seed for reproducibility
**Methods:**
* Same as NimbusLDA
**Example:**
```python theme={null}
from nimbus_bci import NimbusSoftmax
# For non-Gaussian decision boundaries
clf = NimbusSoftmax()
clf.fit(X_train, y_train)
predictions = clf.predict(X_test)
```
### NimbusSTS
Bayesian Structural Time Series classifier with Extended Kalman Filter for non-stationary data.
```python theme={null}
from nimbus_bci import NimbusSTS
clf = NimbusSTS(
state_dim=None,
w_loc=0.0,
w_scale=1.0,
transition_cov=None,
observation_cov=1.0,
transition_matrix=None,
learning_rate=0.1,
num_steps=50,
rng_seed=0,
verbose=False
)
```
**Parameters:**
* `state_dim` (int or None, default=None): Dimension of latent state. If None, set to `n_classes - 1`
* `w_loc` (float, default=0.0): Prior mean for feature weights
* `w_scale` (float, default=1.0): Prior scale for feature weights
* `transition_cov` (float or None, default=None): Process noise covariance Q (controls drift speed). If None, auto-estimated. Typical values:
* 0.001: Very slow drift (multi-day stability)
* 0.01: Moderate drift (within-session adaptation)
* 0.1: Fast drift (rapid environmental changes)
* `observation_cov` (float, default=1.0): Observation noise covariance R
* `transition_matrix` (ndarray or None, default=None): State transition matrix A. If None, uses identity (random walk)
* `learning_rate` (float, default=0.1): Step size for parameter updates
* `num_steps` (int, default=50): Number of learning iterations
* `rng_seed` (int, default=0): Random seed for reproducibility
* `verbose` (bool, default=False): Print convergence diagnostics during training
**Methods:**
* `fit(X, y)`: Fit the model
* `predict(X)`: Predict class labels (stateless)
* `predict_proba(X)`: Predict class probabilities (stateless)
* `partial_fit(X, y, classes=None)`: Incremental learning with EKF update
* `score(X, y)`: Return accuracy score
* `propagate_state(n_steps=1)`: Advance latent state using prior dynamics only
* `reset_state()`: Reset latent state to initial values from training
* `get_latent_state()`: Get current latent state (z\_mean, z\_cov)
* `set_latent_state(z_mean, z_cov=None)`: Set latent state manually
**Attributes:**
* `classes_`: Unique class labels
* `n_classes_`: Number of classes
* `n_features_in_`: Number of features
* `model_`: Underlying Nimbus model with state parameters
**Example - Basic Usage:**
```python theme={null}
from nimbus_bci import NimbusSTS
import numpy as np
# Create and fit
clf = NimbusSTS(transition_cov=0.05, num_steps=100)
clf.fit(X_train, y_train)
# Standard prediction (stateless)
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
```
**Example - Stateful Prediction:**
```python theme={null}
from nimbus_bci import NimbusSTS
# Train model
clf = NimbusSTS(transition_cov=0.05)
clf.fit(X_train, y_train)
# Time-ordered prediction with state propagation
for x_t in X_stream:
clf.propagate_state() # Advance time
pred = clf.predict(x_t.reshape(1, -1))
print(f"Prediction: {pred}")
```
**Example - Online Learning with Delayed Feedback:**
```python theme={null}
from nimbus_bci import NimbusSTS
# Initial training
clf = NimbusSTS(transition_cov=0.05, learning_rate=0.1)
clf.fit(X_calibration, y_calibration)
# Online session
for trial in online_session:
# 1. Advance time (no measurement)
clf.propagate_state()
# 2. Predict using current state
prediction = clf.predict(trial.features.reshape(1, -1))[0]
# 3. Execute action
execute_action(prediction)
# 4. Get feedback after action completes
true_label = wait_for_feedback()
# 5. Update state with measurement
clf.partial_fit(trial.features.reshape(1, -1), [true_label])
```
**Example - State Inspection and Transfer:**
```python theme={null}
from nimbus_bci import NimbusSTS
# Day 1: Train and save state
clf_day1 = NimbusSTS()
clf_day1.fit(X_day1, y_day1)
z_final, P_final = clf_day1.get_latent_state()
# Day 2: Transfer state with increased uncertainty
clf_day2 = NimbusSTS()
clf_day2.fit(X_day2_calib, y_day2_calib) # Minimal calibration
clf_day2.set_latent_state(z_final * 0.5, P_final * 2.0)
# Use with transferred state
predictions = clf_day2.predict(X_day2_test)
```
**Key Differences from Other Classifiers:**
* **Stateful**: Maintains and evolves latent state over time
* **Non-stationary**: Designed for data with temporal drift
* **State Management**: Explicit API for time propagation and state control
* **Use case**: Long sessions, cross-day transfer, adaptive BCI
See [Bayesian STS Documentation](/models/rxsts) for complete usage guide.
## Optional Riemannian Pipelines
### `make_riemann_nimbus_pipeline()`
Factory for composing pyRiemann covariance/tangent feature extraction with an existing Nimbus classifier head.
Install the optional extra before using this API:
```bash theme={null}
pip install nimbus-bci[riemann]
```
```python theme={null}
from nimbus_bci import NimbusLDA
from nimbus_bci.riemann import make_riemann_nimbus_pipeline
pipe = make_riemann_nimbus_pipeline(
head=NimbusLDA(),
covariance_estimator="oas",
tangent_metric="riemann",
)
```
**Parameters:**
* `head` (object or None, default=None): Final sklearn-compatible classifier exposing `fit`, `predict`, and `predict_proba`. Defaults to `NimbusLDA()` when omitted.
* `covariance_estimator` (str, default=`"oas"`): Estimator passed to `pyriemann.estimation.Covariances`.
* `tangent_metric` (str, default=`"riemann"`): Metric passed to `pyriemann.tangentspace.TangentSpace`.
**Returns:**
* `sklearn.pipeline.Pipeline`: Pipeline with `covariances`, `tangent`, and `head` steps.
**Example:**
```python theme={null}
from nimbus_bci import NimbusLDA
from nimbus_bci.riemann import make_riemann_nimbus_pipeline
# X_epochs shape: (n_trials, n_channels, n_times)
pipe = make_riemann_nimbus_pipeline(head=NimbusLDA(mu_scale=5.0))
pipe.fit(X_epochs_train, y_train)
probs = pipe.predict_proba(X_epochs_test)
predictions = pipe.predict(X_epochs_test)
```
Nimbus supports Riemannian workflows by consuming tangent-space features from pyRiemann; this is not a separate Riemannian Bayesian model. The returned object is a standard sklearn `Pipeline`, so do not rely on pipeline-level `partial_fit()` for this path.
## Data Structures
### BCIData
Container for BCI features, metadata, and labels.
```python theme={null}
from nimbus_bci.data import BCIData
data = BCIData(
features, # (n_features, n_samples, n_trials) or (n_features, n_samples)
metadata, # BCIMetadata instance
labels=None # Optional labels
)
```
**Parameters:**
* `features` (np.ndarray): Feature array of shape `(n_features, n_samples, n_trials)` for multiple trials or `(n_features, n_samples)` for one trial
* `metadata` (BCIMetadata): Metadata describing the data
* `labels` (np.ndarray, optional): Trial labels
**Attributes:**
* `features`: Feature array
* `metadata`: Metadata object
* `labels`: Labels (if provided)
* `n_trials`: Number of trials
* `n_samples`: Number of samples per trial
### BCIMetadata
Metadata for BCI experiments.
```python theme={null}
from nimbus_bci.data import BCIMetadata
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=16,
n_classes=4,
chunk_size=None,
temporal_aggregation="mean"
)
```
**Parameters:**
* `sampling_rate` (float): Sampling rate in Hz
* `paradigm` (str): BCI paradigm (`"motor_imagery"`, `"p300"`, `"ssvep"`, `"erp"`, or `"custom"`)
* `feature_type` (str): Feature type (`"raw"`, `"csp"`, `"bandpower"`, `"erp_amplitude"`, or `"custom"`)
* `n_features` (int): Number of features
* `n_classes` (int): Number of classes
* `chunk_size` (int, optional): Chunk size for streaming
* `temporal_aggregation` (str, default="mean"): Aggregation method (`"mean"`, `"logvar"`, `"last"`, `"max"`, `"median"`, `"var"`, or `"std"`)
## Inference
### predict\_batch()
Batch inference with comprehensive diagnostics.
```python theme={null}
from nimbus_bci import predict_batch
result = predict_batch(
model, # Trained model
data, # BCIData instance
num_posterior_samples=50,
rng_seed=0
)
```
**Parameters:**
* `model` (NimbusModel): Trained Nimbus model
* `data` (BCIData): Data to predict on
* `num_posterior_samples` (int, default=50): Posterior samples for softmax models
* `rng_seed` (int, default=0): Random seed for softmax prediction
**Returns:**
* `BatchResult`: Result object with predictions, posteriors, entropy, and diagnostics
**Example:**
```python theme={null}
from nimbus_bci import predict_batch, NimbusLDA
from nimbus_bci.data import BCIData, BCIMetadata
import numpy as np
clf = NimbusLDA()
clf.fit(X_train, y_train)
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=16,
n_classes=4
)
# BCIData expects (n_features, n_samples, n_trials).
# Duplicate tabular test features across a short temporal axis for this example.
features = np.repeat(X_test.T[:, np.newaxis, :], 2, axis=1)
data = BCIData(features, metadata)
result = predict_batch(clf.model_, data)
print(f"Accuracy: {(result.predictions == y_test).mean():.2%}")
print(f"Mean entropy: {result.mean_entropy:.2f} bits")
```
### StreamingSession
Real-time chunk-by-chunk processing.
```python theme={null}
from nimbus_bci import StreamingSession
session = StreamingSession(
model, # Trained model
metadata # BCIMetadata with chunk_size
)
```
**Methods:**
* `process_chunk(chunk)`: Process one chunk, returns ChunkResult
* `finalize_trial(method="weighted_vote")`: Finalize trial, returns StreamingResult
* `reset()`: Reset session for new trial
**Example:**
```python theme={null}
from nimbus_bci import NimbusLDA, StreamingSession
from nimbus_bci.data import BCIMetadata
clf = NimbusLDA()
clf.fit(X_train, y_train)
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=16,
n_classes=4,
chunk_size=125, # 500ms chunks
temporal_aggregation="logvar"
)
session = StreamingSession(clf.model_, metadata)
# Process chunks
for chunk in stream:
result = session.process_chunk(chunk)
print(f"Chunk: class {result.prediction} ({result.confidence:.2%})")
# Finalize
final = session.finalize_trial(method="weighted_vote")
print(f"Final: class {final.prediction}")
```
### ChunkResult
Result from processing a single chunk.
**Attributes:**
* `prediction` (int): Predicted class
* `confidence` (float): Confidence (max probability)
* `posterior` (np.ndarray): Class posterior probabilities
* `latency_ms` (float): Processing latency in milliseconds
### StreamingResult
Result from finalizing a trial.
**Attributes:**
* `prediction` (int): Final predicted class
* `confidence` (float): Final confidence
* `posterior` (np.ndarray): Aggregated posterior probabilities
* `chunk_posteriors` (list): Posterior from each chunk
* `entropy` (float): Final entropy
* `aggregation_method` (str): Method used for aggregation
* `n_chunks` (int): Number of chunks processed
* `latency_ms` (float): Total trial inference latency
* `chunk_latencies_ms` (list): Latency for each chunk
* `balance` (float): Class balance across chunks
* `calibration` (CalibrationMetrics or None): Calibration metrics if a label was provided
### BatchResult
Result from batch inference.
**Attributes:**
* `predictions` (np.ndarray): Predicted classes
* `confidences` (np.ndarray): Maximum posterior probability per trial
* `posteriors` (np.ndarray): Class posterior probabilities
* `entropy` (np.ndarray): Entropy per trial
* `mean_entropy` (float): Mean entropy
* `mahalanobis_distances` (np.ndarray): Distance to each class center
* `outlier_scores` (np.ndarray): Outlier score per trial
* `balance` (float): Class balance
* `latency_ms` (float): Inference latency
* `per_trial_latency_ms` (np.ndarray): Estimated latency per trial
* `calibration` (CalibrationMetrics or None): Calibration metrics if labels were provided
## Metrics
### New in 0.6
```python theme={null}
from nimbus_bci.metrics import (
cohen_kappa,
roc_auc_score,
brier_score,
temperature_scale_logits,
)
kappa = cohen_kappa(y_true, y_pred)
auc = roc_auc_score(y_true, y_score) # see docstring for multi-class shapes
brier = brier_score(y_true, y_proba)
scaled = temperature_scale_logits(logits, temperature=T)
```
Also: artifact-robust `trial_quality` scoring knobs used with Personalizer evaluation.
### compute\_entropy()
Compute Shannon entropy from probabilities.
```python theme={null}
from nimbus_bci import compute_entropy
entropy = compute_entropy(probabilities) # bits
```
**Parameters:**
* `probabilities` (np.ndarray): Probability distributions
**Returns:**
* `float`: Entropy in bits. For a 2D probability matrix, this is the mean entropy across rows.
### compute\_calibration\_metrics()
Compute Expected Calibration Error (ECE) and Maximum Calibration Error (MCE).
```python theme={null}
from nimbus_bci import compute_calibration_metrics
metrics = compute_calibration_metrics(
predictions,
confidences,
labels,
n_bins=10
)
```
**Parameters:**
* `predictions` (np.ndarray): Predicted classes
* `confidences` (np.ndarray): Confidence scores
* `labels` (np.ndarray): True labels
* `n_bins` (int, default=10): Number of bins
**Returns:**
* `CalibrationMetrics`: Object with `ece` and `mce` attributes
### calculate\_itr()
Calculate Information Transfer Rate.
```python theme={null}
from nimbus_bci import calculate_itr
itr = calculate_itr(
accuracy=0.85,
n_classes=4,
trial_duration=4.0
)
```
**Parameters:**
* `accuracy` (float): Classification accuracy (0-1)
* `n_classes` (int): Number of classes
* `trial_duration` (float): Trial duration in seconds
**Returns:**
* `float`: ITR in bits/minute
### assess\_trial\_quality()
Assess quality of predictions.
```python theme={null}
from nimbus_bci import assess_trial_quality
quality = assess_trial_quality(
features,
confidence,
entropy=entropy,
confidence_threshold=0.7
)
```
**Parameters:**
* `features` (np.ndarray): Trial features to check for NaN/Inf artifacts
* `confidence` (float): Prediction confidence in `[0, 1]`
* `confidence_threshold` (float, default=0.6): Minimum confidence for accepting prediction
* `outlier_threshold` (float, default=5.0): Maximum outlier score for accepting prediction
* `entropy` (float, optional): Prediction entropy in bits
* `outlier_score` (float, optional): Mahalanobis-based outlier score
* `entropy_threshold` (float, default=1.5): Maximum entropy for accepting prediction
**Returns:**
* `TrialQuality`: Object with quality metrics
### should\_reject\_trial()
Determine if trial should be rejected based on confidence.
```python theme={null}
from nimbus_bci import should_reject_trial
reject = should_reject_trial(confidence, threshold=0.7)
```
**Parameters:**
* `confidence` (float): Confidence score
* `threshold` (float, default=0.7): Rejection threshold
**Returns:**
* `bool`: True if trial should be rejected
## Utilities
### estimate\_normalization\_params()
Estimate normalization parameters from data.
```python theme={null}
from nimbus_bci import estimate_normalization_params
params = estimate_normalization_params(
X,
method="zscore" # or "minmax", "robust"
)
```
**Parameters:**
* `X` (np.ndarray): Data array
* `method` (str): Normalization method
**Returns:**
* `NormalizationParams`: Parameters for normalization
### apply\_normalization()
Apply normalization to data.
```python theme={null}
from nimbus_bci import apply_normalization
X_normalized = apply_normalization(X, params)
```
**Parameters:**
* `X` (np.ndarray): Data to normalize
* `params` (NormalizationParams): Normalization parameters
**Returns:**
* `np.ndarray`: Normalized data
### diagnose\_preprocessing()
Diagnose preprocessing quality.
```python theme={null}
from nimbus_bci import diagnose_preprocessing
report = diagnose_preprocessing(
bci_data
)
```
**Parameters:**
* `data` (BCIData): Data to diagnose
**Returns:**
* `PreprocessingReport`: Diagnostic report
### compute\_fisher\_score()
Compute Fisher score for feature discriminability.
```python theme={null}
from nimbus_bci import compute_fisher_score
scores = compute_fisher_score(X, y)
```
**Parameters:**
* `X` (np.ndarray): Features
* `y` (np.ndarray): Labels
**Returns:**
* `np.ndarray`: Fisher scores per feature
### rank\_features\_by\_discriminability()
Rank features by discriminability.
```python theme={null}
from nimbus_bci import rank_features_by_discriminability
ranking = rank_features_by_discriminability(X, y)
```
**Parameters:**
* `X` (np.ndarray): Features
* `y` (np.ndarray): Labels
**Returns:**
* `np.ndarray`: Feature indices sorted by discriminability
## MNE Integration
### from\_mne\_epochs()
Convert MNE Epochs to BCIData.
```python theme={null}
from nimbus_bci.compat import from_mne_epochs
data = from_mne_epochs(
epochs,
paradigm="motor_imagery",
feature_type="raw"
)
```
**Parameters:**
* `epochs` (mne.Epochs): MNE Epochs object
* `paradigm` (str): BCI paradigm
* `feature_type` (str): Feature type
**Returns:**
* `BCIData`: Converted data
### extract\_csp\_features()
Extract CSP features from MNE Epochs.
```python theme={null}
from nimbus_bci.compat import extract_csp_features
features, csp = extract_csp_features(
epochs,
n_components=8
)
```
**Parameters:**
* `epochs` (mne.Epochs): MNE Epochs object
* `n_components` (int): Number of CSP components
**Returns:**
* `features` (np.ndarray): CSP features
* `csp` (mne.decoding.CSP): Fitted CSP object
### extract\_bandpower\_features()
Extract bandpower features from MNE Epochs.
```python theme={null}
from nimbus_bci.compat import extract_bandpower_features
features, band_names = extract_bandpower_features(
epochs,
bands={"mu": (8, 12), "beta": (13, 30)},
log_transform=True
)
```
**Parameters:**
* `epochs` (mne.Epochs): MNE Epochs object
* `bands` (dict): Frequency bands
* `log_transform` (bool, default=True): Apply log transform to band powers
**Returns:**
* `tuple[np.ndarray, list[str]]`: Bandpower features and band names
### create\_bci\_pipeline()
Create complete BCI pipeline with MNE and nimbus-bci.
```python theme={null}
from nimbus_bci.compat import create_bci_pipeline
from nimbus_bci import NimbusLDA
pipeline = create_bci_pipeline(
NimbusLDA,
feature_extraction="csp",
n_csp_components=8,
)
```
**Parameters:**
* `model_class` (class): Classifier class (`NimbusLDA`, `NimbusQDA`, or `NimbusSoftmax`)
* `preprocessor` (str, default="standard"): Preprocessing method (`"standard"`, `"robust"`, or `None`)
* `feature_extraction` (str, optional): Feature extraction method (`"csp"` or `None`)
* `n_csp_components` (int, default=8): Number of CSP components
* `**model_kwargs`: Additional arguments passed to the classifier
**Returns:**
* `sklearn.pipeline.Pipeline`: Complete pipeline
## Functional API (Backward Compatible)
### LDA Functions
```python theme={null}
from nimbus_bci import (
nimbus_lda_fit,
nimbus_lda_predict,
nimbus_lda_predict_proba,
nimbus_lda_update
)
import numpy as np
# Fit
model = nimbus_lda_fit(
X,
y,
n_classes=4,
label_base=0,
mu_loc=0.0,
mu_scale=3.0,
wishart_df=X.shape[1] + 2,
wishart_scale=np.eye(X.shape[1]),
class_prior_alpha=1.0,
)
# Predict
probs = nimbus_lda_predict_proba(model, X_test)
preds = nimbus_lda_predict(model, X_test)
# Update
model = nimbus_lda_update(model, X_new, y_new)
```
### QDA Functions
```python theme={null}
from nimbus_bci import (
nimbus_qda_fit,
nimbus_qda_predict,
nimbus_qda_predict_proba,
nimbus_qda_update
)
import numpy as np
# Fit
model = nimbus_qda_fit(
X,
y,
n_classes=4,
label_base=0,
mu_loc=0.0,
mu_scale=3.0,
wishart_df=X.shape[1] + 2,
wishart_scale=np.eye(X.shape[1]),
class_prior_alpha=1.0,
)
probs = nimbus_qda_predict_proba(model, X_test)
```
### Softmax Functions
These functions require the optional `softmax` extra.
```python theme={null}
from nimbus_bci import (
nimbus_softmax_fit,
nimbus_softmax_predict,
nimbus_softmax_predict_proba,
nimbus_softmax_update
)
# Fit
model = nimbus_softmax_fit(
X,
y,
n_classes=4,
label_base=0,
w_loc=0.0,
w_scale=1.0,
b_loc=0.0,
b_scale=1.0,
rng_seed=0,
learning_rate=0.2,
num_steps=50,
)
# Predict
probs = nimbus_softmax_predict_proba(
model,
X_test,
num_posterior_samples=50,
rng_seed=1,
)
```
### STS Functions
```python theme={null}
from nimbus_bci import (
nimbus_sts_fit,
nimbus_sts_predict,
nimbus_sts_predict_proba,
nimbus_sts_update
)
# Fit
model = nimbus_sts_fit(
X, y,
n_classes=4,
label_base=0,
state_dim=None,
transition_cov=0.05,
observation_cov=1.0,
learning_rate=0.1,
num_steps=100,
rng_seed=0,
verbose=False
)
# Predict (with optional state evolution)
probs = nimbus_sts_predict_proba(model, X_test, evolve_state=False)
preds = nimbus_sts_predict(model, X_test)
# Update (online learning)
model = nimbus_sts_update(model, X_new, y_new, learning_rate=0.1)
```
**Note**: The functional API for STS provides lower-level control. For most use cases, prefer the `NimbusSTS` class with its state management methods.
### Model I/O
```python theme={null}
from nimbus_bci import nimbus_save, nimbus_load
# Save model
nimbus_save(model, "model.npz")
# Load model
model = nimbus_load("model.npz")
```
## Active Learning
Active learning helpers reduce calibration cost by ranking unlabeled feature rows, deciding whether streaming trials are worth labeling, and stopping calibration when the model posterior stabilizes.
```python theme={null}
from nimbus_bci.active_learning import (
CalibrationRound,
CalibrationSession,
CalibrationStatus,
QueryResult,
StreamingQueryDecision,
calibration_sufficient,
should_query,
suggest_next_trial,
)
```
All helpers accept either a fitted Nimbus classifier (`NimbusLDA`, `NimbusQDA`, `NimbusSoftmax`, `NimbusSTS`) or a raw `NimbusModel` snapshot.
Active learning expects preprocessed features. Use `X_pool` shaped `(n_pool, n_features)` for pool-based ranking and stopping, and `x_new` shaped `(n_features,)` or `(1, n_features)` for streaming query decisions.
### `CalibrationSession`
Stateful workflow object for active calibration loops. Use it when you want the SDK to manage active-pool bookkeeping, selected index history, `partial_fit()` updates, and previous model snapshots for `posterior_stability`.
```python theme={null}
CalibrationSession(
model,
X_pool,
*,
pool_strategy="bald",
streaming_strategy="entropy",
batch_size=1,
stopping_criterion="posterior_stability",
stopping_threshold=None,
streaming_threshold=None,
num_posterior_samples=256,
rng_seed=0,
)
```
**Parameters:**
* `model` (`NimbusModel` or fitted Nimbus classifier): Model used for scoring. `update(...)` requires a fitted Nimbus classifier with `partial_fit()`.
* `X_pool` (`np.ndarray`): Original unlabeled feature pool with shape `(n_pool, n_features)`.
* `pool_strategy` (`"entropy"`, `"margin"`, `"least_confidence"`, or `"bald"`, default=`"bald"`): Strategy used by `suggest_next_trial()`.
* `streaming_strategy` (`"entropy"`, `"margin"`, or `"least_confidence"`, default=`"entropy"`): Strategy used by `should_query()`.
* `batch_size` (int, default=`1`): Default number of pool candidates to request per round.
* `stopping_criterion` (`"posterior_stability"` or `"expected_info_gain"`, default=`"posterior_stability"`): Criterion used by `calibration_sufficient()`.
* `stopping_threshold` (float, optional): Default stopping threshold.
* `streaming_threshold` (float, optional): Default streaming query threshold.
* `num_posterior_samples` (int, default=`256`): Default sample count forwarded to active-learning helpers.
* `rng_seed` (int, default=`0`): Default deterministic seed.
**Methods:**
* `suggest_next_trial(...) -> QueryResult`: Rank the current active pool. Returned indices are local to `remaining_pool`.
* `update(chosen_indices, y_new) -> CalibrationSession`: Map pool-local indices to original pool rows, capture the pre-update snapshot, call `partial_fit()`, and remove selected rows.
* `calibration_sufficient(...) -> CalibrationStatus`: Evaluate whether calibration can stop.
* `should_query(x_new, ...) -> StreamingQueryDecision`: Delegate streaming query decisions with session defaults.
* `get_model() -> NimbusModel`: Return the current Nimbus model snapshot.
**Properties:**
* `remaining_indices`: Original pool indices still available.
* `remaining_pool`: Active feature rows still available.
* `n_remaining`: Number of active candidates left.
* `is_exhausted`: Whether no candidates remain.
* `round_index`: Number of completed update rounds.
* `n_labeled`: Number of labels applied through `update(...)`.
```python theme={null}
session = CalibrationSession(
clf,
X_pool,
pool_strategy="bald",
batch_size=4,
stopping_threshold=0.02,
)
ranked = session.suggest_next_trial()
global_indices = session.remaining_indices[ranked.indices]
y_new = collect_labels_for(global_indices)
session.update(ranked.indices, y_new)
status = session.calibration_sufficient()
```
For `posterior_stability`, `calibration_sufficient()` requires at least one prior `update(...)` unless you pass an explicit `previous` snapshot.
### `suggest_next_trial()`
Rank an unlabeled feature pool by informativeness and return the top `n` candidates.
```python theme={null}
suggest_next_trial(
model,
X_pool,
*,
strategy="bald",
n=1,
num_posterior_samples=256,
rng_seed=0,
) -> QueryResult
```
**Parameters:**
* `model` (`NimbusModel` or fitted Nimbus classifier): Model used to score candidates
* `X_pool` (`np.ndarray`): Unlabeled feature rows with shape `(n_pool, n_features)`
* `strategy` (`"entropy"`, `"margin"`, `"least_confidence"`, or `"bald"`, default=`"bald"`): Informativeness criterion
* `n` (int, default=`1`): Number of candidates to return
* `num_posterior_samples` (int, default=`256`): Posterior samples for `bald`; also forwarded to `NimbusSoftmax` probability estimates
* `rng_seed` (int, default=`0`): Deterministic seed for posterior sampling
**Returns:** `QueryResult`
* `indices`: Top-`n` indices into `X_pool`, sorted from most to least informative
* `scores`: Raw informativeness score for each row in `X_pool`
* `strategy`: Strategy used
* `n_posterior_samples`: Posterior samples used (`1` for cheap strategies)
```python theme={null}
previous = clf.get_model()
ranked = suggest_next_trial(
clf,
X_pool,
strategy="bald",
n=4,
num_posterior_samples=256,
)
X_new = X_pool[ranked.indices]
y_new = collect_labels_for(ranked.indices)
clf.partial_fit(X_new, y_new)
status = calibration_sufficient(
clf,
X_pool,
previous=previous,
threshold=0.02,
)
```
`strategy="bald"` is supported for `NimbusLDA`, `NimbusQDA`, and `NimbusSoftmax`. `NimbusSTS` supports only the cheap strategies in this release.
### `should_query()`
Decide whether a single arriving trial is informative enough to label.
```python theme={null}
should_query(
model,
x_new,
*,
strategy="entropy",
threshold=...,
num_posterior_samples=50,
rng_seed=0,
) -> StreamingQueryDecision
```
**Parameters:**
* `model` (`NimbusModel` or fitted Nimbus classifier): Model used to score the trial
* `x_new` (`np.ndarray`): Single feature row with shape `(n_features,)` or `(1, n_features)`
* `strategy` (`"entropy"`, `"margin"`, or `"least_confidence"`, default=`"entropy"`): Cheap informativeness strategy
* `threshold` (float): Query cutoff
* `num_posterior_samples` (int, default=`50`): Forwarded to `NimbusSoftmax` probability estimates
* `rng_seed` (int, default=`0`): Deterministic seed for `NimbusSoftmax`
**Returns:** `StreamingQueryDecision`
* `should_query`: Whether the trial should be labeled
* `score`: Raw informativeness score
* `threshold`: Threshold used
* `strategy`: Strategy used
```python theme={null}
decision = should_query(
clf,
x_new,
strategy="entropy",
threshold=1.0,
)
if decision.should_query:
request_label(x_new)
```
`should_query()` does not support `strategy="bald"`. Single-point BALD is too noisy; batch trials and call `suggest_next_trial(strategy="bald")` instead.
### `calibration_sufficient()`
Decide whether calibration can stop based on a label-free criterion evaluated over the same unlabeled pool.
```python theme={null}
calibration_sufficient(
model,
X_pool,
*,
criterion="posterior_stability",
previous=None,
threshold=...,
num_posterior_samples=64,
rng_seed=0,
) -> CalibrationStatus
```
**Parameters:**
* `model` (`NimbusModel` or fitted Nimbus classifier): Current model snapshot
* `X_pool` (`np.ndarray`): Unlabeled feature rows with shape `(n_pool, n_features)`
* `criterion` (`"posterior_stability"` or `"expected_info_gain"`, default=`"posterior_stability"`): Stopping signal
* `previous` (`NimbusModel` or fitted Nimbus classifier, optional): Previous model snapshot, required for `posterior_stability`
* `threshold` (float): Stop when the signal is below this value
* `num_posterior_samples` (int, default=`64`): Posterior samples for `expected_info_gain`; forwarded to `NimbusSoftmax`
* `rng_seed` (int, default=`0`): Deterministic seed
**Returns:** `CalibrationStatus`
* `is_sufficient`: `True` when the stopping signal is below `threshold`
* `signal`: Mean total variation for `posterior_stability`, or mean BALD in bits for `expected_info_gain`
* `threshold`: Threshold used
* `criterion`: Criterion used
* `details`: Criterion-specific diagnostics such as `max_tv`, `min_tv`, `max_bald`, or `min_bald`
```python theme={null}
previous = clf.get_model()
# ... collect labels and call clf.partial_fit(...) on the current round ...
status = calibration_sufficient(
clf,
X_pool,
criterion="posterior_stability",
previous=previous,
threshold=0.02,
)
if status.is_sufficient:
stop_calibration()
```
`posterior_stability` works for every Nimbus head, including `NimbusSTS`. `expected_info_gain` uses BALD and is supported for `NimbusLDA`, `NimbusQDA`, and `NimbusSoftmax`.
### Strategy Units
| Quantity | Range | Used by |
| --------------------- | ----------------------------- | -------------------------------------------------------------------------------- |
| `entropy` | `[0, log2(n_classes)]` bits | `suggest_next_trial()`, `should_query()` |
| `bald` | `[0, log2(n_classes)]` bits | `suggest_next_trial()`, `calibration_sufficient(criterion="expected_info_gain")` |
| `margin` | `[0, 1]` probability gap | `suggest_next_trial()`, `should_query()` |
| `least_confidence` | `[0, 1 - 1/n_classes]` | `suggest_next_trial()`, `should_query()` |
| `posterior_stability` | `[0, 1]` mean total variation | `calibration_sufficient()` |
## Type Hints
All functions and classes include type hints for better IDE support:
```python theme={null}
from nimbus_bci import NimbusLDA
import numpy as np
from numpy.typing import NDArray
def train_classifier(
X: NDArray[np.float64],
y: NDArray[np.int64]
) -> NimbusLDA:
clf = NimbusLDA()
clf.fit(X, y)
return clf
```
## API FAQ
Start with `NimbusLDA` for fast baselines, especially motor imagery. Use `NimbusQDA` for overlapping distributions and `NimbusSTS` for non-stationary sessions.
Use `predict_batch` for offline trials and evaluation. Use `StreamingSession` for chunk-by-chunk real-time inference where latency and incremental decisions matter.
No. MNE integration is optional. You can use `nimbus-bci` with any preprocessing pipeline as long as you provide correctly shaped feature arrays.
## Next Read
Advanced sklearn patterns and best practices
Real-time BCI with chunk processing
Complete EEG preprocessing pipeline
Working code examples
# Changelog 0.6
Source: https://docs.nimbusbci.com/python-sdk/changelog-0.6
Release notes for nimbus-bci 0.6 — factories, profiles, paradigm recipes, breaking preset renames.
Public release notes for **nimbus-bci 0.6.0** (2026-09-13). The package ships a full `CHANGELOG.md` in the PyPI sdist; this page is the docs mirror for the product surface.
Upgrading from 0.5? Start with [Migration to 0.6](/python-sdk/migration-0.6).
Also new in docs: [Paradigms & profiles](/personalizer/paradigms) · [Transforms](/personalizer/transforms) · [Research / PEFT](/personalizer/research-peft).
## Highlights
* Cheap online adaptation: head `partial_fit` \~**8–10×** vs sklearn batch refit; \~**22–131×** vs trunk retrain at matched n
* Factories: `for_deployment`, `for_research`, `for_features`, `for_riemann`, `for_p300`, `for_balora`
* Portable profiles + MI / P300 session bundles
* Embedding transforms + opt-in `[train]` PEFT / BaLoRA
## Added
* `fit_calibrated(...)` — fit + pin decision-gate accept rate
* `adapt(...)` / `adapt_async(...)` — adaptation gateway; `BrainStateBatch`
* `export_profile` / `from_profile` / `UserProfile`
* Transforms: `standardize` / `whiten` / `coral` / `affine` / `rpa` / `euclidean_alignment`
* P300 / Riemann / MI recipes — see [Paradigms & profiles](/personalizer/paradigms)
* Session-bundle trust: `trusted=True` + `sidecar_sha256`
* `calibration_status` / `suggest_calibration_trials`
* BaLoRA + trunk PEFT behind `[train]` — see [Research / PEFT](/personalizer/research-peft)
* Metrics: `cohen_kappa`, `roc_auc_score`, `brier_score`, `temperature_scale_logits`
* Optional forgetting `λ\<1` and `prior_fraction=` on LDA/QDA
* `adapt_errp`, `adapt_pseudo`, `fit_tent`, T3A, `fit_population` (research)
## Changed (breaking)
* Presets: `research` → `strict`, `consumer` → `permissive`
* Middleware imports regrouped under `policies/` / `alignment/` / `techniques/` (no shims)
* `scikit-learn >= 1.6`; Python `>=3.11,<3.14`; `[train]` torch `>=2.14`
* sfreq never-declared warning when encoder has `training_sfreq`
## Evidence integrity
Update **cost** is the robust claim (held even where accuracy gates failed). Cite wall-time ratios — details on [Evidence](/personalizer/evidence).
## Next read
What to change in your code
MI / P300 / profiles
Extras and verify 0.6.0
Factory-first path
# Python SDK Installation
Source: https://docs.nimbusbci.com/python-sdk/installation
Install and verify the nimbus-bci Python SDK with pip, dependencies, and setup checks for local BCI development.
Install the nimbus-bci Python SDK from PyPI and verify your setup.
## Requirements
Before installing, ensure you have:
* **Python ≥ 3.11** (Python 3.11, 3.12, or 3.13)
* **pip** package manager
* **64-bit system** (Linux, macOS, or Windows)
The Python SDK does **not** require an API key. All processing is local.
## Install from PyPI
Install the latest stable version from PyPI:
```bash theme={null}
pip install nimbus-bci
```
This installs the core package with all required dependencies:
* NumPy ≥ 1.26
* scikit-learn ≥ 1.6
* SciPy ≥ 1.17.1
Requires Python `>=3.11,<3.14` (CPython 3.11–3.13 wheels).
## Optional Dependencies
Install optional features as needed:
### MNE-Python Integration
For EEG preprocessing and feature extraction:
```bash theme={null}
pip install nimbus-bci[mne]
```
This adds:
* MNE ≥ 1.6
### Visualization
For plotting and diagnostics:
```bash theme={null}
pip install nimbus-bci[viz]
```
This adds:
* Matplotlib ≥ 3.8
### Softmax Model
For the optional JAX-based `NimbusSoftmax` model:
```bash theme={null}
pip install nimbus-bci[softmax]
```
This adds:
* JAX ≥ 0.4.25
### Riemannian Feature Pipelines
For pyRiemann covariance / tangent-space features, plus `Personalizer.for_riemann` / `for_p300`:
```bash theme={null}
pip install nimbus-bci[riemann]
```
This adds:
* pyRiemann ≥ 0.11, \< 0.12
The `riemann` extra enables `nimbus_bci.riemann.make_riemann_nimbus_pipeline(...)` and the paradigm factories. This is a feature-extraction / geometry bridge to Nimbus heads, not a separate Riemannian Bayesian model family.
### Trunk PEFT / BaLoRA (research)
For opt-in trunk adaptation (`wrap_lora`, FiLM, `Personalizer.for_balora`):
```bash theme={null}
pip install nimbus-bci[train]
```
This adds:
* torch ≥ 2.14.0
Research surface only — no certified accuracy claims.
### All Optional Dependencies
Install everything:
```bash theme={null}
pip install nimbus-bci[all]
```
## Development Installation
For development or contributing, install from a source tree you have access to:
```bash theme={null}
pip install -e ".[dev,all]"
```
This includes:
* pytest for testing
* Cython for building extensions
* build and twine for packaging
Published installs use PyPI — you do not need a Git clone for normal use.
## Verify Installation
Test your installation — product surface first, then classical heads:
```python theme={null}
import nimbus_bci
print(f"nimbus-bci version: {nimbus_bci.__version__}")
from nimbus_bci import Personalizer, wrap, BrainState
print("Personalizer / wrap / BrainState imported successfully")
from nimbus_bci import NimbusLDA, NimbusQDA, NimbusSTS
print("Core heads imported successfully")
import numpy as np
X = np.random.randn(100, 10)
y = np.random.randint(0, 2, 100)
enc = wrap(lambda Z: Z, model_id="verify-encoder", embedding_dim=10)
adapter = Personalizer.for_deployment(enc, ["a", "b"])
adapter.fit(X, y)
states = adapter.predict(X[:5])
assert isinstance(states[0], BrainState)
print("Personalizer smoke test works")
```
Expected output:
```text theme={null}
nimbus-bci version: 0.6.0
Personalizer / wrap / BrainState imported successfully
Core heads imported successfully
Personalizer smoke test works
```
## Platform-Specific Notes
### macOS
Pre-built wheels for Intel (`x86_64`) and Apple Silicon (`arm64`):
```bash theme={null}
pip install nimbus-bci
```
### Linux
Pre-built wheels for **x86\_64** use the `manylinux_2_28` platform tag. Other architectures fall back to building from the sdist (requires a C compiler and Cython).
```bash theme={null}
pip install nimbus-bci
```
### Windows
Pre-built wheels for 64-bit Windows:
```bash theme={null}
pip install nimbus-bci
```
32-bit systems are not supported. Use 64-bit Python on a 64-bit OS.
## Troubleshooting
### JAX Installation Issues
JAX is only required for `NimbusSoftmax`. If JAX installation fails, install it separately first:
```bash theme={null}
# CPU-only JAX
pip install jax[cpu]
# Then install the softmax extra
pip install nimbus-bci[softmax]
```
For GPU support, see [JAX installation guide](https://github.com/google/jax#installation).
### Import Errors
If you see import errors, verify dependencies:
```python theme={null}
import numpy
import sklearn
import scipy
print(f"NumPy: {numpy.__version__}")
print(f"scikit-learn: {sklearn.__version__}")
print(f"SciPy: {scipy.__version__}")
```
All versions should meet minimum requirements.
### MNE-Python Conflicts
If you have an existing MNE installation, you may need to upgrade:
```bash theme={null}
pip install --upgrade mne scipy
```
## Upgrading
Upgrade to the latest version:
```bash theme={null}
pip install --upgrade nimbus-bci
```
Upgrading from 0.5? See [Migration to 0.6](/python-sdk/migration-0.6) — decision presets and some middleware import paths changed. Release notes: [Changelog 0.6](/python-sdk/changelog-0.6).
Check your version:
```python theme={null}
import nimbus_bci
print(nimbus_bci.__version__)
```
## Uninstalling
Remove the package:
```bash theme={null}
pip uninstall nimbus-bci
```
## License
The nimbus-bci Python SDK is **proprietary**, with two tracks under `LICENSE.txt` (v2.1) shipped in the PyPI package:
| Track | Use case | Cost |
| ------------------ | ---------------------------------------------------------------------------- | -------------------------------------- |
| **Non-Commercial** | Academic research, teaching, government / non-profit labs, personal learning | Free (self-executing; no registration) |
| **Startup** | Companies with less than US \$1M annual revenue | Paid — contact us |
| **Commercial** | Full production rights | Paid — contact us |
| **Enterprise** | Unlimited deployments + SLA | Paid — contact us |
| **OEM / Embedded** | Medical devices, FDA support | Paid — contact us |
Any use inside a for-profit company, in a paid product or service, or for sponsored / fee-for-service research requires a commercial license. Citation is requested as a community norm, not a license condition.
### Request a commercial license
Email [hello@nimbusbci.com](mailto:hello@nimbusbci.com) with your use case.
Non-commercial eligibility is self-executing — no license key or registration is required. Full terms ship with the package as `LICENSE.txt`.
## What's next
Build your first Personalizer in minutes
Breaking changes from 0.5
Release notes mirror
Explore the complete API documentation
Use nimbus-bci with sklearn pipelines
## Support
Need help with installation?
* **Email**: [hello@nimbusbci.com](mailto:hello@nimbusbci.com)
* **Docs**: [docs.nimbusbci.com](https://docs.nimbusbci.com)
* **PyPI**: [pypi.org/project/nimbus-bci](https://pypi.org/project/nimbus-bci/)
***
Successfully installed? Continue with the [Quickstart](/python-sdk/quickstart).
# Python SDK
Source: https://docs.nimbusbci.com/python-sdk/introduction
Overview of the nimbus-bci Python SDK: Personalizer middleware for frozen encoders, BrainState app outputs, and sklearn-compatible Bayesian heads.
The **nimbus-bci** Python library is a Bayesian personalization head for frozen neural EEG embeddings — and sklearn-compatible classifiers for classical feature pipelines. Wrap any trunk that emits `encode(X) → Z`, adapt online with `Personalizer`, and ship apps on `BrainState`.
Optional extras: `[softmax]` (JAX), `[riemann]` (pyRiemann + `for_riemann` / `for_p300`), `[train]` (torch PEFT / BaLoRA), `[mne]`, `[viz]`, or `[all]`.
## Start here
Install and verify your Python SDK environment.
Encoder contract, BrainState, and decision presets.
Fit your first Personalizer in minutes.
## Overview
`nimbus-bci` is a production-ready Python package on PyPI. The product surface is middleware; the Bayesian heads (`NimbusLDA`, …) power `Personalizer` and remain usable standalone.
```bash theme={null}
pip install nimbus-bci
```
**Product wedge:** `wrap` → `Personalizer` → `BrainState`. Also: `CalibrationSession` for active learning, and `nimbus_bci.riemann.make_riemann_nimbus_pipeline(...)` for pyRiemann + Nimbus heads. See [Personalizer & Middleware](/personalizer/overview) and [Active Learning](/python-sdk/active-learning).
## Key features
Plug any frozen trunk; personalize online; ship `BrainState` to apps.
Bayesian heads work as drop-in sklearn classifiers in pipelines and CV.
Real-time chunk-by-chunk processing with optional BrainState bridges.
Works with MNE-Python for EEG preprocessing and feature extraction.
Optional pyRiemann covariance/tangent feature pipelines feeding Nimbus heads.
Entropy, calibration metrics (ECE/MCE), ITR calculation, and quality assessment.
Update heads incrementally with `partial_fit()` without retraining from scratch.
Rank unlabeled trials, request labels only when useful, and stop when the posterior stabilizes.
No API keys required. All processing happens on your machine.
## Quick Example
```python theme={null}
from nimbus_bci import Personalizer, wrap
import numpy as np
# Toy frozen encoder — replace with partner / foundation encode(X) → Z
def encode(X):
return X # already embeddings or CSP features
enc = wrap(encode, model_id="demo-encoder", embedding_dim=16)
adapter = Personalizer.for_deployment(enc, ["left", "right"])
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test) # list[BrainState]
print(states[0].intent, states[0].confidence)
```
Classical features without a trunk:
```python theme={null}
from nimbus_bci import NimbusLDA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
pipe = make_pipeline(StandardScaler(), NimbusLDA())
pipe.fit(X_train, y_train)
```
## Available Heads
The Python SDK provides four Bayesian heads (used by `Personalizer(head=...)` or standalone):
| Head | Description | Best For |
| ----------------- | --------------------------------------------- | ---------------------------------------- |
| **NimbusLDA** | Bayesian LDA with shared covariance | Default Personalizer head; motor imagery |
| **NimbusQDA** | Bayesian QDA with class-specific covariances | Complex distributions (P300) |
| **NimbusSoftmax** | Bayesian logistic regression (Polya-Gamma VI) | Non-Gaussian decision boundaries |
| **NimbusSTS** | Bayesian Structural Time Series (EKF) | Non-stationary data, long sessions |
All heads provide:
* Full posterior distributions
* Uncertainty quantification
* Online learning via `partial_fit()`
* sklearn-compatible API
**Note**: NimbusSTS also includes state management methods (`propagate_state()`, `get_latent_state()`, `set_latent_state()`) for temporal adaptation.
## Python vs Julia SDK
Both SDKs implement the same Bayesian models but with different design philosophies:
| Feature | Python SDK | Julia SDK |
| ---------------- | ------------------------------------------------------ | -------------------------------- |
| **Installation** | `pip install nimbus-bci` | `Pkg.add("NimbusSDK")` + API key |
| **API Style** | sklearn-compatible classes | Functional + types |
| **Integration** | sklearn pipelines, MNE-Python | RxInfer.jl ecosystem |
| **Performance** | Fast local Python implementation; optional JAX Softmax | Fastest (native Julia) |
| **API Key** | Not required | Required |
| **Use Case** | Production ML pipelines | Research, custom models |
| **License** | Proprietary (Non-Commercial free + paid Commercial) | Commercial tiers |
### When to use the Python SDK
Choose the Python SDK if you:
* Work primarily in Python
* Use sklearn pipelines and MNE-Python
* Want installation without API keys
* Need a sklearn-compatible API
* Prefer fully local processing
### When to use the Julia SDK
Choose the Julia SDK if you:
* Need native Julia performance
* Want to build custom Bayesian models
* Work with the RxInfer.jl ecosystem
* Need pre-trained model distribution
* Require enterprise support
## Architecture
**Product path:**
```text theme={null}
EEG → frozen trunk encode(X)→Z → Personalizer → BrainState → Your Application
```
**Classical path:**
```text theme={null}
EEG → MNE / CSP / Riemann → Nimbus head → Your Application
(Preprocessing) (Classification) (Control/Analysis)
```
**Key Design Principles:**
* **Encoder contract**: Nimbus does not own DL architectures — integrators supply embeddings
* **App surface**: `BrainState` for devices; `BatchResult` for research diagnostics
* **Local Processing**: All computation happens on your machine
* **Privacy First**: Your EEG data never leaves your computer
* **sklearn Compatible**: Heads work with existing ML workflows
* **Uncertainty Aware**: Full Bayesian posterior distributions
## Use cases
Plug EEGNet / BrainDecode / partner trunks into Personalizer
Control devices with imagined movements using CSP features and NimbusLDA
Communication interfaces using ERP features and NimbusQDA
Streaming inference for live brain state monitoring
## What's Included
The `nimbus-bci` package provides:
**Middleware (product surface):**
* `Personalizer` — Bayesian personalization head over a frozen encoder
* Factories — `for_deployment`, `for_research`, `for_features`, `for_riemann`, `for_p300`, `for_balora`
* `Recipe` / `UserProfile` — paradigm recipes and portable profiles (`export_profile` / `from_profile`)
* `wrap` / `FrozenEncoder` / `EncoderProtocol` — encoder contract
* `BrainState` / `BrainStateBatch` / `Prediction` / `IntentHypothesis` — app contracts
* `decide` / `brain_states_from_batch` / `brain_state_from_streaming_result` — bridges
* `wrap_eegnet` / `wrap_braindecode` — optional named adapters
* Transforms — `StandardizeTransform`, `WhitenTransform`, `CoralTransform`, `RpaTransform`, `EuclideanAlignment`
**Heads (classifiers):**
* `NimbusLDA` - Bayesian Linear Discriminant Analysis
* `NimbusQDA` - Bayesian Quadratic Discriminant Analysis
* `NimbusSoftmax` - Bayesian Multinomial Logistic Regression
* `NimbusSTS` - Bayesian Structural Time Series (Extended Kalman Filter)
**Data Structures:**
* `BCIData` - Container for features, metadata, labels
* `BCIMetadata` - Metadata for BCI experiments
**Inference:**
* `predict_batch()` - Batch inference with diagnostics
* `StreamingSession` - Real-time chunk processing
**Active Learning:**
* `CalibrationSession` - Manage pool-based calibration rounds and stopping snapshots
* `suggest_next_trial()` - Rank unlabeled feature pools by informativeness
* `should_query()` - Decide whether an arriving trial is worth labeling
* `calibration_sufficient()` - Stop calibration when the posterior stabilizes
**Metrics:**
* `compute_entropy()` - Prediction uncertainty
* `compute_calibration_metrics()` - ECE/MCE
* `calculate_itr()` - Information Transfer Rate
* `assess_trial_quality()` - Quality checks
**Utilities:**
* `estimate_normalization_params()` - Feature normalization
* `diagnose_preprocessing()` - Preprocessing diagnostics
* `compute_fisher_score()` - Feature discriminability
**MNE Integration:**
* `from_mne_epochs()` - Convert MNE Epochs to BCIData
* `extract_csp_features()` - CSP feature extraction
* `extract_bandpower_features()` - Bandpower features
**Optional Riemannian Features:**
* `make_riemann_nimbus_pipeline()` - Build an EEG-epoch pipeline from pyRiemann covariance/tangent transforms to a Nimbus classifier head
## Next read
Install nimbus-bci and verify your setup
Encoder contract and BrainState
Fit your first Personalizer in minutes
Complete API documentation with examples
Reduce calibration time with label-efficient trial selection
Use nimbus-bci with MNE-Python
## Support
* **Email**: [hello@nimbusbci.com](mailto:hello@nimbusbci.com)
* **Docs**: [docs.nimbusbci.com](https://docs.nimbusbci.com)
* **PyPI**: [pypi.org/project/nimbus-bci](https://pypi.org/project/nimbus-bci/)
***
Ready to get started? Head to the [Installation Guide](/python-sdk/installation).
# Migration to 0.6
Source: https://docs.nimbusbci.com/python-sdk/migration-0.6
Breaking changes and new Personalizer APIs when upgrading nimbus-bci from 0.5 to 0.6.
Upgrade guide for **nimbus-bci 0.6** — the personalization release (factories, profiles, paradigm recipes, cheaper online adaptation).
```bash theme={null}
pip install --upgrade nimbus-bci
```
Confirm:
```python theme={null}
import nimbus_bci
assert nimbus_bci.__version__.startswith("0.6")
```
## Breaking changes
### Decision presets renamed
| 0.5 | 0.6 |
| ------------ | -------------- |
| `"research"` | `"strict"` |
| `"consumer"` | `"permissive"` |
Old strings raise `ValueError`. Also available: `"aggressive"`.
```python theme={null}
# Before
Personalizer(..., preset="research")
decide(pred, preset="consumer")
# After
Personalizer(..., preset="strict")
decide(pred, preset="permissive")
# Or prefer factories (recommended):
Personalizer.for_deployment(enc, classes) # strict gating
Personalizer.for_research(enc, classes) # permissive + knobs
```
### Middleware import paths regrouped
Flat middleware modules moved into subpackages — **no compatibility shims**:
| 0.5 | 0.6 |
| ---------------------------------- | ------------------------------------------- |
| `nimbus_bci.middleware.adapt_gate` | `nimbus_bci.middleware.policies.adapt_gate` |
| `nimbus_bci.middleware.decision` | `nimbus_bci.middleware.policies.decision` |
| transforms | `nimbus_bci.middleware.alignment` |
| PEFT / TTA | `nimbus_bci.middleware.techniques` |
The public top-level API (`from nimbus_bci import Personalizer, …`) is unchanged.
### Dependency floors
* `scikit-learn >= 1.6` (was `>= 1.4`)
* `requires-python`: `>=3.11,<3.14`
* Optional `[train]` / `[all]`: `torch >= 2.14.0`
### Compiled extensions
After upgrading from source, rebuild extensions (`scripts/build_extensions.py`) — a stale `learning.*.so` can shadow new source.
## What to adopt (new in 0.6)
| Feature | API |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Factory entry points | `for_deployment`, `for_research`, `for_features`, `for_riemann`, `for_p300`, `for_balora` |
| Gate calibration | `fit_calibrated(..., target_accept=...)` |
| Adaptation gateway | `adapt(...)` / `adapt_async(...)` |
| Portable profiles | `export_profile()` / `from_profile(...)` / `UserProfile` |
| Paradigm recipes | `Recipe`, `fit_mi_session`, `MISessionBundle` / `P300SessionBundle` — see [Paradigms & profiles](/personalizer/paradigms) |
| Embedding transforms | `transform="standardize"\|"whiten"\|"coral"\|"affine"\|"rpa"\|…` |
| Optional forgetting | LDA/QDA `λ\<1` (default `λ=1` is byte-identical to 0.5) |
Session-bundle sidecars require `trusted=True` on load; `save` records `sidecar_sha256` verified before unpickle.
## Recommended rewrite
```python theme={null}
from nimbus_bci import Personalizer, wrap
enc = wrap(model.encode, model_id="partner", embedding_dim=64)
adapter = Personalizer.for_deployment(enc, ["left", "right"], paradigm="motor_imagery")
# adapter.fit_calibrated(X_cal, y_cal, X_val, y_val, target_accept=0.7)
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test)
adapter.adapt(X_new, y_new)
profile = adapter.export_profile()
```
## Next read
Verify 0.6 and optional extras
Factory-first Personalizer path
Release notes mirror
MI / P300 / Riemann recipes
Full Personalizer surface
# MNE-Python Integration
Source: https://docs.nimbusbci.com/python-sdk/mne-integration
Use MNE-Python with nimbus-bci: epochs → frozen encoder → Personalizer → BrainState, or classical CSP → Nimbus heads.
Use MNE-Python with nimbus-bci to build BCI pipelines from raw EEG to `BrainState` decisions.
## Overview
MNE-Python handles:
* Loading EEG data from various formats
* Preprocessing (filtering, artifact removal)
* Epoching and event extraction
* Feature extraction (CSP, bandpower)
nimbus-bci handles:
* Bayesian personalization (`Personalizer`) and standalone heads
* Uncertainty quantification and gating (`BrainState`)
* Online learning
* Real-time inference
* Heads: `NimbusLDA`, `NimbusQDA`, `NimbusSoftmax`, `NimbusSTS`
**Dual path after MNE epochs**
1. **Product:** epochs → frozen encoder (`wrap` / BrainDecode) → [`Personalizer`](/personalizer/overview) → `BrainState`
2. **Classical:** epochs → CSP / bandpower → `Personalizer(encoder=None)` or `NimbusLDA`
## Installation
Install both packages:
```bash theme={null}
pip install nimbus-bci[mne]
```
This installs:
* `nimbus-bci` - Bayesian personalization + heads
* `mne` ≥ 1.6 - EEG preprocessing
## Product path: epochs → encoder → Personalizer
Use MNE for acquisition / epoching, then hand trial tensors to a frozen trunk:
```python theme={null}
import mne
from nimbus_bci import Personalizer, wrap
raw = mne.io.read_raw_gdf("motor_imagery.gdf", preload=True)
raw.filter(8, 30)
events = mne.find_events(raw)
epochs = mne.Epochs(
raw,
events,
event_id={"left_hand": 1, "right_hand": 2},
tmin=0,
tmax=4,
baseline=None,
preload=True,
)
# Trial tensor for your frozen trunk — shape depends on encode()
X = epochs.get_data() # (n_epochs, n_channels, n_times)
y = epochs.events[:, 2]
# Map to semantic labels if needed
classes = ["left", "right"]
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64)
adapter = Personalizer.for_deployment(enc, classes)
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test)
print(states[0].intent, states[0].confidence, states[0].need_more_data)
adapter.save("subject_mne_profile")
```
Optional shortcuts: `wrap_eegnet`, `wrap_braindecode` — see [Personalizer & Middleware](/personalizer/overview).
## Classical path: CSP → Personalizer / head
### 1. Load and Preprocess with MNE
```python theme={null}
import mne
# Load raw EEG data
raw = mne.io.read_raw_gdf("motor_imagery.gdf", preload=True)
# Filter to relevant frequency bands
raw.filter(8, 30) # Mu + Beta bands for motor imagery
# Find events
events = mne.find_events(raw)
# Create epochs
epochs = mne.Epochs(
raw,
events,
event_id={'left_hand': 1, 'right_hand': 2},
tmin=0,
tmax=4,
baseline=None,
preload=True
)
```
### 2. Extract Features
```python theme={null}
from nimbus_bci.compat import extract_csp_features
# Extract CSP features
csp_features, csp = extract_csp_features(epochs, n_components=8)
# csp_features shape: (n_epochs, n_components)
# csp: fitted CSP object for later use
```
### 3. Personalize (or fit a head)
```python theme={null}
from nimbus_bci import Personalizer, NimbusLDA
from sklearn.model_selection import cross_val_score
labels = epochs.events[:, 2]
# Product-style on classical features
adapter = Personalizer.for_features(["left", "right"], head="lda")
adapter.fit(csp_features, labels)
states = adapter.predict(csp_features[:5])
# Or standalone head for sklearn CV
clf = NimbusLDA()
scores = cross_val_score(clf, csp_features, labels, cv=5)
print(f"Accuracy: {scores.mean():.2%} (+/- {scores.std():.2%})")
```
## Complete Motor Imagery Pipeline (classical)
```python theme={null}
import mne
from nimbus_bci import Personalizer
from nimbus_bci.compat import extract_csp_features
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# 1. Load data
raw = mne.io.read_raw_gdf("motor_imagery.gdf", preload=True)
# 2. Preprocessing
raw.filter(8, 30) # Mu + Beta bands
raw.set_eeg_reference('average') # Average reference
# 3. Artifact removal (optional)
raw.set_annotations(mne.preprocessing.annotate_muscle_zscore(raw))
# 4. Epoching
events = mne.find_events(raw)
event_id = {'left_hand': 1, 'right_hand': 2, 'feet': 3, 'tongue': 4}
epochs = mne.Epochs(
raw, events, event_id,
tmin=0, tmax=4,
baseline=None,
preload=True
)
# 5. Feature extraction
csp_features, csp = extract_csp_features(epochs, n_components=8)
labels = epochs.events[:, 2]
# 6. Train/test split
X_train, X_test, y_train, y_test = train_test_split(
csp_features, labels, test_size=0.2, stratify=labels, random_state=42
)
# 7. Scale + Personalizer on classical features
scaler = StandardScaler()
Z_train = scaler.fit_transform(X_train)
Z_test = scaler.transform(X_test)
adapter = Personalizer.for_features(
["left", "right", "feet", "tongue"], head="lda"
)
adapter.fit(Z_train, y_train)
states = adapter.predict(Z_test)
print(states[0].intent, states[0].confidence)
# 8. Evaluate via the head (sklearn-compatible)
train_score = adapter.head.score(Z_train, y_train)
test_score = adapter.head.score(Z_test, y_test)
print(f"Train accuracy: {train_score:.2%}")
print(f"Test accuracy: {test_score:.2%}")
```
## Optional Riemannian Feature Pipeline
For workflows that use covariance geometry, install the pyRiemann extra and build a sklearn pipeline from EEG epochs to a Nimbus head (then optional Personalizer on the resulting features):
```bash theme={null}
pip install nimbus-bci[riemann]
```
```python theme={null}
import mne
from nimbus_bci import NimbusLDA
from nimbus_bci.riemann import make_riemann_nimbus_pipeline
from sklearn.model_selection import train_test_split
raw = mne.io.read_raw_gdf("motor_imagery.gdf", preload=True)
raw.filter(8, 30)
events = mne.find_events(raw)
epochs = mne.Epochs(raw, events, tmin=0, tmax=4, baseline=None, preload=True)
X_epochs = epochs.get_data() # (n_trials, n_channels, n_times)
y = epochs.events[:, 2]
X_train, X_test, y_train, y_test = train_test_split(
X_epochs, y, test_size=0.2, stratify=y, random_state=42
)
pipe = make_riemann_nimbus_pipeline(head=NimbusLDA())
pipe.fit(X_train, y_train)
probs = pipe.predict_proba(X_test)
predictions = pipe.predict(X_test)
```
This factory delegates covariance and tangent-space transforms to pyRiemann, then feeds the resulting feature rows into a Nimbus head. It is a composition helper, not a new Riemannian Bayesian model family.
## CSP Feature Extraction
### Basic CSP
```python theme={null}
from nimbus_bci.compat import extract_csp_features
# Extract CSP features
features, csp = extract_csp_features(
epochs,
n_components=8 # Number of CSP components
)
```
### Custom CSP Parameters
```python theme={null}
from mne.decoding import CSP
# Create custom CSP
csp = CSP(
n_components=8,
reg='ledoit_wolf', # Regularization
log=True,
norm_trace=False
)
# Fit and transform
X = epochs.get_data()
y = epochs.events[:, 2]
csp_features = csp.fit_transform(X, y)
```
### Multi-Class CSP
For more than 2 classes:
```python theme={null}
from nimbus_bci.compat import extract_csp_features
# Works automatically for multi-class
features, csp = extract_csp_features(
epochs, # 4-class motor imagery
n_components=8
)
# Train multi-class classifier
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
clf.fit(features, epochs.events[:, 2])
```
## Bandpower Features
### Extract Bandpower
```python theme={null}
from nimbus_bci.compat import extract_bandpower_features
# Define frequency bands
bands = {
'delta': (1, 4),
'theta': (4, 8),
'alpha': (8, 13),
'beta': (13, 30),
'gamma': (30, 45)
}
# Extract bandpower features
features, band_names = extract_bandpower_features(
epochs,
bands=bands,
log_transform=True
)
# Train classifier
from nimbus_bci import NimbusQDA
clf = NimbusQDA()
clf.fit(features, epochs.events[:, 2])
```
### Output Shape
```python theme={null}
features, band_names = extract_bandpower_features(epochs, bands)
print(features.shape) # (n_epochs, n_channels * n_bands)
print(band_names) # Names from the bands dict
```
## P300 ERP Features
### Extract ERP Amplitudes
```python theme={null}
import mne
from nimbus_bci import NimbusQDA
import numpy as np
# Load P300 data
raw = mne.io.read_raw_fif("p300_oddball.fif", preload=True)
raw.filter(0.1, 30)
# Epoch around stimuli
events = mne.find_events(raw)
epochs = mne.Epochs(
raw, events,
event_id={'target': 1, 'non_target': 2},
tmin=-0.2, tmax=0.8,
baseline=(-0.2, 0),
preload=True
)
# Extract ERP features (mean amplitude in time windows)
def extract_erp_features(epochs):
data = epochs.get_data() # (n_epochs, n_channels, n_times)
# Define time windows of interest
p300_window = (0.3, 0.5) # 300-500ms
times = epochs.times
# Find time indices
idx = np.where((times >= p300_window[0]) & (times <= p300_window[1]))[0]
# Mean amplitude in window
features = data[:, :, idx].mean(axis=2) # (n_epochs, n_channels)
return features
erp_features = extract_erp_features(epochs)
# Train classifier
clf = NimbusQDA()
clf.fit(erp_features, epochs.events[:, 2])
```
## Convert Between Formats
### MNE Epochs to BCIData
```python theme={null}
from nimbus_bci.compat import from_mne_epochs
# Convert MNE Epochs to BCIData
data = from_mne_epochs(
epochs,
paradigm="motor_imagery",
feature_type="raw" # or "csp", "bandpower"
)
# Use with batch inference only when the model was trained with matching
# raw-channel features and metadata.n_features.
from nimbus_bci import predict_batch
# result = predict_batch(raw_feature_model, data)
```
### BCIData to MNE Epochs
```python theme={null}
from nimbus_bci.compat import to_mne_epochs
# Convert BCIData back to MNE Epochs
epochs_reconstructed = to_mne_epochs(
bci_data,
info=original_epochs.info
)
```
## Complete BCI Pipeline
Create end-to-end pipeline:
```python theme={null}
from nimbus_bci.compat import create_bci_pipeline
from nimbus_bci import NimbusLDA
# Create complete pipeline
pipeline = create_bci_pipeline(
NimbusLDA,
feature_extraction="csp",
n_csp_components=8
)
# Load and preprocess with MNE
raw = mne.io.read_raw_gdf("data.gdf", preload=True)
raw.filter(8, 30)
events = mne.find_events(raw)
epochs = mne.Epochs(raw, events, tmin=0, tmax=4, preload=True)
# Extract features
X = epochs.get_data()
y = epochs.events[:, 2]
# Train pipeline
pipeline.fit(X, y)
# Predict
predictions = pipeline.predict(X)
```
## Real-Time BCI with MNE
### Online Processing
```python theme={null}
import mne
from nimbus_bci import NimbusLDA, StreamingSession
from nimbus_bci.data import BCIMetadata
from nimbus_bci.compat import extract_csp_features
# 1. Calibration phase
print("=== Calibration ===")
raw = mne.io.read_raw_gdf("calibration.gdf", preload=True)
raw.filter(8, 30)
events = mne.find_events(raw)
epochs = mne.Epochs(raw, events, tmin=0, tmax=4, preload=True)
# Train CSP and classifier
csp_features, csp = extract_csp_features(epochs, n_components=8)
clf = NimbusLDA()
clf.fit(csp_features, epochs.events[:, 2])
# 2. Online phase
print("=== Online ===")
# Setup streaming
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=8,
n_classes=4,
chunk_size=125,
temporal_aggregation="logvar"
)
session = StreamingSession(clf.model_, metadata)
# Process real-time data
from mne.io import RawArray
while True:
# Acquire raw EEG chunk (your acquisition code)
raw_chunk = acquire_eeg_chunk(duration=0.5) # 500ms
# Create MNE RawArray for preprocessing
info = mne.create_info(
ch_names=['C3', 'Cz', 'C4'], # Your channels
sfreq=250.0,
ch_types='eeg'
)
raw_mne = RawArray(raw_chunk, info)
# Apply same preprocessing
raw_mne.filter(8, 30)
# Convert raw EEG into the same feature space used for training.
# This should return (n_features, chunk_size), matching metadata.
feature_chunk = extract_streaming_csp_chunk(raw_mne.get_data(), csp)
# Process chunk
result = session.process_chunk(feature_chunk)
print(f"Prediction: {result.prediction} ({result.confidence:.2%})")
```
## Cross-Session Transfer
Handle different sessions with normalization:
```python theme={null}
import mne
from nimbus_bci import NimbusLDA
from nimbus_bci import estimate_normalization_params, apply_normalization
from nimbus_bci.compat import extract_csp_features
# Session 1 (training)
raw1 = mne.io.read_raw_gdf("session1.gdf", preload=True)
raw1.filter(8, 30)
epochs1 = mne.Epochs(raw1, mne.find_events(raw1), tmin=0, tmax=4, preload=True)
features1, csp = extract_csp_features(epochs1, n_components=8)
# Estimate normalization from session 1
norm_params = estimate_normalization_params(features1, method="zscore")
features1_norm = apply_normalization(features1, norm_params)
# Train on normalized features
clf = NimbusLDA()
clf.fit(features1_norm, epochs1.events[:, 2])
# Session 2 (testing)
raw2 = mne.io.read_raw_gdf("session2.gdf", preload=True)
raw2.filter(8, 30)
epochs2 = mne.Epochs(raw2, mne.find_events(raw2), tmin=0, tmax=4, preload=True)
# Apply same CSP
X2 = epochs2.get_data()
features2 = csp.transform(X2)
# Apply same normalization
features2_norm = apply_normalization(features2, norm_params)
# Predict
predictions = clf.predict(features2_norm)
accuracy = (predictions == epochs2.events[:, 2]).mean()
print(f"Cross-session accuracy: {accuracy:.2%}")
```
## Advanced Preprocessing
### ICA for Artifact Removal
```python theme={null}
import mne
from nimbus_bci import NimbusLDA
from nimbus_bci.compat import extract_csp_features
# Load data
raw = mne.io.read_raw_gdf("data.gdf", preload=True)
raw.filter(1, 40)
# Run ICA
ica = mne.preprocessing.ICA(n_components=15, random_state=42)
ica.fit(raw)
# Find and remove eye blinks
eog_indices, eog_scores = ica.find_bads_eog(raw)
ica.exclude = eog_indices
# Apply ICA
raw_clean = ica.apply(raw.copy())
# Continue with clean data
raw_clean.filter(8, 30)
epochs = mne.Epochs(raw_clean, mne.find_events(raw_clean), tmin=0, tmax=4, preload=True)
features, csp = extract_csp_features(epochs)
clf = NimbusLDA()
clf.fit(features, epochs.events[:, 2])
```
### Automated Artifact Rejection
```python theme={null}
import mne
# Load and filter
raw = mne.io.read_raw_gdf("data.gdf", preload=True)
raw.filter(8, 30)
# Epoch
events = mne.find_events(raw)
epochs = mne.Epochs(raw, events, tmin=0, tmax=4, preload=True)
# Automated rejection
reject_criteria = dict(
eeg=100e-6 # 100 µV
)
epochs.drop_bad(reject=reject_criteria)
print(f"Kept {len(epochs)} / {len(events)} epochs")
# Continue with clean epochs
from nimbus_bci.compat import extract_csp_features
features, csp = extract_csp_features(epochs)
```
## Visualization
### Plot CSP Patterns
```python theme={null}
import mne
from nimbus_bci.compat import extract_csp_features
import matplotlib.pyplot as plt
# Extract CSP
features, csp = extract_csp_features(epochs, n_components=8)
# Plot CSP patterns
csp.plot_patterns(epochs.info, show=True)
# Plot CSP filters
csp.plot_filters(epochs.info, show=True)
```
### Plot Classification Results
```python theme={null}
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt
# Get predictions
predictions = clf.predict(features)
labels = epochs.events[:, 2]
# Confusion matrix
cm = confusion_matrix(labels, predictions)
disp = ConfusionMatrixDisplay(cm, display_labels=['Left', 'Right', 'Feet', 'Tongue'])
disp.plot()
plt.show()
```
## Best Practices
### 1. Consistent Preprocessing
Apply same preprocessing to all data:
```python theme={null}
def preprocess_raw(raw):
"""Standard preprocessing pipeline."""
raw = raw.copy()
raw.filter(8, 30)
raw.set_eeg_reference('average')
return raw
# Use for all sessions
raw_train = preprocess_raw(mne.io.read_raw_gdf("train.gdf", preload=True))
raw_test = preprocess_raw(mne.io.read_raw_gdf("test.gdf", preload=True))
```
### 2. Save Preprocessing Objects
Save CSP and normalization for later use:
```python theme={null}
import joblib
# Save CSP transformer
joblib.dump(csp, 'csp_transformer.pkl')
# Save normalization params
joblib.dump(norm_params, 'norm_params.pkl')
# Load later
csp = joblib.load('csp_transformer.pkl')
norm_params = joblib.load('norm_params.pkl')
```
### 3. Validate Data Quality
Check data quality before training:
```python theme={null}
from nimbus_bci import diagnose_preprocessing
# Diagnose features
from nimbus_bci.data import BCIData, BCIMetadata
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=features.shape[1],
n_classes=len(set(labels))
)
bci_data = BCIData(features.T[:, None, :], metadata, labels)
report = diagnose_preprocessing(
bci_data
)
print(f"Quality score: {report.quality_score}")
print(f"Errors: {report.errors}")
print(f"Warnings: {report.warnings}")
```
## Next Read
Encoder contract and BrainState
CV / GridSearch on heads
Real-time BCI with chunk processing
Complete API documentation
# Python SDK Quickstart
Source: https://docs.nimbusbci.com/python-sdk/quickstart
Build your first nimbus-bci Personalizer in minutes — wrap an encoder, adapt online, ship BrainState — plus classical sklearn heads.
Get started with nimbus-bci: wrap any frozen encoder, personalize with a Bayesian head, and consume `BrainState` outputs.
**Time to complete:** \~10 minutes
This guide covers installation, Personalizer (product path), classical heads, and online updates. Deeper topics link out below.
## Prerequisites
Before you begin:
* **Python ≥ 3.11** installed
* **Embeddings** from a frozen trunk, or **preprocessed features** (CSP, bandpower, etc.) — see [Encoder contract](/personalizer/encoder-contract) and [preprocessing requirements](/inference-configuration/preprocessing-requirements)
nimbus-bci expects **embeddings or preprocessed features**, not raw EEG. Plug a frozen trunk via `wrap()`, or extract CSP / bandpower with MNE-Python.
***
Install from PyPI:
```bash theme={null}
pip install nimbus-bci
```
For MNE-Python integration:
```bash theme={null}
pip install nimbus-bci[mne]
```
Verify installation:
```python theme={null}
from nimbus_bci import Personalizer, wrap, BrainState
import nimbus_bci
print(f"nimbus-bci version: {nimbus_bci.__version__}")
```
Wrap a frozen encoder and adapt online:
```python theme={null}
from nimbus_bci import Personalizer, wrap
import numpy as np
np.random.seed(42)
n_samples, n_features = 100, 16
X_cal = np.random.randn(n_samples, n_features)
y_cal = np.random.randint(0, 2, n_samples)
X_test = np.random.randn(20, n_features)
# Toy identity encoder — replace with partner / foundation encode(X) → Z
enc = wrap(lambda X: X, model_id="demo-encoder", embedding_dim=n_features)
adapter = Personalizer.for_deployment(
enc, ["left", "right"], paradigm="motor_imagery"
)
adapter.fit(X_cal, y_cal)
states = adapter.predict(X_test) # list[BrainState]
print(states[0].intent, states[0].confidence, states[0].need_more_data)
adapter.save("subject_demo_profile")
```
Prefer factories: `for_deployment` (strict gating), `for_research` (permissive + knobs), `for_features` (no trunk). Full contract: [Personalizer & Middleware](/personalizer/overview).
Already have CSP / bandpower vectors? Use a head directly or `Personalizer.for_features`:
```python theme={null}
from nimbus_bci import NimbusLDA, Personalizer
import numpy as np
np.random.seed(42)
n_samples, n_features = 100, 16
X_train = np.random.randn(n_samples, n_features)
y_train = np.random.randint(0, 4, n_samples)
clf = NimbusLDA()
clf.fit(X_train, y_train)
X_test = np.random.randn(20, n_features)
predictions = clf.predict(X_test)
probabilities = clf.predict_proba(X_test)
print(f"Predictions: {predictions}")
print(f"Probabilities shape: {probabilities.shape}") # (20, 4)
# Same app surface without a trunk:
# p = Personalizer.for_features(["a", "b", "c", "d"]).fit(X_train, y_train)
```
Use nimbus-bci with sklearn pipelines:
```python theme={null}
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score
from nimbus_bci import NimbusLDA
pipe = make_pipeline(
StandardScaler(),
NimbusLDA(mu_scale=3.0),
)
pipe.fit(X_train, y_train)
predictions = pipe.predict(X_test)
scores = cross_val_score(pipe, X_train, y_train, cv=5)
print(f"CV Accuracy: {scores.mean():.2%} (+/- {scores.std():.2%})")
```
Heads are sklearn-compatible. Use them with `Pipeline`, `GridSearchCV`, and `cross_val_score`. See [sklearn Integration](/python-sdk/sklearn-integration).
Update with new labeled trials via `adapt` / `partial_fit`, and optionally gate with `recommend_adapt`:
```python theme={null}
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
clf.fit(X_train, y_train)
X_new = np.random.randn(10, n_features)
y_new = np.random.randint(0, 4, 10)
clf.partial_fit(X_new, y_new)
# Personalizer path
# adapter.adapt(X_lab, y_lab) # preferred gateway
# adapter.partial_fit(X_lab, y_lab) # head-only conjugate update
# dec = adapter.recommend_adapt(X_stream, tau=your_tau)
# if dec.should_adapt:
# adapter.partial_fit(X_lab, y_lab)
```
Trial rejection (`BrainState.rejected` / `need_more_data`) is a different gate from adapt recommendations. Presets are `strict` / `permissive` (0.6 rename). See [BrainState](/personalizer/brain-state) and [Migration to 0.6](/python-sdk/migration-0.6).
***
## Available heads
| Model | Best for | Notes |
| ----------------- | ------------------------------------- | ------------------------------------------- |
| **NimbusLDA** | Motor imagery, well-separated classes | Default `Personalizer(head="lda")` |
| **NimbusQDA** | Overlapping distributions (e.g. P300) | Class-specific covariances |
| **NimbusSoftmax** | Multiclass nonlinear boundaries | Optional: `pip install nimbus-bci[softmax]` |
| **NimbusSTS** | Non-stationary / long sessions | Stateful; see [Bayesian STS](/models/rxsts) |
## Continue with
Encoder contract, adapt helper, BrainState
`CalibrationSession` and label-efficient calibration
Chunk-by-chunk real-time processing
Epochs → features or frozen encoder
Pipelines, CV, and GridSearch
Full symbol reference
## Quickstart FAQ
Not directly. Expect embeddings or preprocessed features (CSP, bandpower, ERP). See [Preprocessing Requirements](/inference-configuration/preprocessing-requirements).
Start with `NimbusLDA` (default Personalizer head). Try `NimbusQDA` for overlapping distributions, and `NimbusSTS` for non-stationary sessions.
Optional extra: `pip install nimbus-bci[riemann]`, then `Personalizer.for_riemann(...)`, `Personalizer.for_p300(...)`, or `make_riemann_nimbus_pipeline(...)`. See [sklearn Integration](/python-sdk/sklearn-integration) and the API reference.
## Support
* **Email**: [hello@nimbusbci.com](mailto:hello@nimbusbci.com)
* **Docs**: [docs.nimbusbci.com](https://docs.nimbusbci.com)
* **PyPI**: [pypi.org/project/nimbus-bci](https://pypi.org/project/nimbus-bci/)
# sklearn Integration
Source: https://docs.nimbusbci.com/python-sdk/sklearn-integration
Use nimbus-bci heads with sklearn pipelines, CV, and GridSearch — alongside Personalizer for app-facing BrainState outputs.
The nimbus-bci **heads** are fully compatible with scikit-learn — pipelines, cross-validation, hyperparameter tuning, and the broader sklearn ecosystem. Use them standalone or as `Personalizer(head=...)` under the [middleware contract](/personalizer/overview).
**Product vs sklearn surface**
* Apps / devices: `Personalizer` → `BrainState` ([middleware](/personalizer/overview)).
* Research CV / GridSearch: use the head directly (`NimbusLDA`, …) or `adapter.head` on already-encoded embeddings `Z`.
## Personalizer + sklearn heads
Encode once, personalize for apps, tune the head with sklearn:
```python theme={null}
from nimbus_bci import Personalizer, wrap
from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import StandardScaler
import numpy as np
enc = wrap(model.encode, model_id="partner-encoder", embedding_dim=64)
Z_train = enc.encode(X_train)
Z_test = enc.encode(X_test)
# App path — BrainState on already-encoded embeddings
adapter = Personalizer.for_features(["left", "right"], head="lda")
adapter.fit(Z_train, y_train)
states = adapter.predict(Z_test)
# Research path — CV on the same head
scores = cross_val_score(adapter.head, Z_train, y_train, cv=5)
print(f"CV accuracy: {scores.mean():.2%}")
print(states[0].intent, states[0].confidence)
```
## sklearn-Compatible API
All nimbus-bci heads implement the sklearn estimator interface:
```python theme={null}
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
# Standard sklearn methods
clf.fit(X_train, y_train) # Train
predictions = clf.predict(X_test) # Predict classes
probabilities = clf.predict_proba(X_test) # Predict probabilities
score = clf.score(X_test, y_test) # Accuracy score
```
## Pipelines
### Basic Pipeline
Combine scaling with a Nimbus head (classical features or precomputed embeddings):
```python theme={null}
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from nimbus_bci import NimbusLDA
# Create pipeline — head is also Personalizer(head="lda") under the hood
pipe = make_pipeline(
StandardScaler(),
NimbusLDA(mu_scale=3.0)
)
# Fit and predict
pipe.fit(X_train, y_train)
predictions = pipe.predict(X_test)
score = pipe.score(X_test, y_test)
print(f"Accuracy: {score:.2%}")
```
### Multi-Step Pipeline
Add feature selection and normalization:
```python theme={null}
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, RobustScaler
from sklearn.feature_selection import SelectKBest, f_classif
from nimbus_bci import NimbusLDA
pipe = Pipeline([
('scaler', RobustScaler()),
('feature_selection', SelectKBest(f_classif, k=10)),
('classifier', NimbusLDA(mu_scale=5.0))
])
pipe.fit(X_train, y_train)
print(f"Selected features: {pipe.named_steps['feature_selection'].get_support()}")
```
### BCI-Specific Pipeline
Complete BCI preprocessing pipeline:
```python theme={null}
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from nimbus_bci import NimbusLDA
# Complete BCI pipeline
bci_pipeline = Pipeline([
('normalization', StandardScaler()),
('dimensionality_reduction', PCA(n_components=16)),
('classifier', NimbusLDA(mu_scale=3.0))
])
# Train on CSP features
bci_pipeline.fit(csp_features, labels)
# Real-time prediction
new_trial = extract_features(eeg_chunk)
prediction = bci_pipeline.predict(new_trial.reshape(1, -1))[0]
```
## Cross-Validation
### Basic Cross-Validation
Evaluate model performance:
```python theme={null}
from sklearn.model_selection import cross_val_score
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
scores = cross_val_score(clf, X, y, cv=5, scoring='accuracy')
print(f"Accuracy: {scores.mean():.2%} (+/- {scores.std():.2%})")
```
### Stratified K-Fold
Maintain class balance in folds:
```python theme={null}
from sklearn.model_selection import StratifiedKFold, cross_val_score
from nimbus_bci import NimbusQDA
clf = NimbusQDA()
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(clf, X, y, cv=cv, scoring='accuracy')
print(f"Stratified CV Accuracy: {scores.mean():.2%}")
```
### Multiple Metrics
Evaluate multiple metrics simultaneously:
```python theme={null}
from sklearn.model_selection import cross_validate
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
scoring = ['accuracy', 'precision_macro', 'recall_macro', 'f1_macro']
scores = cross_validate(clf, X, y, cv=5, scoring=scoring)
for metric in scoring:
print(f"{metric}: {scores[f'test_{metric}'].mean():.3f}")
```
### Leave-One-Subject-Out
For BCI with multiple subjects:
```python theme={null}
from sklearn.model_selection import LeaveOneGroupOut, cross_val_score
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
logo = LeaveOneGroupOut()
# subject_ids: array indicating which subject each trial belongs to
scores = cross_val_score(clf, X, y, groups=subject_ids, cv=logo)
print(f"LOSO Accuracy: {scores.mean():.2%}")
print(f"Per-subject scores: {scores}")
```
## Hyperparameter Tuning
### Grid Search
Exhaustive search over parameter grid:
```python theme={null}
from sklearn.model_selection import GridSearchCV
from nimbus_bci import NimbusLDA
# Define parameter grid
param_grid = {
'mu_scale': [1.0, 3.0, 5.0, 7.0],
'class_prior_alpha': [0.5, 1.0, 2.0]
}
# Grid search with cross-validation
grid = GridSearchCV(
NimbusLDA(),
param_grid,
cv=5,
scoring='accuracy',
n_jobs=-1,
verbose=1
)
grid.fit(X_train, y_train)
print(f"Best parameters: {grid.best_params_}")
print(f"Best CV score: {grid.best_score_:.2%}")
# Use best model
best_clf = grid.best_estimator_
test_score = best_clf.score(X_test, y_test)
print(f"Test accuracy: {test_score:.2%}")
```
### Random Search
More efficient for large parameter spaces:
```python theme={null}
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import uniform, randint
from nimbus_bci import NimbusQDA
# Define parameter distributions
param_dist = {
'mu_scale': uniform(1.0, 10.0),
'class_prior_alpha': uniform(0.1, 5.0)
}
# Random search
random_search = RandomizedSearchCV(
NimbusQDA(),
param_dist,
n_iter=20,
cv=5,
scoring='accuracy',
n_jobs=-1,
random_state=42
)
random_search.fit(X_train, y_train)
print(f"Best parameters: {random_search.best_params_}")
```
### Pipeline Parameter Tuning
Tune parameters across entire pipeline:
```python theme={null}
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import GridSearchCV
from nimbus_bci import NimbusLDA
pipe = Pipeline([
('scaler', StandardScaler()),
('clf', NimbusLDA())
])
# Tune both preprocessing and classifier
param_grid = {
'scaler__with_mean': [True, False],
'scaler__with_std': [True, False],
'clf__mu_scale': [1.0, 3.0, 5.0],
'clf__class_prior_alpha': [0.5, 1.0, 2.0]
}
grid = GridSearchCV(pipe, param_grid, cv=5, n_jobs=-1)
grid.fit(X_train, y_train)
print(f"Best pipeline: {grid.best_params_}")
```
## Model Selection
### Compare Multiple Heads
Compare different nimbus-bci heads (same choices as `Personalizer(head=...)`):
```python theme={null}
from sklearn.model_selection import cross_val_score
from nimbus_bci import NimbusLDA, NimbusQDA, NimbusSTS
heads = {
'LDA': NimbusLDA(),
'QDA': NimbusQDA(),
'STS': NimbusSTS()
}
results = {}
for name, clf in heads.items():
scores = cross_val_score(clf, X, y, cv=5, scoring='accuracy')
results[name] = scores
print(f"{name}: {scores.mean():.2%} (+/- {scores.std():.2%})")
# Select best
best_head = max(results.items(), key=lambda x: x[1].mean())
print(f"\nBest head: {best_head[0]}")
```
### Compare with sklearn Classifiers
Benchmark against sklearn models:
```python theme={null}
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from sklearn.model_selection import cross_val_score
from nimbus_bci import NimbusLDA
classifiers = {
'NimbusLDA': NimbusLDA(),
'sklearn LDA': LinearDiscriminantAnalysis(),
'Random Forest': RandomForestClassifier(n_estimators=100),
'SVM': SVC(probability=True)
}
for name, clf in classifiers.items():
scores = cross_val_score(clf, X, y, cv=5)
print(f"{name}: {scores.mean():.2%} (+/- {scores.std():.2%})")
```
## Feature Selection
### Univariate Feature Selection
Select best features before classification:
```python theme={null}
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.pipeline import Pipeline
from nimbus_bci import NimbusLDA
pipe = Pipeline([
('feature_selection', SelectKBest(f_classif, k=10)),
('classifier', NimbusLDA())
])
pipe.fit(X_train, y_train)
# Get selected features
selected = pipe.named_steps['feature_selection'].get_support()
print(f"Selected {selected.sum()} features")
```
### Recursive Feature Elimination
Iteratively remove least important features:
```python theme={null}
from sklearn.feature_selection import RFE
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
rfe = RFE(clf, n_features_to_select=10)
rfe.fit(X_train, y_train)
print(f"Selected features: {rfe.support_}")
print(f"Feature ranking: {rfe.ranking_}")
```
## Ensemble Methods
### Voting Classifier
Combine multiple nimbus-bci classifiers:
```python theme={null}
from sklearn.ensemble import VotingClassifier
from nimbus_bci import NimbusLDA, NimbusQDA, NimbusSTS
ensemble = VotingClassifier(
estimators=[
('lda', NimbusLDA()),
('qda', NimbusQDA()),
('sts', NimbusSTS())
],
voting='soft' # Use probabilities
)
ensemble.fit(X_train, y_train)
score = ensemble.score(X_test, y_test)
print(f"Ensemble accuracy: {score:.2%}")
```
### Bagging
Bootstrap aggregating for variance reduction:
```python theme={null}
from sklearn.ensemble import BaggingClassifier
from nimbus_bci import NimbusLDA
bagging = BaggingClassifier(
estimator=NimbusLDA(),
n_estimators=10,
random_state=42
)
bagging.fit(X_train, y_train)
score = bagging.score(X_test, y_test)
print(f"Bagging accuracy: {score:.2%}")
```
## Calibration
### Probability Calibration
Calibrate predicted probabilities:
```python theme={null}
from sklearn.calibration import CalibratedClassifierCV
from nimbus_bci import NimbusLDA
# Calibrate with cross-validation
calibrated = CalibratedClassifierCV(
NimbusLDA(),
method='isotonic',
cv=5
)
calibrated.fit(X_train, y_train)
probs = calibrated.predict_proba(X_test)
# Check calibration
from sklearn.calibration import calibration_curve
prob_true, prob_pred = calibration_curve(y_test, probs[:, 1], n_bins=10)
```
## Custom Transformers
### BCI-Specific Transformer
Create custom transformer for BCI preprocessing:
```python theme={null}
from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np
class TemporalAggregator(BaseEstimator, TransformerMixin):
"""Aggregate temporal features for BCI."""
def __init__(self, method='logvar'):
self.method = method
def fit(self, X, y=None):
return self
def transform(self, X):
if self.method == 'logvar':
return np.log(np.var(X, axis=1))
elif self.method == 'mean':
return np.mean(X, axis=1)
elif self.method == 'rms':
return np.sqrt(np.mean(X**2, axis=1))
return X
# Use in pipeline
from sklearn.pipeline import Pipeline
from nimbus_bci import NimbusLDA
pipe = Pipeline([
('temporal_agg', TemporalAggregator(method='logvar')),
('classifier', NimbusLDA())
])
pipe.fit(X_train, y_train)
```
## Performance Evaluation
### Comprehensive Metrics
Evaluate with multiple metrics:
```python theme={null}
from sklearn.metrics import (
accuracy_score, precision_score, recall_score, f1_score,
confusion_matrix, classification_report
)
from nimbus_bci import NimbusLDA
clf = NimbusLDA()
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.2%}")
print(f"Precision: {precision_score(y_test, y_pred, average='macro'):.2%}")
print(f"Recall: {recall_score(y_test, y_pred, average='macro'):.2%}")
print(f"F1: {f1_score(y_test, y_pred, average='macro'):.2%}")
print("\nConfusion Matrix:")
print(confusion_matrix(y_test, y_pred))
print("\nClassification Report:")
print(classification_report(y_test, y_pred))
```
### ROC and AUC
For binary classification:
```python theme={null}
from sklearn.metrics import roc_curve, auc, roc_auc_score
import matplotlib.pyplot as plt
from nimbus_bci import NimbusQDA
clf = NimbusQDA()
clf.fit(X_train, y_train)
y_probs = clf.predict_proba(X_test)[:, 1]
# ROC curve
fpr, tpr, thresholds = roc_curve(y_test, y_probs)
roc_auc = auc(fpr, tpr)
plt.plot(fpr, tpr, label=f'ROC curve (AUC = {roc_auc:.2f})')
plt.plot([0, 1], [0, 1], 'k--')
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.title('ROC Curve')
plt.legend()
plt.show()
print(f"AUC: {roc_auc_score(y_test, y_probs):.3f}")
```
## Saving and Loading
### Joblib Persistence
Save entire pipeline:
```python theme={null}
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from nimbus_bci import NimbusLDA
import joblib
# Create and train pipeline
pipe = Pipeline([
('scaler', StandardScaler()),
('clf', NimbusLDA())
])
pipe.fit(X_train, y_train)
# Save pipeline
joblib.dump(pipe, 'bci_pipeline.pkl')
# Load pipeline
loaded_pipe = joblib.load('bci_pipeline.pkl')
predictions = loaded_pipe.predict(X_test)
```
## Best Practices
### 1. Always Use Pipelines
Encapsulate preprocessing with classification:
```python theme={null}
# Good: Preprocessing in pipeline
pipe = make_pipeline(StandardScaler(), NimbusLDA())
pipe.fit(X_train, y_train)
pipe.predict(X_test)
# Bad: Manual preprocessing
X_train_scaled = StandardScaler().fit_transform(X_train)
X_test_scaled = StandardScaler().fit_transform(X_test) # Wrong!
clf = NimbusLDA().fit(X_train_scaled, y_train)
```
### 2. Use Stratified Splits
Maintain class balance:
```python theme={null}
from sklearn.model_selection import StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(clf, X, y, cv=cv)
```
### 3. Tune Hyperparameters
Always optimize hyperparameters:
```python theme={null}
from sklearn.model_selection import GridSearchCV
param_grid = {'mu_scale': [1.0, 3.0, 5.0]}
grid = GridSearchCV(NimbusLDA(), param_grid, cv=5)
grid.fit(X_train, y_train)
best_clf = grid.best_estimator_
```
### 4. Evaluate on Held-Out Test Set
Never tune on test data:
```python theme={null}
from sklearn.model_selection import train_test_split
# Split data
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
# Tune on train set only
grid = GridSearchCV(NimbusLDA(), param_grid, cv=5)
grid.fit(X_train, y_train)
# Final evaluation on test set
test_score = grid.score(X_test, y_test)
```
## Next Read
App-facing BrainState contract
Epochs → encoder or CSP → Personalizer
Real-time BCI with chunk processing
Complete API documentation
# Python Streaming Inference
Source: https://docs.nimbusbci.com/python-sdk/streaming-inference
Real-time chunk-by-chunk BCI processing with nimbus-bci. Learn streaming inference, chunk processing, and temporal aggregation for real-time BCI.
Process feature or embedding chunks as they arrive with `StreamingSession` (and `StreamingSessionSTS` for `NimbusSTS`).
This page is the **Python API**. Chunk-size guidelines, when to stream vs batch, and aggregation policy live on [Streaming inference configuration](/inference-configuration/streaming-inference). For app-facing decisions on frozen encoders, prefer [Personalizer](/personalizer/overview) and [BrainState](/personalizer/brain-state).
## Supported heads
| Session | Heads | Notes |
| --------------------- | ----------------------------------------- | ------------------------------------------- |
| `StreamingSession` | `NimbusLDA`, `NimbusQDA`, `NimbusSoftmax` | Stateless; works on a fitted model snapshot |
| `StreamingSessionSTS` | `NimbusSTS` | Propagates latent state across chunks |
## Basic loop
```python theme={null}
from nimbus_bci import NimbusLDA, StreamingSession
from nimbus_bci.data import BCIMetadata
clf = NimbusLDA()
clf.fit(X_calibration, y_calibration)
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=16,
n_classes=4,
chunk_size=125, # samples per chunk — see config page for duration guidance
temporal_aggregation="logvar",
)
session = StreamingSession(clf.model_, metadata)
for chunk in eeg_stream: # each chunk: (n_features, chunk_size)
result = session.process_chunk(chunk)
print(result.prediction, result.confidence)
final = session.finalize_trial(method="weighted_vote")
print(final.prediction, final.confidence, final.entropy, final.n_chunks)
session.reset() # required before the next trial
```
`process_chunk` returns a per-chunk result (`prediction`, `confidence`, `posterior`, …). `finalize_trial` aggregates chunks for the trial; `reset` clears session state.
## Aggregation methods
Pass `method=` to `finalize_trial`. Defaults and when to pick each method are documented on [Streaming inference configuration](/inference-configuration/streaming-inference).
```python theme={null}
final = session.finalize_trial(method="weighted_vote") # default
final = session.finalize_trial(method="majority_vote")
final = session.finalize_trial(method="max_confidence")
final = session.finalize_trial(method="posterior_mean")
```
## Temporal aggregation
When chunks still have a time axis, `BCIMetadata.temporal_aggregation` reduces them before classification (for example `"logvar"` for CSP). Choose the feature type and aggregation on the [config page](/inference-configuration/streaming-inference); the Python session reads them from `BCIMetadata`.
## Bridge to BrainState
Convert a finalized `StreamingResult` for device / UI decisions:
```python theme={null}
from nimbus_bci import NimbusLDA, StreamingSession, brain_state_from_streaming_result
from nimbus_bci.data import BCIMetadata
clf = NimbusLDA().fit(X_cal, y_cal)
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=X_cal.shape[1],
n_classes=2,
chunk_size=25,
temporal_aggregation="logvar",
)
session = StreamingSession(clf.model_, metadata)
# ... session.process_chunk(chunk) for the trial ...
final = session.finalize_trial(method="weighted_vote")
state = brain_state_from_streaming_result(final, classes=["left", "right"])
print(state.intent, state.confidence, state.need_more_data)
session.reset()
```
Prefer `BrainState` for apps; keep `StreamingResult` for research diagnostics. See [BrainState](/personalizer/brain-state).
## StreamingSessionSTS
For non-stationary sessions, use `NimbusSTS` with `StreamingSessionSTS` so latent state propagates across chunks:
```python theme={null}
from nimbus_bci import NimbusSTS
from nimbus_bci.inference import StreamingSessionSTS
from nimbus_bci.data import BCIMetadata
clf = NimbusSTS(transition_cov=0.05, num_steps=100)
clf.fit(X_train, y_train)
metadata = BCIMetadata(
sampling_rate=250.0,
paradigm="motor_imagery",
feature_type="csp",
n_features=16,
n_classes=4,
chunk_size=125,
temporal_aggregation="logvar",
)
session = StreamingSessionSTS(clf, metadata)
for chunk in eeg_stream:
result = session.process_chunk(chunk)
final = session.finalize_trial()
session.reset()
```
Online updates after a trial still use the classifier’s `partial_fit` on trial-level features (your pipeline aggregates chunks). For STS state APIs and when to prefer STS, see [Bayesian STS](/models/rxsts).
## API notes
* Chunk shape must match `(metadata.n_features, metadata.chunk_size)`.
* Always `reset()` between trials so posteriors do not leak.
* Quality gates (confidence / entropy thresholds) are application policy — see [Streaming inference configuration](/inference-configuration/streaming-inference) and [BrainState](/personalizer/brain-state) presets.
## Next read
Chunk size, aggregation policy, quality gates
App-facing intents, presets, and bridges
Full symbol reference
LSL, BrainFlow, and acquisition loops
# Nimbus Quickstart
Source: https://docs.nimbusbci.com/quickstart
Choose the Python or Julia SDK quickstart path and find the setup guide for your first BCI workflow.
Choose the SDK that matches your workflow. The Python SDK leads with `wrap` → `Personalizer` → `BrainState` (classical heads remain available). The Julia SDK uses NimbusSDK.jl and requires an API key for the commercial core.
Install `nimbus-bci`, fit a Personalizer (or classical head), and try streaming / active learning.
Install NimbusSDK.jl, authenticate with an API key, and run your first Julia inference workflow.
Using a frozen neural encoder (EEGNet, BrainDecode, REVE, …)? Start with [Personalizer & Middleware](/personalizer/overview) instead of the classical classifier path.
## Before You Start
* Use **Python 3.11+** for the Python SDK or **Julia 1.9+** for the Julia SDK.
* Provide **embeddings** (`encode(X) → Z`) or **preprocessed EEG features**, not raw EEG.
* Start with [Personalizer & Middleware](/personalizer/overview) for the encoder contract, or [Preprocessing Requirements](/inference-configuration/preprocessing-requirements) for CSP / bandpower / ERP features.
Nimbus focuses on Bayesian personalization and BCI workflow tooling. Filtering, artifact removal, epoching, and feature extraction (or a frozen trunk) happen before data reaches the head.
## Common Next Steps
Encoder contract, BrainState, and decision presets.
Compare NimbusLDA, NimbusQDA, NimbusSoftmax, NimbusProbit, and NimbusSTS heads.
Normalize features consistently across sessions.
Configure chunk-by-chunk low-latency inference.
## FAQ
No. The Python SDK (`nimbus-bci`) runs locally and does not require an API key. The Julia SDK (`NimbusSDK.jl`) requires an API key to install and use NimbusSDKCore.
Choose Python for Personalizer / BrainState app integration, sklearn/MNE, and local installation. Choose Julia for the NimbusSDK.jl workflow, RxInfer-backed models, or Julia model registry tooling.
Embeddings or extracted features — not raw EEG. Personalizer expects `(n_trials, n_features)` after encode (or with `encoder=None`). Classical classifier examples use the same shape. Julia batch data usually uses `(n_features, n_samples, n_trials)` inside `BCIData`.