# Copyright 2024 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import typing as tp
from dataclasses import dataclass
from functools import partial
import jax
import jax.numpy as jnp
import numpy as np
from brainstate._compatible_import import Tracer
from brainstate._state import LongTermState
__all__ = [
'MetricState',
'Metric',
'AverageMetric',
'WelfordMetric',
'AccuracyMetric',
'MultiMetric',
'PrecisionMetric',
'RecallMetric',
'F1ScoreMetric',
'ConfusionMatrix',
]
class MetricState(LongTermState):
"""
Wrapper class for Metric Variables.
This class extends ``State`` to provide a container for metric state variables
that need to be tracked and updated during training or evaluation.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> state = brainstate.nn.MetricState(jnp.array(0.0))
>>> state.value
Array(0., dtype=float32)
>>> state.value = jnp.array(1.5)
>>> state.value
Array(1.5, dtype=float32)
"""
__module__ = "brainstate.nn"
class Metric(object):
"""
Base class for metrics.
Any class that subclasses ``Metric`` should implement ``compute``, ``reset``,
and ``update`` methods to track and compute evaluation metrics.
Methods
-------
reset()
Reset the metric state to initial values.
update(**kwargs)
Update the metric state with new data.
compute()
Compute and return the current metric value.
Notes
-----
This is an abstract base class and should not be instantiated directly.
Subclasses must implement all three methods.
"""
__module__ = "brainstate.nn"
[docs]
def reset(self) -> None:
"""
In-place reset the metric state to initial values.
This method should restore all internal state variables to their
initial values as if the metric was just constructed.
Raises
------
NotImplementedError
If the subclass does not implement this method.
"""
raise NotImplementedError('Must override `reset()` method.')
[docs]
def update(self, **kwargs) -> None:
"""
In-place update the metric with new data.
Parameters
----------
**kwargs
Keyword arguments containing the data to update the metric.
The specific arguments depend on the metric implementation.
Raises
------
NotImplementedError
If the subclass does not implement this method.
"""
raise NotImplementedError('Must override `update()` method.')
[docs]
def compute(self):
"""
Compute and return the current value of the metric.
Returns
-------
metric_value
The computed metric value. The type depends on the specific metric.
Raises
------
NotImplementedError
If the subclass does not implement this method.
"""
raise NotImplementedError('Must override `compute()` method.')
class AverageMetric(Metric):
"""
Average metric for computing running mean of values.
This metric maintains a running sum and count to compute the average
of all values passed to it via the ``update`` method.
Parameters
----------
argname : str, optional
The keyword argument name that ``update`` will use to derive the new value.
Defaults to ``'values'``.
Attributes
----------
argname : str
The keyword argument name for updates.
total : MetricState
Cumulative sum of all values.
count : MetricState
Total number of elements processed.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> batch_loss = jnp.array([1, 2, 3, 4])
>>> batch_loss2 = jnp.array([3, 2, 1, 0])
>>> metrics = brainstate.nn.AverageMetric()
>>> metrics.compute()
Array(nan, dtype=float32)
>>> metrics.update(values=batch_loss)
>>> metrics.compute()
Array(2.5, dtype=float32)
>>> metrics.update(values=batch_loss2)
>>> metrics.compute()
Array(2., dtype=float32)
>>> metrics.reset()
>>> metrics.compute()
Array(nan, dtype=float32)
Notes
-----
The metric returns NaN when no values have been added (count = 0).
This metric can handle scalar values, arrays, or tensors.
"""
__module__ = "brainstate.nn"
def __init__(self, argname: str = 'values'):
self.argname = argname
self.total = MetricState(jnp.array(0, dtype=jnp.float32))
self.count = MetricState(jnp.array(0, dtype=jnp.int32))
[docs]
def reset(self) -> None:
"""
Reset the metric state to zero.
This sets both the total sum and count to zero.
"""
self.total.value = jnp.array(0, dtype=jnp.float32)
self.count.value = jnp.array(0, dtype=jnp.int32)
[docs]
def update(self, **kwargs) -> None:
"""
Update the metric with new values.
Parameters
----------
**kwargs
Must contain ``self.argname`` as a key, mapping to the values
to be averaged. Values can be scalars, arrays, or tensors.
Raises
------
TypeError
If the expected keyword argument is not provided.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> metric = brainstate.nn.AverageMetric('loss')
>>> metric.update(loss=jnp.array([1.0, 2.0, 3.0]))
>>> metric.compute()
Array(2., dtype=float32)
"""
if self.argname not in kwargs:
raise TypeError(f"Expected keyword argument '{self.argname}'")
values: tp.Union[int, float, jax.Array] = kwargs[self.argname]
self.total.value += (
values if isinstance(values, (int, float)) else values.sum()
)
self.count.value += 1 if isinstance(values, (int, float)) else values.size
[docs]
def compute(self) -> jax.Array:
"""
Compute and return the average.
Returns
-------
jax.Array
The average of all values provided to ``update``.
Returns NaN if no values have been added.
"""
return self.total.value / self.count.value
@partial(
jax.tree_util.register_dataclass,
data_fields=['mean', 'standard_error_of_mean', 'standard_deviation'],
meta_fields=[]
)
@dataclass
class Statistics:
"""
Dataclass for statistical measurements.
Attributes
----------
mean : float32
The mean value.
standard_error_of_mean : float32
The standard error of the mean (SEM).
standard_deviation : float32
The standard deviation.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> stats = brainstate.nn.Statistics(
... mean=jnp.float32(2.5),
... standard_error_of_mean=jnp.float32(0.5),
... standard_deviation=jnp.float32(1.0)
... )
>>> stats.mean
Array(2.5, dtype=float32)
"""
__module__ = "brainstate.nn"
mean: jnp.float32
standard_error_of_mean: jnp.float32
standard_deviation: jnp.float32
class WelfordMetric(Metric):
"""
Welford's algorithm for computing mean and variance of streaming data.
This metric uses Welford's online algorithm to compute running statistics
(mean, variance, standard deviation) in a numerically stable way.
Parameters
----------
argname : str, optional
The keyword argument name that ``update`` will use to derive the new value.
Defaults to ``'values'``.
Attributes
----------
argname : str
The keyword argument name for updates.
count : MetricState
Total number of elements processed.
mean : MetricState
Running mean estimate.
m2 : MetricState
Running sum of squared deviations from the mean.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> batch_loss = jnp.array([1, 2, 3, 4])
>>> batch_loss2 = jnp.array([3, 2, 1, 0])
>>> metrics = brainstate.nn.WelfordMetric()
>>> metrics.compute()
Statistics(mean=Array(0., dtype=float32), standard_error_of_mean=Array(nan, dtype=float32), standard_deviation=Array(nan, dtype=float32))
>>> metrics.update(values=batch_loss)
>>> metrics.compute()
Statistics(mean=Array(2.5, dtype=float32), standard_error_of_mean=Array(0.559017, dtype=float32), standard_deviation=Array(1.118034, dtype=float32))
>>> metrics.update(values=batch_loss2)
>>> metrics.compute()
Statistics(mean=Array(2., dtype=float32), standard_error_of_mean=Array(0.43301272, dtype=float32), standard_deviation=Array(1.2247449, dtype=float32))
>>> metrics.reset()
>>> metrics.compute()
Statistics(mean=Array(0., dtype=float32), standard_error_of_mean=Array(nan, dtype=float32), standard_deviation=Array(nan, dtype=float32))
Notes
-----
Welford's algorithm is numerically stable and computes variance in a single pass.
The algorithm updates the mean and variance incrementally as new data arrives.
References
----------
.. [1] Welford, B. P. (1962). "Note on a method for calculating corrected sums
of squares and products". Technometrics. 4 (3): 419-420.
"""
__module__ = "brainstate.nn"
def __init__(self, argname: str = 'values'):
self.argname = argname
self.count = MetricState(jnp.array(0, dtype=jnp.int32))
self.mean = MetricState(jnp.array(0, dtype=jnp.float32))
self.m2 = MetricState(jnp.array(0, dtype=jnp.float32))
[docs]
def reset(self) -> None:
"""
Reset the metric state to zero.
This resets count, mean, and the sum of squared deviations (m2).
"""
self.count.value = jnp.array(0, dtype=jnp.int32)
self.mean.value = jnp.array(0, dtype=jnp.float32)
self.m2.value = jnp.array(0, dtype=jnp.float32)
[docs]
def update(self, **kwargs) -> None:
"""
Update the metric using Welford's algorithm.
Parameters
----------
**kwargs
Must contain ``self.argname`` as a key, mapping to the values
to be processed. Values can be scalars, arrays, or tensors.
Raises
------
TypeError
If the expected keyword argument is not provided.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> metric = brainstate.nn.WelfordMetric('data')
>>> metric.update(data=jnp.array([1.0, 2.0, 3.0]))
>>> stats = metric.compute()
>>> stats.mean
Array(2., dtype=float32)
"""
if self.argname not in kwargs:
raise TypeError(f"Expected keyword argument '{self.argname}'")
values: tp.Union[int, float, jax.Array] = kwargs[self.argname]
count = 1 if isinstance(values, (int, float)) else values.size
original_count = self.count.value
self.count.value += count
delta = (
values if isinstance(values, (int, float)) else values.mean()
) - self.mean.value
self.mean.value += delta * count / self.count.value
m2 = 0.0 if isinstance(values, (int, float)) else values.var() * count
self.m2.value += (
m2 + delta * delta * count * original_count / self.count.value
)
[docs]
def compute(self) -> Statistics:
"""
Compute and return statistical measurements.
Returns
-------
Statistics
A dataclass containing mean, standard error of mean, and standard deviation.
Returns NaN for error metrics when count is 0.
"""
variance = self.m2.value / self.count.value
standard_deviation = variance ** 0.5
sem = standard_deviation / (self.count.value ** 0.5)
return Statistics(
mean=self.mean.value,
standard_error_of_mean=sem,
standard_deviation=standard_deviation,
)
class AccuracyMetric(AverageMetric):
"""
Accuracy metric for classification tasks.
This metric computes the accuracy by comparing predicted labels (derived from
logits using argmax) with ground truth labels. It inherits from ``AverageMetric``
and shares the same ``reset`` and ``compute`` implementations.
Examples
--------
.. code-block:: python
>>> import brainstate
>>> import jax, jax.numpy as jnp
>>> logits = jax.random.normal(jax.random.key(0), (5, 2))
>>> labels = jnp.array([1, 1, 0, 1, 0])
>>> logits2 = jax.random.normal(jax.random.key(1), (5, 2))
>>> labels2 = jnp.array([0, 1, 1, 1, 1])
>>> metrics = brainstate.nn.AccuracyMetric()
>>> metrics.compute()
Array(nan, dtype=float32)
>>> metrics.update(logits=logits, labels=labels)
>>> metrics.compute()
Array(0.6, dtype=float32)
>>> metrics.update(logits=logits2, labels=labels2)
>>> metrics.compute()
Array(0.7, dtype=float32)
>>> metrics.reset()
>>> metrics.compute()
Array(nan, dtype=float32)
Notes
-----
The accuracy is computed as the fraction of correct predictions:
accuracy = (number of correct predictions) / (total predictions)
Logits are converted to predictions using argmax along the last dimension.
"""
__module__ = "brainstate.nn"
[docs]
def update(self, *, logits: jax.Array, labels: jax.Array, **_) -> None:
"""
Update the accuracy metric with predictions and labels.
Parameters
----------
logits : jax.Array
Predicted activations/logits with shape (..., num_classes).
The last dimension represents class scores.
labels : jax.Array
Ground truth integer labels with shape (...,).
Must be one dimension less than logits.
**_
Additional keyword arguments are ignored.
Raises
------
ValueError
If logits and labels have incompatible shapes, or if labels have
incorrect dtype.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> logits = jnp.array([[0.1, 0.9], [0.8, 0.2], [0.3, 0.7]])
>>> labels = jnp.array([1, 0, 1])
>>> metric = brainstate.nn.AccuracyMetric()
>>> metric.update(logits=logits, labels=labels)
>>> metric.compute()
Array(1., dtype=float32)
"""
if logits.ndim != labels.ndim + 1:
raise ValueError(
f'Expected logits.ndim==labels.ndim+1, got {logits.ndim} and {labels.ndim}'
)
elif not jnp.issubdtype(labels.dtype, jnp.integer):
raise ValueError(f'Expected integer labels.dtype, got {labels.dtype}')
labels = jnp.astype(labels, jnp.int32)
super().update(values=(logits.argmax(axis=-1) == labels))
class PrecisionMetric(Metric):
"""
Precision metric for binary and multi-class classification.
Precision is the ratio of true positives to all positive predictions:
precision = TP / (TP + FP)
Parameters
----------
num_classes : int, optional
Number of classes. If None, assumes binary classification. Default is None.
average : str, optional
Type of averaging for multi-class: 'micro', 'macro', or 'weighted'.
Default is 'macro'. Ignored for binary classification.
Attributes
----------
num_classes : int or None
Number of classes.
average : str
Averaging method for multi-class.
true_positives : MetricState
Count of true positive predictions.
false_positives : MetricState
Count of false positive predictions.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> predictions = jnp.array([1, 0, 1, 1, 0])
>>> labels = jnp.array([1, 0, 0, 1, 0])
>>> metric = brainstate.nn.PrecisionMetric()
>>> metric.update(predictions=predictions, labels=labels)
>>> metric.compute()
Array(0.6666667, dtype=float32)
Notes
-----
For multi-class classification, the metric supports different averaging strategies:
- 'micro': Calculate metrics globally by counting total TP and FP
- 'macro': Calculate metrics for each class and find their unweighted mean
- 'weighted': Calculate metrics for each class and find their weighted mean
"""
__module__ = "brainstate.nn"
def __init__(self, num_classes: tp.Optional[int] = None, average: str = 'macro'):
if average not in ('micro', 'macro', 'weighted'):
raise ValueError(
f"`average` must be one of 'micro', 'macro', 'weighted'. Got {average!r}."
)
self.num_classes = num_classes
self.average = average
if num_classes is None:
self.true_positives = MetricState(jnp.array(0, dtype=jnp.int32))
self.false_positives = MetricState(jnp.array(0, dtype=jnp.int32))
else:
self.true_positives = MetricState(jnp.zeros(num_classes, dtype=jnp.int32))
self.false_positives = MetricState(jnp.zeros(num_classes, dtype=jnp.int32))
# Per-class support (number of true labels of each class), used by the
# 'weighted' average.
self.support = MetricState(jnp.zeros(num_classes, dtype=jnp.int32))
[docs]
def reset(self) -> None:
"""Reset the metric state to zero."""
if self.num_classes is None:
self.true_positives.value = jnp.array(0, dtype=jnp.int32)
self.false_positives.value = jnp.array(0, dtype=jnp.int32)
else:
self.true_positives.value = jnp.zeros(self.num_classes, dtype=jnp.int32)
self.false_positives.value = jnp.zeros(self.num_classes, dtype=jnp.int32)
self.support.value = jnp.zeros(self.num_classes, dtype=jnp.int32)
[docs]
def update(self, *, predictions: jax.Array, labels: jax.Array, **_) -> None:
"""
Update the precision metric.
Parameters
----------
predictions : jax.Array
Predicted class labels (integers).
labels : jax.Array
Ground truth class labels (integers).
**_
Additional keyword arguments are ignored.
"""
if self.num_classes is None:
# Binary classification
self.true_positives.value += jnp.sum((predictions == 1) & (labels == 1))
self.false_positives.value += jnp.sum((predictions == 1) & (labels == 0))
else:
# Multi-class classification
for c in range(self.num_classes):
self.true_positives.value = self.true_positives.value.at[c].add(
jnp.sum((predictions == c) & (labels == c))
)
self.false_positives.value = self.false_positives.value.at[c].add(
jnp.sum((predictions == c) & (labels != c))
)
self.support.value = self.support.value.at[c].add(
jnp.sum(labels == c)
)
[docs]
def compute(self) -> jax.Array:
"""
Compute and return the precision.
Returns
-------
jax.Array
The precision value(s). For binary classification, returns a scalar.
For multi-class, returns per-class or averaged precision based on
the ``average`` parameter.
"""
denominator = self.true_positives.value + self.false_positives.value
precision = jnp.where(
denominator > 0,
self.true_positives.value / denominator,
jnp.zeros_like(denominator, dtype=jnp.float32)
)
if self.num_classes is not None and self.average == 'macro':
return jnp.mean(precision)
elif self.num_classes is not None and self.average == 'micro':
total_tp = jnp.sum(self.true_positives.value)
total_fp = jnp.sum(self.false_positives.value)
return jnp.where(
total_tp + total_fp > 0,
total_tp / (total_tp + total_fp),
jnp.float32(0.0)
)
elif self.num_classes is not None and self.average == 'weighted':
support = self.support.value
total = jnp.sum(support)
return jnp.where(
total > 0,
jnp.sum(precision * support) / total,
jnp.float32(0.0)
)
return precision
class RecallMetric(Metric):
"""
Recall (sensitivity) metric for binary and multi-class classification.
Recall is the ratio of true positives to all actual positives:
recall = TP / (TP + FN)
Parameters
----------
num_classes : int, optional
Number of classes. If None, assumes binary classification. Default is None.
average : str, optional
Type of averaging for multi-class: 'micro', 'macro', or 'weighted'.
Default is 'macro'. Ignored for binary classification.
Attributes
----------
num_classes : int or None
Number of classes.
average : str
Averaging method for multi-class.
true_positives : MetricState
Count of true positive predictions.
false_negatives : MetricState
Count of false negative predictions.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> predictions = jnp.array([1, 0, 1, 1, 0])
>>> labels = jnp.array([1, 0, 0, 1, 0])
>>> metric = brainstate.nn.RecallMetric()
>>> metric.update(predictions=predictions, labels=labels)
>>> metric.compute()
Array(1., dtype=float32)
Notes
-----
Recall measures the fraction of actual positive cases that were correctly identified.
Also known as sensitivity or true positive rate (TPR).
"""
__module__ = "brainstate.nn"
def __init__(self, num_classes: tp.Optional[int] = None, average: str = 'macro'):
if average not in ('micro', 'macro', 'weighted'):
raise ValueError(
f"`average` must be one of 'micro', 'macro', 'weighted'. Got {average!r}."
)
self.num_classes = num_classes
self.average = average
if num_classes is None:
self.true_positives = MetricState(jnp.array(0, dtype=jnp.int32))
self.false_negatives = MetricState(jnp.array(0, dtype=jnp.int32))
else:
self.true_positives = MetricState(jnp.zeros(num_classes, dtype=jnp.int32))
self.false_negatives = MetricState(jnp.zeros(num_classes, dtype=jnp.int32))
[docs]
def reset(self) -> None:
"""Reset the metric state to zero."""
if self.num_classes is None:
self.true_positives.value = jnp.array(0, dtype=jnp.int32)
self.false_negatives.value = jnp.array(0, dtype=jnp.int32)
else:
self.true_positives.value = jnp.zeros(self.num_classes, dtype=jnp.int32)
self.false_negatives.value = jnp.zeros(self.num_classes, dtype=jnp.int32)
[docs]
def update(self, *, predictions: jax.Array, labels: jax.Array, **_) -> None:
"""
Update the recall metric.
Parameters
----------
predictions : jax.Array
Predicted class labels (integers).
labels : jax.Array
Ground truth class labels (integers).
**_
Additional keyword arguments are ignored.
"""
if self.num_classes is None:
# Binary classification
self.true_positives.value += jnp.sum((predictions == 1) & (labels == 1))
self.false_negatives.value += jnp.sum((predictions == 0) & (labels == 1))
else:
# Multi-class classification
for c in range(self.num_classes):
self.true_positives.value = self.true_positives.value.at[c].add(
jnp.sum((predictions == c) & (labels == c))
)
self.false_negatives.value = self.false_negatives.value.at[c].add(
jnp.sum((predictions != c) & (labels == c))
)
[docs]
def compute(self) -> jax.Array:
"""
Compute and return the recall.
Returns
-------
jax.Array
The recall value(s). For binary classification, returns a scalar.
For multi-class, returns per-class or averaged recall based on
the ``average`` parameter.
"""
denominator = self.true_positives.value + self.false_negatives.value
recall = jnp.where(
denominator > 0,
self.true_positives.value / denominator,
jnp.zeros_like(denominator, dtype=jnp.float32)
)
if self.num_classes is not None and self.average == 'macro':
return jnp.mean(recall)
elif self.num_classes is not None and self.average == 'micro':
total_tp = jnp.sum(self.true_positives.value)
total_fn = jnp.sum(self.false_negatives.value)
return jnp.where(
total_tp + total_fn > 0,
total_tp / (total_tp + total_fn),
jnp.float32(0.0)
)
elif self.num_classes is not None and self.average == 'weighted':
# Per-class support equals TP + FN, i.e. the recall denominator.
total = jnp.sum(denominator)
return jnp.where(
total > 0,
jnp.sum(recall * denominator) / total,
jnp.float32(0.0)
)
return recall
class F1ScoreMetric(Metric):
"""
F1 score metric for binary and multi-class classification.
F1 score is the harmonic mean of precision and recall:
F1 = 2 * (precision * recall) / (precision + recall)
Parameters
----------
num_classes : int, optional
Number of classes. If None, assumes binary classification. Default is None.
average : str, optional
Type of averaging for multi-class: 'micro', 'macro', or 'weighted'.
Default is 'macro'. Ignored for binary classification.
Attributes
----------
precision_metric : PrecisionMetric
Internal precision metric.
recall_metric : RecallMetric
Internal recall metric.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> predictions = jnp.array([1, 0, 1, 1, 0])
>>> labels = jnp.array([1, 0, 0, 1, 0])
>>> metric = brainstate.nn.F1ScoreMetric()
>>> metric.update(predictions=predictions, labels=labels)
>>> metric.compute()
Array(0.8, dtype=float32)
Notes
-----
The F1 score balances precision and recall, providing a single metric that
considers both false positives and false negatives.
"""
__module__ = "brainstate.nn"
def __init__(self, num_classes: tp.Optional[int] = None, average: str = 'macro'):
self.precision_metric = PrecisionMetric(num_classes, average)
self.recall_metric = RecallMetric(num_classes, average)
[docs]
def reset(self) -> None:
"""Reset the metric state to zero."""
self.precision_metric.reset()
self.recall_metric.reset()
[docs]
def update(self, *, predictions: jax.Array, labels: jax.Array, **_) -> None:
"""
Update the F1 score metric.
Parameters
----------
predictions : jax.Array
Predicted class labels (integers).
labels : jax.Array
Ground truth class labels (integers).
**_
Additional keyword arguments are ignored.
"""
self.precision_metric.update(predictions=predictions, labels=labels)
self.recall_metric.update(predictions=predictions, labels=labels)
[docs]
def compute(self) -> jax.Array:
"""
Compute and return the F1 score.
Returns
-------
jax.Array
The F1 score value(s). Returns 0 when both precision and recall are 0.
"""
nc = self.precision_metric.num_classes
avg = self.precision_metric.average
if nc is None:
p = self.precision_metric.compute()
r = self.recall_metric.compute()
denom = p + r
return jnp.where(denom > 0, 2 * p * r / denom, jnp.float32(0.0))
if avg == 'micro':
p = self.precision_metric.compute()
r = self.recall_metric.compute()
denom = p + r
return jnp.where(denom > 0, 2 * p * r / denom, jnp.float32(0.0))
tp = self.precision_metric.true_positives.value
fp = self.precision_metric.false_positives.value
fn = self.recall_metric.false_negatives.value
support = self.precision_metric.support.value # TP + FN per class
p_den = tp + fp
p_c = jnp.where(p_den > 0, tp / p_den, jnp.zeros_like(p_den, dtype=jnp.float32))
r_den = tp + fn
r_c = jnp.where(r_den > 0, tp / r_den, jnp.zeros_like(r_den, dtype=jnp.float32))
f_den = p_c + r_c
f1_c = jnp.where(f_den > 0, 2 * p_c * r_c / f_den, jnp.zeros_like(f_den, dtype=jnp.float32))
if avg == 'macro':
return jnp.mean(f1_c)
else: # 'weighted'
total = jnp.sum(support)
return jnp.where(total > 0, jnp.sum(f1_c * support) / total, jnp.float32(0.0))
class ConfusionMatrix(Metric):
"""
Confusion matrix metric for multi-class classification.
A confusion matrix shows the counts of predicted vs. actual class labels,
where rows represent true labels and columns represent predicted labels.
Parameters
----------
num_classes : int
Number of classes in the classification task.
Attributes
----------
num_classes : int
Number of classes.
matrix : MetricState
The confusion matrix of shape (num_classes, num_classes).
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> predictions = jnp.array([0, 1, 2, 1, 0])
>>> labels = jnp.array([0, 1, 1, 1, 2])
>>> metric = brainstate.nn.ConfusionMatrix(num_classes=3)
>>> metric.update(predictions=predictions, labels=labels)
>>> metric.compute()
Array([[1, 0, 0],
[0, 2, 1],
[1, 0, 0]], dtype=int32)
Notes
-----
The confusion matrix is useful for understanding which classes are being
confused with each other and for computing class-specific metrics.
"""
__module__ = "brainstate.nn"
def __init__(self, num_classes: int):
self.num_classes = num_classes
self.matrix = MetricState(jnp.zeros((num_classes, num_classes), dtype=jnp.int32))
[docs]
def reset(self) -> None:
"""Reset the confusion matrix to zeros."""
self.matrix.value = jnp.zeros((self.num_classes, self.num_classes), dtype=jnp.int32)
[docs]
def update(self, *, predictions: jax.Array, labels: jax.Array, **_) -> None:
"""
Update the confusion matrix.
Parameters
----------
predictions : jax.Array
Predicted class labels (integers) with shape (batch_size,).
labels : jax.Array
Ground truth class labels (integers) with shape (batch_size,).
**_
Additional keyword arguments are ignored.
Raises
------
ValueError
If predictions or labels contain values outside [0, num_classes).
"""
predictions = jnp.asarray(predictions, dtype=jnp.int32).flatten()
labels = jnp.asarray(labels, dtype=jnp.int32).flatten()
if not (isinstance(predictions, Tracer) or isinstance(labels, Tracer)):
if jnp.any((predictions < 0) | (predictions >= self.num_classes)):
raise ValueError(f"Predictions contain values outside [0, {self.num_classes})")
if jnp.any((labels < 0) | (labels >= self.num_classes)):
raise ValueError(f"Labels contain values outside [0, {self.num_classes})")
for true_label in range(self.num_classes):
for pred_label in range(self.num_classes):
count = jnp.sum((labels == true_label) & (predictions == pred_label))
self.matrix.value = self.matrix.value.at[true_label, pred_label].add(count)
[docs]
def compute(self) -> jax.Array:
"""
Compute and return the confusion matrix.
Returns
-------
jax.Array
The confusion matrix of shape (num_classes, num_classes).
Element [i, j] represents the count of samples with true label i
that were predicted as label j.
"""
return self.matrix.value
class MultiMetric(Metric):
"""
Container for multiple metrics updated simultaneously.
This class allows you to group multiple metrics together and update them
all with a single call. It's useful for tracking multiple evaluation metrics
(e.g., accuracy, loss, F1 score) during training or evaluation.
Parameters
----------
**metrics
Keyword arguments where keys are metric names (strings) and values
are Metric instances.
Attributes
----------
_metric_names : list of str
List of metric names in the order they were added.
Examples
--------
.. code-block:: python
>>> import brainstate
>>> import jax, jax.numpy as jnp
>>> metrics = brainstate.nn.MultiMetric(
... accuracy=brainstate.nn.AccuracyMetric(),
... loss=brainstate.nn.AverageMetric(),
... )
>>> logits = jax.random.normal(jax.random.key(0), (5, 2))
>>> labels = jnp.array([1, 1, 0, 1, 0])
>>> batch_loss = jnp.array([1, 2, 3, 4])
>>> metrics.compute()
{'accuracy': Array(nan, dtype=float32), 'loss': Array(nan, dtype=float32)}
>>> metrics.update(logits=logits, labels=labels, values=batch_loss)
>>> metrics.compute()
{'accuracy': Array(0.6, dtype=float32), 'loss': Array(2.5, dtype=float32)}
>>> metrics.reset()
>>> metrics.compute()
{'accuracy': Array(nan, dtype=float32), 'loss': Array(nan, dtype=float32)}
Notes
-----
All keyword arguments passed to ``update`` are forwarded to all underlying metrics.
Each metric will extract the arguments it needs based on its implementation.
Reserved names ('reset', 'update', 'compute', '_metric_names') cannot be used as
metric names.
"""
__module__ = "brainstate.nn"
def __init__(self, **metrics):
# Validate that no reserved names are used. This includes the public method
# names *and* the internal ``_metric_names`` bookkeeping attribute, which
# would otherwise be silently overwritten by a metric of the same name and
# break ``reset``/``update``/``compute``.
reserved_names = {'reset', 'update', 'compute', '_metric_names'}
for metric_name in metrics.keys():
if metric_name in reserved_names:
raise ValueError(
f"Metric name '{metric_name}' is reserved for class methods. "
f"Please use a different name. Reserved names: {reserved_names}"
)
self._metric_names = []
for metric_name, metric in metrics.items():
if not isinstance(metric, Metric):
raise TypeError(
f"All metrics must be instances of Metric, got {type(metric)} "
f"for metric '{metric_name}'"
)
self._metric_names.append(metric_name)
vars(self)[metric_name] = metric
[docs]
def reset(self) -> None:
"""
Reset all underlying metrics.
This calls the ``reset`` method on each metric in the collection.
"""
for metric_name in self._metric_names:
getattr(self, metric_name).reset()
[docs]
def update(self, **updates) -> None:
"""
Update all underlying metrics.
All keyword arguments are passed to the ``update`` method of each metric.
Individual metrics will extract the arguments they need.
Parameters
----------
**updates
Keyword arguments to be passed to all underlying metrics.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> import brainstate
>>> metrics = brainstate.nn.MultiMetric(
... accuracy=brainstate.nn.AccuracyMetric(),
... loss=brainstate.nn.AverageMetric('loss_value'),
... )
>>> logits = jnp.array([[0.2, 0.8], [0.9, 0.1]])
>>> labels = jnp.array([1, 0])
>>> loss = jnp.array([0.5, 0.3])
>>> metrics.update(logits=logits, labels=labels, loss_value=loss)
"""
for metric_name in self._metric_names:
getattr(self, metric_name).update(**updates)
[docs]
def compute(self) -> dict[str, tp.Any]:
"""
Compute and return all metric values.
Returns
-------
dict[str, Any]
Dictionary mapping metric names to their computed values.
The value type depends on the specific metric implementation.
Examples
--------
.. code-block:: python
>>> import brainstate
>>> metrics = brainstate.nn.MultiMetric(
... loss=brainstate.nn.AverageMetric(),
... stats=brainstate.nn.WelfordMetric(),
... )
>>> # After updates...
>>> results = metrics.compute()
>>> results['loss'] # Returns a scalar
>>> results['stats'] # Returns a Statistics object
"""
return {
metric_name: getattr(self, metric_name).compute()
for metric_name in self._metric_names
}