Skip to content

Commit

Permalink
F-beta score (#185)
Browse files Browse the repository at this point in the history
  • Loading branch information
0urobor0s authored Oct 11, 2023
1 parent 31de556 commit 26e9988
Show file tree
Hide file tree
Showing 2 changed files with 151 additions and 32 deletions.
163 changes: 131 additions & 32 deletions lib/scholar/metrics/classification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ defmodule Scholar.Metrics.Classification do
* `:micro` - Calculate metrics globally by counting the total true positives,
false negatives and false positives.
* `:none` - The f1 scores for each class are returned.
* `:none` - The F-score values for each class are returned.
"""
]
]

fbeta_score_schema = f1_score_schema

confusion_matrix_schema =
general_schema ++
[
Expand Down Expand Up @@ -163,6 +165,7 @@ defmodule Scholar.Metrics.Classification do
@confusion_matrix_schema NimbleOptions.new!(confusion_matrix_schema)
@balanced_accuracy_schema NimbleOptions.new!(balanced_accuracy_schema)
@cohen_kappa_schema NimbleOptions.new!(cohen_kappa_schema)
@fbeta_score_schema NimbleOptions.new!(fbeta_score_schema)
@f1_score_schema NimbleOptions.new!(f1_score_schema)
@brier_score_loss_schema NimbleOptions.new!(brier_score_loss_schema)
@accuracy_schema NimbleOptions.new!(accuracy_schema)
Expand Down Expand Up @@ -586,86 +589,182 @@ defmodule Scholar.Metrics.Classification do
end

@doc """
Calculates F1 score given rank-1 tensors which represent
Calculates F-beta score given rank-1 tensors which represent
the expected (`y_true`) and predicted (`y_pred`) classes.
If all examples are true negatives, then the result is 0 to
avoid zero division.
## Options
#{NimbleOptions.docs(@f1_score_schema)}
#{NimbleOptions.docs(@fbeta_score_schema)}
## Examples
iex> y_true = Nx.tensor([0, 1, 1, 1, 1, 0, 2, 1, 0, 1], type: :u32)
iex> y_pred = Nx.tensor([0, 2, 1, 1, 2, 2, 2, 0, 0, 1], type: :u32)
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3)
iex> Scholar.Metrics.Classification.fbeta_score(y_true, y_pred, Nx.u32(1), num_classes: 3)
#Nx.Tensor<
f32[3]
[0.6666666865348816, 0.6666666865348816, 0.4000000059604645]
>
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3, average: :macro)
iex> Scholar.Metrics.Classification.fbeta_score(y_true, y_pred, Nx.u32(2), num_classes: 3)
#Nx.Tensor<
f32[3]
[0.6666666865348816, 0.5555555820465088, 0.625]
>
iex> Scholar.Metrics.Classification.fbeta_score(y_true, y_pred, Nx.f32(0.5), num_classes: 3)
#Nx.Tensor<
f32[3]
[0.6666666865348816, 0.8333333134651184, 0.29411765933036804]
>
iex> Scholar.Metrics.Classification.fbeta_score(y_true, y_pred, Nx.u32(2), num_classes: 3, average: :macro)
#Nx.Tensor<
f32
0.5777778029441833
0.6157407760620117
>
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3, average: :weighted)
iex> Scholar.Metrics.Classification.fbeta_score(y_true, y_pred, Nx.u32(2), num_classes: 3, average: :weighted)
#Nx.Tensor<
f32
0.6399999856948853
0.5958333611488342
>
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3, average: :micro)
iex> Scholar.Metrics.Classification.fbeta_score(y_true, y_pred, Nx.f32(0.5), num_classes: 3, average: :micro)
#Nx.Tensor<
f32
0.6000000238418579
>
iex> Scholar.Metrics.Classification.f1_score(Nx.tensor([1,0,1,0]), Nx.tensor([0, 1, 0, 1]), num_classes: 2, average: :none)
iex> Scholar.Metrics.Classification.fbeta_score(Nx.tensor([1, 0, 1, 0]), Nx.tensor([0, 1, 0, 1]), Nx.tensor(0.5), num_classes: 2, average: :none)
#Nx.Tensor<
f32[2]
[0.0, 0.0]
>
iex> Scholar.Metrics.Classification.fbeta_score(Nx.tensor([1, 0, 1, 0]), Nx.tensor([0, 1, 0, 1]), 0.5, num_classes: 2, average: :none)
#Nx.Tensor<
f32[2]
[0.0, 0.0]
>
"""
deftransform f1_score(y_true, y_pred, opts \\ []) do
f1_score_n(y_true, y_pred, NimbleOptions.validate!(opts, @f1_score_schema))
deftransform fbeta_score(y_true, y_pred, beta, opts \\ []) do
fbeta_score_n(y_true, y_pred, beta, NimbleOptions.validate!(opts, @fbeta_score_schema))
end

defnp f1_score_n(y_true, y_pred, opts) do
defnp fbeta_score_n(y_true, y_pred, beta, opts) do
check_shape(y_pred, y_true)
num_classes = check_num_classes(opts[:num_classes])
average = opts[:average]

case opts[:average] do
{_precision, _recall, per_class_fscore} =
precision_recall_fscore_n(y_true, y_pred, beta, num_classes, average)

per_class_fscore
end

defnp fbeta_score_v(confusion_matrix, average) do
true_positive = Nx.take_diagonal(confusion_matrix)
false_positive = Nx.sum(confusion_matrix, axes: [0]) - true_positive
false_negative = Nx.sum(confusion_matrix, axes: [1]) - true_positive

case average do
:micro ->
accuracy(y_true, y_pred)
true_positive = Nx.sum(true_positive)
false_positive = Nx.sum(false_positive)
false_negative = Nx.sum(false_negative)

{true_positive, false_positive, false_negative}

_ ->
cm = confusion_matrix(y_true, y_pred, num_classes: num_classes)
true_positive = Nx.take_diagonal(cm)
false_positive = Nx.sum(cm, axes: [0]) - true_positive
false_negative = Nx.sum(cm, axes: [1]) - true_positive
{true_positive, false_positive, false_negative}
end
end

precision = safe_division(true_positive, true_positive + false_positive)
defnp precision_recall_fscore_n(y_true, y_pred, beta, num_classes, average) do
confusion_matrix = confusion_matrix(y_true, y_pred, num_classes: num_classes)
{true_positive, false_positive, false_negative} = fbeta_score_v(confusion_matrix, average)

recall = safe_division(true_positive, true_positive + false_negative)
precision = safe_division(true_positive, true_positive + false_positive)
recall = safe_division(true_positive, true_positive + false_negative)

per_class_f1 = safe_division(2 * precision * recall, precision + recall)
per_class_fscore =
cond do
# Should only be +Inf
Nx.is_infinity(beta) ->
recall

case opts[:average] do
:none ->
per_class_f1
beta == 0 ->
precision

:macro ->
Nx.mean(per_class_f1)
true ->
beta2 = Nx.pow(beta, 2)
safe_division((1 + beta2) * precision * recall, beta2 * precision + recall)
end

:weighted ->
support = (y_true == Nx.iota({num_classes, 1})) |> Nx.sum(axes: [1])
case average do
:none ->
{precision, recall, per_class_fscore}

safe_division(per_class_f1 * support, Nx.sum(support))
|> Nx.sum()
end
:micro ->
{precision, recall, per_class_fscore}

:macro ->
{precision, recall, Nx.mean(per_class_fscore)}

:weighted ->
support = (y_true == Nx.iota({num_classes, 1})) |> Nx.sum(axes: [1])

per_class_fscore =
(per_class_fscore * support)
|> safe_division(Nx.sum(support))
|> Nx.sum()

{precision, recall, per_class_fscore}
end
end

@doc """
Calculates F1 score given rank-1 tensors which represent
the expected (`y_true`) and predicted (`y_pred`) classes.
If all examples are true negatives, then the result is 0 to
avoid zero division.
## Options
#{NimbleOptions.docs(@f1_score_schema)}
## Examples
iex> y_true = Nx.tensor([0, 1, 1, 1, 1, 0, 2, 1, 0, 1], type: :u32)
iex> y_pred = Nx.tensor([0, 2, 1, 1, 2, 2, 2, 0, 0, 1], type: :u32)
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3)
#Nx.Tensor<
f32[3]
[0.6666666865348816, 0.6666666865348816, 0.4000000059604645]
>
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3, average: :macro)
#Nx.Tensor<
f32
0.5777778029441833
>
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3, average: :weighted)
#Nx.Tensor<
f32
0.6399999856948853
>
iex> Scholar.Metrics.Classification.f1_score(y_true, y_pred, num_classes: 3, average: :micro)
#Nx.Tensor<
f32
0.6000000238418579
>
iex> Scholar.Metrics.Classification.f1_score(Nx.tensor([1, 0, 1, 0]), Nx.tensor([0, 1, 0, 1]), num_classes: 2, average: :none)
#Nx.Tensor<
f32[2]
[0.0, 0.0]
>
"""
deftransform f1_score(y_true, y_pred, opts \\ []) do
fbeta_score_n(y_true, y_pred, 1, NimbleOptions.validate!(opts, @f1_score_schema))
end

@doc """
Zero-one classification loss.
Expand Down
20 changes: 20 additions & 0 deletions test/scholar/metrics/classification_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,24 @@ defmodule Scholar.Metrics.ClassificationTest do
assert_all_close(tpr, Nx.tensor([0.0, 0.5, 1.0, 1.0]))
assert_all_close(thresholds, Nx.tensor([1.3, 0.3, 0.2, 0.1]))
end

describe "fbeta_score" do
test "equals recall when beta is infinity" do
beta = Nx.tensor(:infinity)
y_true = Nx.tensor([0, 0, 0, 0, 0, 1, 1, 1, 1, 1], type: :u32)
y_pred = Nx.tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1], type: :u32)
fbeta_scores = Classification.fbeta_score(y_true, y_pred, beta, num_classes: 2)

assert_all_close(fbeta_scores, Classification.recall(y_true, y_pred, num_classes: 2))
end

test "equals precision when beta is 0" do
beta = 0
y_true = Nx.tensor([0, 0, 0, 0, 0, 1, 1, 1, 1, 1], type: :u32)
y_pred = Nx.tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1], type: :u32)
fbeta_scores = Classification.fbeta_score(y_true, y_pred, beta, num_classes: 2)

assert_all_close(fbeta_scores, Classification.precision(y_true, y_pred, num_classes: 2))
end
end
end

0 comments on commit 26e9988

Please sign in to comment.