stat: define ROC cutoffs instead of using number of cutoffs
MODIFY
stat/roc.go
stat/roc.go
package stat
-import "sort"
+import (
+ "math"
+ "sort"
+)
// ROC returns paired false positive rate (FPR) and true positive rate
-// (TPR) values corresponding to n cutoffs spanning the relative
-// (or receiver) operator characteristic (ROC) curve obtained when y is
-// treated as a binary classifier for classes with weights.
+// (TPR) values corresponding to cutoff points on the receiver operator
+// characteristic (ROC) curve obtained when y is treated as a binary
+// classifier for classes with weights.
//
-// Cutoffs are equally spaced from eps less than the minimum value of y
-// to the maximum value of y, including both endpoints meaning that the
-// resulting ROC curve will always begin at (0,0) and end at (1,1).
-//
-// The input y must be sorted, and SortWeightedLabeled can be used in
-// order to sort y together with classes and weights.
+// The input y and cutoffs must be sorted, and values in y must correspond
+// to values in classes and weights. SortWeightedLabeled can be used to
+// sort y together with classes and weights.
//
// For a given cutoff value, observations corresponding to entries in y
// greater than the cutoff value are classified as false, while those
-// below (or equal to) the cutoff value are classified as true. These
+// less than or equal to the cutoff value are classified as true. These
// assigned class labels are compared with the true values in the classes
// slice and used to calculate the FPR and TPR.
//
// If weights is nil, all weights are treated as 1.
//
-// When n is zero all possible cutoffs are calculated, resulting
-// in fpr and tpr having length one greater than the number of unique
-// values in y. When n is greater than one fpr and tpr will be returned
-// with length n. ROC will panic if n is equal to one or less than 0.
+// If cutoffs is nil or empty, all possible cutoffs are calculated,
+// resulting in fpr and tpr having length one greater than the number of
+// unique values in y. Otherwise fpr and tpr will be returned with the
+// same length as cutoffs. floats.Span can be used to generate equally
+// spaced cutoffs.
//
// More details about ROC curves are available at
// https://en.wikipedia.org/wiki/Receiver_operating_characteristic
-func ROC(n int, y []float64, classes []bool, weights []float64) (tpr, fpr []float64) {
+func ROC(cutoffs, y []float64, classes []bool, weights []float64) (tpr, fpr []float64) {
if len(y) != len(classes) {
panic("stat: slice length mismatch")
}
@@ -41,37 +42,48 @@ func ROC(n int, y []float64, classes []bool, weights []float64) (tpr, fpr []floa
panic("stat: slice length mismatch")
}
if !sort.Float64sAreSorted(y) {
- panic("stat: input must be sorted")
+ panic("stat: input must be sorted ascending")
+ }
+ if !sort.Float64sAreSorted(cutoffs) {
+ panic("stat: cutoff values must be sorted ascending")
}
-
- var incWidth, tol float64
- if n == 0 {
if len(y) == 0 {
return nil, nil
}
- tpr = make([]float64, len(y)+1)
- fpr = make([]float64, len(y)+1)
+ var bin int
+ if len(cutoffs) == 0 {
+ if cutoffs == nil || cap(cutoffs) < len(y)+1 {
+ cutoffs = make([]float64, len(y)+1)
} else {
- if n < 2 {
- panic("stat: cannot calculate fewer than 2 points on a ROC curve")
+ cutoffs = cutoffs[:len(y)+1]
}
- if len(y) == 0 {
- return nil, nil
+ cutoffs[0] = math.Nextafter(y[0], y[0]-1)
+ // Choose all possible cutoffs but remove duplicate values
+ // in y.
+ for i, u := range y {
+ if i != 0 && u != y[i-1] {
+ bin++
}
- tpr = make([]float64, n)
- fpr = make([]float64, n)
- incWidth = (y[len(y)-1] - y[0]) / float64(n-1)
- tol = y[0] + incWidth
- if incWidth == 0 {
- tpr[n-1] = 1
- fpr[n-1] = 1
- return
+ cutoffs[bin+1] = u
}
+ cutoffs = cutoffs[0 : bin+2]
}
- bin := 1 // the initial bin is known to have 0 fpr and 0 tpr
+ tpr = make([]float64, len(cutoffs))
+ fpr = make([]float64, len(cutoffs))
+ bin = 0
var nPos, nNeg float64
for i, u := range classes {
+ // Update the bin until it matches the next y value
+ // skipping empty bins.
+ for bin < len(cutoffs) && y[i] > cutoffs[bin] {
+ if bin == len(cutoffs)-1 {
+ break
+ }
+ bin++
+ tpr[bin] = tpr[bin-1]
+ fpr[bin] = fpr[bin-1]
+ }
var posWeight, negWeight float64 = 0, 1
if weights != nil {
negWeight = weights[i]
@@ -81,31 +93,10 @@ func ROC(n int, y []float64, classes []bool, weights []float64) (tpr, fpr []floa
}
nPos += posWeight
nNeg += negWeight
+ if y[i] <= cutoffs[bin] {
tpr[bin] += posWeight
fpr[bin] += negWeight
-
- // Assess if the bin needs to be updated. If n is zero,
- // the bin is always updated, unless consecutive y values
- // are equal. Otherwise, the bin must be updated until it
- // matches the next y value (skipping empty bins).
- if n == 0 {
- if i != (len(y)-1) && y[i] != y[i+1] {
- bin++
- tpr[bin] = tpr[bin-1]
- fpr[bin] = fpr[bin-1]
- }
- } else {
- for i != (len(y)-1) && y[i+1] > tol {
- tol += incWidth
- bin++
- tpr[bin] = tpr[bin-1]
- fpr[bin] = fpr[bin-1]
- }
- }
}
- if n == 0 {
- tpr = tpr[:(bin + 1)]
- fpr = fpr[:(bin + 1)]
}
var invNeg, invPos float64
@@ -119,8 +110,6 @@ func ROC(n int, y []float64, classes []bool, weights []float64) (tpr, fpr []floa
tpr[i] *= invPos
fpr[i] *= invNeg
}
- tpr[len(tpr)-1] = 1
- fpr[len(fpr)-1] = 1
return tpr, fpr
}

MODIFY
stat/roc_example_test.go
stat/roc_example_test.go
@@ -6,7 +6,9 @@ package stat_test
import (
"fmt"
+ "math"
+ "gonum.org/v1/gonum/floats"
"gonum.org/v1/gonum/integrate"
"gonum.org/v1/gonum/stat"
)
@@ -16,7 +18,7 @@ func ExampleROC_weighted() {
classes := []bool{true, false, true, false, false, false}
weights := []float64{4, 1, 6, 3, 2, 2}
- tpr, fpr := stat.ROC(0, y, classes, weights)
+ tpr, fpr := stat.ROC(nil, y, classes, weights)
fmt.Printf("true positive rate: %v\n", tpr)
fmt.Printf("false positive rate: %v\n", fpr)
@@ -29,7 +31,7 @@ func ExampleROC_unweighted() {
y := []float64{0, 3, 5, 6, 7.5, 8}
classes := []bool{true, false, true, false, false, false}
- tpr, fpr := stat.ROC(0, y, classes, nil)
+ tpr, fpr := stat.ROC(nil, y, classes, nil)
fmt.Printf("true positive rate: %v\n", tpr)
fmt.Printf("false positive rate: %v\n", fpr)
@@ -38,12 +40,65 @@ func ExampleROC_unweighted() {
// false positive rate: [0 0 0.25 0.25 0.5 0.75 1]
}
+func ExampleROC_unsorted() {
+ y := []float64{8, 7.5, 6, 5, 3, 0}
+ classes := []bool{false, false, false, true, false, true}
+ weights := []float64{2, 2, 3, 6, 1, 4}
+
+ stat.SortWeightedLabeled(y, classes, weights)
+
+ tpr, fpr := stat.ROC(nil, y, classes, weights)
+ fmt.Printf("true positive rate: %v\n", tpr)
+ fmt.Printf("false positive rate: %v\n", fpr)
+
+ // Output:
+ // true positive rate: [0 0.4 0.4 1 1 1 1]
+ // false positive rate: [0 0 0.125 0.125 0.5 0.75 1]
+}
+
+func ExampleROC_knownCutoffs() {
+ y := []float64{8, 7.5, 6, 5, 3, 0}
+ classes := []bool{false, false, false, true, false, true}
+ weights := []float64{2, 2, 3, 6, 1, 4}
+ cutoffs := []float64{-1, 3, 4}
+
+ stat.SortWeightedLabeled(y, classes, weights)
+
+ tpr, fpr := stat.ROC(cutoffs, y, classes, weights)
+ fmt.Printf("true positive rate: %v\n", tpr)
+ fmt.Printf("false positive rate: %v\n", fpr)
+
+ // Output:
+ // true positive rate: [0 0.4 0.4]
+ // false positive rate: [0 0.125 0.125]
+}
+
+func ExampleROC_equallySpacedCutoffs() {
+ y := []float64{8, 7.5, 6, 5, 3, 0}
+ classes := []bool{false, false, false, true, false, true}
+ weights := []float64{2, 2, 3, 6, 1, 4}
+ n := 9
+
+ stat.SortWeightedLabeled(y, classes, weights)
+ cutoffs := make([]float64, n)
+ floats.Span(cutoffs, math.Nextafter(y[0], y[0]-1), y[len(y)-1])
+
+ tpr, fpr := stat.ROC(cutoffs, y, classes, weights)
+ fmt.Printf("true positive rate: %v\n", tpr)
+ fmt.Printf("false positive rate: %v\n", fpr)
+
+ // Output:
+ // true positive rate: [0 0.4 0.4 0.4 0.4 1 1 1 1]
+ // false positive rate: [0 0 0 0.125 0.125 0.125 0.5 0.5 1]
+}
+
func ExampleROC_aUC() {
y := []float64{0.1, 0.35, 0.4, 0.8}
classes := []bool{true, false, true, false}
- tpr, fpr := stat.ROC(0, y, classes, nil)
- // compute Area Under Curve
+ tpr, fpr := stat.ROC(nil, y, classes, nil)
+
+ // Compute Area Under Curve.
auc := integrate.Trapezoidal(fpr, tpr)
fmt.Printf("true positive rate: %v\n", tpr)
fmt.Printf("false positive rate: %v\n", fpr)

MODIFY
stat/roc_test.go
stat/roc_test.go
@@ -10,164 +10,164 @@ import (
"gonum.org/v1/gonum/floats"
)
-// Test cases where calculated manually.
+// Test cases were calculated manually.
func TestROC(t *testing.T) {
cases := []struct {
y []float64
c []bool
w []float64
- n int
+ cutoffs []float64
wantTPR []float64
wantFPR []float64
}{
- {
+ { // 0
y: []float64{0, 3, 5, 6, 7.5, 8},
c: []bool{true, false, true, false, false, false},
wantTPR: []float64{0, 0.5, 0.5, 1, 1, 1, 1},
wantFPR: []float64{0, 0, 0.25, 0.25, 0.5, 0.75, 1},
},
- {
+ { // 1
y: []float64{0, 3, 5, 6, 7.5, 8},
c: []bool{true, false, true, false, false, false},
w: []float64{4, 1, 6, 3, 2, 2},
wantTPR: []float64{0, 0.4, 0.4, 1, 1, 1, 1},
wantFPR: []float64{0, 0, 0.125, 0.125, 0.5, 0.75, 1},
},
- {
+ { // 2
y: []float64{0, 3, 5, 6, 7.5, 8},
c: []bool{true, false, true, false, false, false},
- n: int(5),
+ cutoffs: []float64{-1, 2, 4, 6, 8},
wantTPR: []float64{0, 0.5, 0.5, 1, 1},
wantFPR: []float64{0, 0, 0.25, 0.5, 1},
},
- {
+ { // 3
y: []float64{0, 3, 5, 6, 7.5, 8},
c: []bool{true, false, true, false, false, false},
- n: int(9),
+ cutoffs: []float64{-1, 1, 2, 3, 4, 5, 6, 7, 8},
wantTPR: []float64{0, 0.5, 0.5, 0.5, 0.5, 1, 1, 1, 1},
wantFPR: []float64{0, 0, 0, 0.25, 0.25, 0.25, 0.5, 0.5, 1},
},
- {
+ { // 4
y: []float64{0, 3, 5, 6, 7.5, 8},
c: []bool{true, false, true, false, false, false},
w: []float64{4, 1, 6, 3, 2, 2},
- n: int(5),
+ cutoffs: []float64{-1, 2, 4, 6, 8},
wantTPR: []float64{0, 0.4, 0.4, 1, 1},
wantFPR: []float64{0, 0, 0.125, 0.5, 1},
},
- {
+ { // 5
y: []float64{0, 3, 5, 6, 7.5, 8},
c: []bool{true, false, true, false, false, false},
w: []float64{4, 1, 6, 3, 2, 2},
- n: int(9),
+ cutoffs: []float64{-1, 1, 2, 3, 4, 5, 6, 7, 8},
wantTPR: []float64{0, 0.4, 0.4, 0.4, 0.4, 1, 1, 1, 1},
wantFPR: []float64{0, 0, 0, 0.125, 0.125, 0.125, 0.5, 0.5, 1},
},
- {
+ { // 6
y: []float64{0, 3, 6, 6, 6, 8},
c: []bool{true, false, true, false, false, false},
wantTPR: []float64{0, 0.5, 0.5, 1, 1},
wantFPR: []float64{0, 0, 0.25, 0.75, 1},
},
- {
+ { // 7
y: []float64{0, 3, 6, 6, 6, 8},
c: []bool{true, false, true, false, false, false},
w: []float64{4, 1, 6, 3, 2, 2},
wantTPR: []float64{0, 0.4, 0.4, 1, 1},
wantFPR: []float64{0, 0, 0.125, 0.75, 1},
},
- {
+ { // 8
y: []float64{0, 3, 6, 6, 6, 8},
c: []bool{true, false, true, false, false, false},
- n: int(5),
+ cutoffs: []float64{-1, 2, 4, 6, 8},
wantTPR: []float64{0, 0.5, 0.5, 1, 1},
wantFPR: []float64{0, 0, 0.25, 0.75, 1},
},
- {
+ { // 9
y: []float64{0, 3, 6, 6, 6, 8},
c: []bool{true, false, true, false, false, false},
- n: int(9),
+ cutoffs: []float64{-1, 1, 2, 3, 4, 5, 6, 7, 8},
wantTPR: []float64{0, 0.5, 0.5, 0.5, 0.5, 0.5, 1, 1, 1},
wantFPR: []float64{0, 0, 0, 0.25, 0.25, 0.25, 0.75, 0.75, 1},
},
- {
+ { // 10
y: []float64{0, 3, 6, 6, 6, 8},
c: []bool{true, false, true, false, false, false},
w: []float64{4, 1, 6, 3, 2, 2},
- n: int(5),
+ cutoffs: []float64{-1, 2, 4, 6, 8},
wantTPR: []float64{0, 0.4, 0.4, 1, 1},
wantFPR: []float64{0, 0, 0.125, 0.75, 1},
},
- {
+ { // 11
y: []float64{0, 3, 6, 6, 6, 8},
c: []bool{true, false, true, false, false, false},
w: []float64{4, 1, 6, 3, 2, 2},
- n: int(9),
+ cutoffs: []float64{-1, 1, 2, 3, 4, 5, 6, 7, 8},
wantTPR: []float64{0, 0.4, 0.4, 0.4, 0.4, 0.4, 1, 1, 1},
wantFPR: []float64{0, 0, 0, 0.125, 0.125, 0.125, 0.75, 0.75, 1},
},
- {
+ { // 12
y: []float64{1, 2},
c: []bool{true, true},
wantTPR: []float64{0, 0.5, 1},
- wantFPR: []float64{0, 0, 1},
+ wantFPR: []float64{0, 0, 0},
},
- {
+ { // 13
y: []float64{1, 2},
c: []bool{true, true},
- n: int(2),
+ cutoffs: []float64{-1, 2},
wantTPR: []float64{0, 1},
- wantFPR: []float64{0, 1},
+ wantFPR: []float64{0, 0},
},
- {
+ { // 14
y: []float64{1, 2},
c: []bool{true, true},
- n: int(7),
- wantTPR: []float64{0, 0.5, 0.5, 0.5, 0.5, 0.5, 1},
- wantFPR: []float64{0, 0, 0, 0, 0, 0, 1},
+ cutoffs: []float64{0, 1.2, 1.4, 1.6, 1.8, 2},
+ wantTPR: []float64{0, 0.5, 0.5, 0.5, 0.5, 1},
+ wantFPR: []float64{0, 0, 0, 0, 0, 0},
},
- {
+ { // 15
y: []float64{1},
c: []bool{true},
wantTPR: []float64{0, 1},
- wantFPR: []float64{0, 1},
+ wantFPR: []float64{0, 0},
},
- {
+ { // 16
y: []float64{1},
c: []bool{true},
- n: int(2),
+ cutoffs: []float64{-1, 1},
wantTPR: []float64{0, 1},
- wantFPR: []float64{0, 1},
+ wantFPR: []float64{0, 0},
},
- {
+ { // 17
y: []float64{1},
c: []bool{false},
- wantTPR: []float64{0, 1},
+ wantTPR: []float64{0, 0},
wantFPR: []float64{0, 1},
},
- {
+ { // 18
y: []float64{0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 10},
c: []bool{true, false, true, true, false, false, true},
- n: int(5),
+ cutoffs: []float64{-1, 2.5, 5, 7.5, 10},
wantTPR: []float64{0, 0.75, 0.75, 0.75, 1},
wantFPR: []float64{0, 1, 1, 1, 1},
},
- {
+ { // 19
y: []float64{},
c: []bool{},
wantTPR: nil,
wantFPR: nil,
},
- {
+ { // 20
y: []float64{},
c: []bool{},
- n: int(5),
+ cutoffs: []float64{-1, 2.5, 5, 7.5, 10},
wantTPR: nil,
wantFPR: nil,
},
}
for i, test := range cases {
- gotTPR, gotFPR := ROC(test.n, test.y, test.c, test.w)
+ gotTPR, gotFPR := ROC(test.cutoffs, test.y, test.c, test.w)
if !floats.Same(gotTPR, test.wantTPR) {
t.Errorf("%d: unexpected TPR got:%v want:%v", i, gotTPR, test.wantTPR)
}

stat: define ROC cutoffs instead of using number of cutoffs