Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 183 additions & 12 deletions python/mlx/nn/losses.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import math

import mlx.core as mx
from mlx.nn.layers.base import Module


def cross_entropy(
Expand Down Expand Up @@ -272,17 +271,6 @@ def triplet_loss(
return _reduce(loss, reduction)


def _reduce(loss: mx.array, reduction: str = "none"):
if reduction == "mean":
return mx.mean(loss)
elif reduction == "sum":
return mx.sum(loss)
elif reduction == "none":
return loss
else:
raise ValueError("Invalid reduction. Must be 'none', 'mean', or 'sum'.")


def hinge_loss(
inputs: mx.array, targets: mx.array, reduction: str = "none"
) -> mx.array:
Expand Down Expand Up @@ -372,3 +360,186 @@ def log_cosh_loss(
loss = mx.logaddexp(errors, -errors) - math.log(2)

return _reduce(loss, reduction)


def focal_loss(
inputs: mx.array,
targets: mx.array,
alpha: float = 0.25,
gamma: float = 2.0,
reduction: str = "none",
) -> mx.array:
r"""
Computes the Focal Loss between inputs and targets, which is designed to address
class imbalance by focusing more on hard-to-classify examples.

.. math::

FL(p_t) = -\alpha_t (1 - p_t)^{\gamma} \log(p_t)

Args:
inputs (array): The predicted logits or probabilities.
targets (array): The ground truth target values.
Comment on lines +381 to +382

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion

        inputs (mx.array): Predicted probabilities for the positive class.
        targets (mx.array): The target values (binary).

alpha (float, optional): The balancing parameter. Default: 0.25.
gamma (float, optional): The focusing parameter. Default: 2.0.
reduction (str, optional): Specifies the reduction to apply to the output:
``'none'`` | ``'mean'`` | ``'sum'``. Default: ``'none'``.

Returns:
array: The computed Focal Loss.
"""
if gamma < 0:
raise ValueError(f"Focal loss gamma must be non-negative, got {gamma}.")

# Calculating the cross-entropy loss
ce_loss = mx.logaddexp(0.0, inputs) - targets * inputs
Comment on lines +394 to +395

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestions. It's for binary target value so it's binary cross entropy, and you could use pre-defined bce loss for concise understanding.

    BCE_loss = binary_cross_entropy(inputs, targets)
    pt = mx.exp(-BCE_loss)
    loss = -alpha * ((1 - pt) ** gamma) * BCE_loss
    return _reduce(loss, reduction)


# Calculating the probability
pt = mx.exp(-ce_loss)

# Calculating Focal Loss
focal_loss = -alpha * ((1 - pt) ** gamma) * ce_loss

return _reduce(focal_loss, reduction)


def dice_loss(
inputs: mx.array, targets: mx.array, epsilon: float = 1e-6, reduction: str = "none"
) -> mx.array:
r"""
Computes the Dice Loss, which is a measure of overlap between two samples.
This loss is commonly used for binary segmentation tasks.
Comment on lines +406 to +411

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI we already decided this one was a bit niche to put in nn in #224 .


.. math::

\text{Dice Loss} = 1 - \frac{2 \times |X \cap Y|}{|X| + |Y|}

Args:
inputs (array): The predicted values.
targets (array): The ground truth values.
epsilon (float, optional): Small constant for numerical stability. Default: 1e-6.
reduction (str, optional): Specifies the reduction to apply to the output:
``'none'`` | ``'mean'`` | ``'sum'``. Default: ``'none'``.

Returns:
array: The computed Dice Loss.
"""
intersection = mx.sum(inputs * targets, axis=1)
cardinality = mx.sum(inputs + targets, axis=1)
dice_score = (2.0 * intersection + epsilon) / (cardinality + epsilon)
loss = 1 - dice_score

return _reduce(loss, reduction)


def iou_loss(
inputs: mx.array, targets: mx.array, epsilon: float = 1e-6, reduction: str = "none"
) -> mx.array:
r"""
Computes the Intersection over Union (IoU) Loss, which is a measure of the
overlap between two sets, typically used in segmentation tasks.

.. math::

\text{IoU Loss} = 1 - \frac{X \cap Y}{X \cup Y}

Args:
inputs (array): The predicted values.
targets (array): The ground truth values.
epsilon (float, optional): Small constant for numerical stability. Default: 1e-6.
reduction (str, optional): Specifies the reduction to apply to the output:
``'none'`` | ``'mean'`` | ``'sum'``. Default: ``'none'``.

Returns:
array: The computed IoU Loss.
"""
intersection = mx.sum(inputs * targets, axis=1)
union = mx.sum(inputs + targets - inputs * targets, axis=1)
iou_score = (intersection + epsilon) / (union + epsilon)
loss = 1 - iou_score

return _reduce(loss, reduction)


def contrastive_loss(
anchors: mx.array,
positives: mx.array,
negatives: mx.array,
margin: float = 1.0,
p: int = 2,
reduction: str = "none",
) -> mx.array:
r"""
Computes the Contrastive Loss for a set of anchor, positive, and negative samples.

.. math::

L_{\text{contrastive}} = \max\left(\|A - P\|_p - \|A - N\|_p + \text{margin}, 0\right)

Args:
anchors (array): The anchor samples.
positives (array): The positive samples.
negatives (array): The negative samples.
margin (float, optional): Margin for the contrastive loss. Defaults to ``1.0``.
p (int, optional): The norm degree for pairwise distance. Default: ``2``.
reduction (str, optional): Specifies the reduction to apply to the output:
``'none'`` | ``'mean'`` | ``'sum'``. Default: ``'none'``.

Returns:
array: Computed contrastive loss.
"""
positive_distance = mx.sqrt(mx.power(anchors - positives, p).sum(axis=1))
negative_distance = mx.sqrt(mx.power(anchors - negatives, p).sum(axis=1))
loss = mx.maximum(positive_distance - negative_distance + margin, 0)

return _reduce(loss, reduction)


def tversky_loss(
inputs: mx.array,
targets: mx.array,
alpha: float = 0.5,
beta: float = 0.5,
epsilon: float = 1e-6,
reduction: str = "none",
) -> mx.array:
r"""
Computes the Tversky Loss, a generalization of the Dice Loss, allowing more control over false
positives and false negatives. It is particularly useful in segmentation tasks with imbalanced datasets.

.. math::

\text{Tversky Loss} = 1 - \frac{|X \cap Y|}{|X \cap Y| + \alpha |X \backslash Y| + \beta |Y \backslash X|}

Args:
inputs (array): The predicted values.
targets (array): The ground truth values.
alpha (float, optional): Controls the penalty for false negatives. Default: 0.5.
beta (float, optional): Controls the penalty for false positives. Default: 0.5.
epsilon (float, optional): Small constant for numerical stability. Default: 1e-6.
reduction (str, optional): Specifies the reduction to apply to the output:
``'none'`` | ``'mean'`` | ``'sum'``. Default: ``'none'``.

Returns:
array: The computed Tversky Loss.
"""
intersection = mx.sum(inputs * targets, axis=1)
false_negatives = mx.sum(inputs * (1 - targets), axis=1)
false_positives = mx.sum((1 - inputs) * targets, axis=1)
tversky_index = (intersection + epsilon) / (
intersection + alpha * false_negatives + beta * false_positives + epsilon
)
loss = 1 - tversky_index

return _reduce(loss, reduction)


def _reduce(loss: mx.array, reduction: str = "none"):
if reduction == "mean":
return mx.mean(loss)
elif reduction == "sum":
return mx.sum(loss)
elif reduction == "none":
return loss
else:
raise ValueError("Invalid reduction. Must be 'none', 'mean', or 'sum'.")
72 changes: 72 additions & 0 deletions python/tests/test_nn.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,78 @@ def test_log_cosh_loss(self):
loss = nn.losses.log_cosh_loss(inputs, targets, reduction="mean")
self.assertAlmostEqual(loss.item(), 0.433781, places=6)

def test_focal_loss(self):
inputs = mx.array([[2.0, -1.0, 3.0, 0.1], [-1.0, 2.0, -0.5, 0.2]])
targets = mx.array([[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]])
Comment on lines +796 to +797

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is better to use original test examples used above.

Also, it is likely to use pre-defined expected output rather than copy of your loss fuction at losses.py

  ce_loss = mx.logaddexp(0.0, inputs) - targets * inputs
  pt = mx.exp(-ce_loss)
  expected_loss = -alpha * ((1 - pt) ** gamma) * ce_loss
  expected_loss = mx.mean(expected_loss)
  loss = nn.losses.focal_loss(
      inputs, targets, alpha=alpha, gamma=gamma, reduction="mean"
  )

These are my codes that uses like [[0.5, 0.5, 0.2, 0.9], [0.1, 0.3, 0.5, 0.5]]. You can extend test functions to tversky_loss and iou. However, I am not sure why less common loss are added on this pr.

Nice work!

cc @awni

    def test_dice_loss(self):
        inputs = mx.array([[0.5, 0.5, 0.2, 0.9], [0.1, 0.3, 0.5, 0.5]])
        targets = mx.array([[1, 0, 1, 0], [0, 1, 0, 1]])

        # Test with reduction 'none'
        losses_none = nn.losses.dice_loss(inputs, targets, reduction="none")
        expected_none = mx.array([0.658536, 0.529412])  # Example expected values
        self.assertTrue(mx.allclose(losses_none, expected_none))

        # Test with reduction 'mean'
        losses_mean = nn.losses.dice_loss(inputs, targets, reduction="mean")
        expected_mean = mx.mean(expected_none)
        self.assertTrue(mx.allclose(losses_mean, expected_mean))

        # Test with reduction 'sum'
        losses_sum = nn.losses.dice_loss(inputs, targets, reduction="sum")
        expected_sum = mx.sum(expected_none)
        self.assertTrue(mx.allclose(losses_sum, expected_sum))

    def test_focal_loss(self):
        inputs = mx.array([[0.5, 0.5, 0.2, 0.9], [0.1, 0.3, 0.5, 0.5]])
        targets = mx.array([[1, 0, 1, 0], [0, 1, 0, 1]])
        alpha = 0.25
        gamma = 2.0

        # Test with reduction 'none'
        losses_none = nn.losses.focal_loss(
            inputs, targets, alpha, gamma, reduction="none"
        )
        expected_none = mx.array(
            [
                [0.0433217, 0.0433217, 0.25751, 0.466273],
                [0.000263401, 0.147487, 0.0433217, 0.0433217],
            ]
        )
        self.assertTrue(mx.allclose(losses_none, expected_none))

        # Test with reduction 'mean'
        losses_mean = nn.losses.focal_loss(
            inputs, targets, alpha, gamma, reduction="mean"
        )
        expected_mean = mx.mean(expected_none)
        self.assertTrue(mx.allclose(losses_mean, expected_mean))

        # Test with reduction 'sum'
        losses_sum = nn.losses.focal_loss(
            inputs, targets, alpha, gamma, reduction="sum"
        )
        expected_sum = mx.sum(expected_none)
        self.assertTrue(mx.allclose(losses_sum, expected_sum))

    def test_contrastive_loss(self):
        embeddings1 = mx.array([[0.5, 0.5, 0.2, 0.9], [0.1, 0.3, 0.5, 0.5]])
        embeddings2 = mx.array([[0.6, 0.4, 0.3, 0.8], [0.2, 0.5, 0.6, 0.4]])
        targets = mx.array([1, 0])
        margin = 1.0

        # Test with reduction 'none'
        losses_none = nn.losses.contrastive_loss(
            embeddings1, embeddings2, targets, margin, reduction="none"
        )
        expected_none = mx.array([0.2, 0.735425])
        self.assertTrue(mx.allclose(losses_none, expected_none))

        # Test with reduction 'mean'
        losses_mean = nn.losses.contrastive_loss(
            embeddings1, embeddings2, targets, margin, reduction="mean"
        )
        expected_mean = mx.mean(expected_none)
        self.assertTrue(mx.allclose(losses_mean, expected_mean))

        # Test with reduction 'sum'
        losses_sum = nn.losses.contrastive_loss(
            embeddings1, embeddings2, targets, margin, reduction="sum"
        )
        expected_sum = mx.sum(expected_none)
        self.assertTrue(mx.allclose(losses_sum, expected_sum))

alpha = 0.25
gamma = 2.0
ce_loss = mx.logaddexp(0.0, inputs) - targets * inputs
pt = mx.exp(-ce_loss)
expected_loss = -alpha * ((1 - pt) ** gamma) * ce_loss
expected_loss = mx.mean(expected_loss)
loss = nn.losses.focal_loss(
inputs, targets, alpha=alpha, gamma=gamma, reduction="mean"
)
self.assertAlmostEqual(loss.item(), expected_loss.item(), places=6)

def test_dice_loss(self):
inputs = mx.array([[1, 0, 1, 1], [0, 1, 1, 0]])
targets = mx.array([[1, 1, 1, 0], [0, 0, 1, 1]])
epsilon = 1e-6
intersection = mx.sum(inputs * targets, axis=1)
cardinality = mx.sum(inputs + targets, axis=1)
dice_score = (2.0 * intersection + epsilon) / (cardinality + epsilon)
expected_loss = 1 - dice_score
expected_loss = mx.mean(expected_loss)
loss = nn.losses.dice_loss(inputs, targets, epsilon=epsilon, reduction="mean")
self.assertAlmostEqual(loss.item(), expected_loss.item(), places=6)

def test_iou_loss(self):
inputs = mx.array([[1, 0, 1, 1], [0, 1, 1, 0]])
targets = mx.array([[1, 1, 1, 0], [0, 0, 1, 1]])
epsilon = 1e-6
intersection = mx.sum(inputs * targets, axis=1)
union = mx.sum(inputs + targets - inputs * targets, axis=1)
iou_score = (intersection + epsilon) / (union + epsilon)
expected_loss = 1 - iou_score
expected_loss = mx.mean(expected_loss)
loss = nn.losses.iou_loss(inputs, targets, epsilon=epsilon, reduction="mean")
self.assertAlmostEqual(loss.item(), expected_loss.item(), places=6)

def test_contrastive_loss(self):
anchors = mx.array([[1, 2], [3, 4]])
positives = mx.array([[1, 3], [2, 4]])
negatives = mx.array([[5, 6], [7, 8]])
margin = 1.0
p = 2
positive_distance = mx.sqrt(mx.power(anchors - positives, p).sum(axis=1))
negative_distance = mx.sqrt(mx.power(anchors - negatives, p).sum(axis=1))
expected_loss = mx.maximum(positive_distance - negative_distance + margin, 0)
expected_loss = mx.mean(expected_loss)
loss = nn.losses.contrastive_loss(
anchors, positives, negatives, margin=margin, p=p, reduction="mean"
)
self.assertAlmostEqual(loss.item(), expected_loss.item(), places=6)

def test_tversky_loss(self):
inputs = mx.array([[1, 0, 1, 1], [0, 1, 1, 0]])
targets = mx.array([[1, 1, 1, 0], [0, 0, 1, 1]])
alpha = 0.5
beta = 0.5
epsilon = 1e-6
intersection = mx.sum(inputs * targets, axis=1)
false_negatives = mx.sum(inputs * (1 - targets), axis=1)
false_positives = mx.sum((1 - inputs) * targets, axis=1)
tversky_index = (intersection + epsilon) / (
intersection + alpha * false_negatives + beta * false_positives + epsilon
)
expected_loss = 1 - tversky_index
expected_loss = mx.mean(expected_loss)
loss = nn.losses.tversky_loss(
inputs, targets, alpha=alpha, beta=beta, epsilon=epsilon, reduction="mean"
)
self.assertAlmostEqual(loss.item(), expected_loss.item(), places=6)

def test_dropout(self):
x = mx.ones((2, 4))
y = nn.Dropout(0.5)(x)
Expand Down