Skip to content

Commit 5d8ff22

Browse files
committed
Implemented specificity
1 parent 989a6e7 commit 5d8ff22

File tree

2 files changed

+109
-0
lines changed

2 files changed

+109
-0
lines changed

Orange/evaluation/scoring.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import numpy as np
1616
import sklearn.metrics as skl_metrics
17+
from sklearn.metrics import confusion_matrix
1718

1819
from Orange.data import DiscreteVariable, ContinuousVariable
1920
from Orange.misc.wrapper_meta import WrapperMeta
@@ -281,6 +282,57 @@ def compute_score(self, results, eps=1e-15, normalize=True,
281282
dtype=np.float64, count=len(results.probabilities))
282283

283284

285+
class Specificity(ClassificationScore):
286+
is_binary = True
287+
288+
def calculate_weights(self, results):
289+
classes, counts = np.unique(results.actual, return_counts=True)
290+
n = np.array(results.actual).shape[0]
291+
return counts / n, classes
292+
293+
@staticmethod
294+
def specificity(y_true, y_pred):
295+
tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
296+
return tn / (tn + fp)
297+
298+
def single_class_specificity(self, results, target):
299+
y_true = (np.array(results.actual) == target).astype(int)
300+
return np.fromiter(
301+
(self.specificity(y_true,
302+
np.array(predicted == target, dtype=int))
303+
for predicted in results.predicted),
304+
dtype=np.float64, count=len(results.predicted))
305+
306+
def multi_class_specificity(self, results):
307+
weights, classes = self.calculate_weights(results)
308+
scores = np.array([self.single_class_specificity(results, class_)
309+
for class_ in classes])
310+
return np.sum(scores.T * weights, axis=1)
311+
312+
def compute_score(self, results, target=None, average="binary"):
313+
domain = results.domain
314+
n_classes = len(domain.class_var.values)
315+
316+
if n_classes == 0:
317+
raise ValueError("Class variable has less than one values")
318+
else:
319+
if target is None:
320+
if average == "weighted":
321+
return self.multi_class_specificity(results)
322+
else: # average is binary
323+
if n_classes != 2:
324+
raise ValueError(
325+
"Binary averaging needs two classes in data: "
326+
"specify target class or use "
327+
"weighted averaging.")
328+
return self.single_class_specificity(results, 1)
329+
elif target is not None:
330+
return self.single_class_specificity(results, target)
331+
else:
332+
raise ValueError(
333+
"Wrong parameters: For averaging select one of the "
334+
"following values: ('weighted', 'binary')")
335+
284336
# Regression scores
285337

286338
class MSE(RegressionScore):

Orange/tests/test_evaluation_scoring.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
MajorityLearner
1111
from Orange.evaluation import AUC, CA, Results, Recall, \
1212
Precision, TestOnTrainingData, scoring, LogLoss, F1, CrossValidation
13+
from Orange.evaluation.scoring import Specificity
1314
from Orange.preprocess import discretize, Discretize
1415
from Orange.tests import test_filename
1516

@@ -367,6 +368,62 @@ def test_log_loss_calc(self):
367368
self.assertAlmostEqual(ll_calc, ll_orange[0])
368369

369370

371+
class TestSpecificity(unittest.TestCase):
372+
@classmethod
373+
def setUpClass(cls):
374+
cls.iris = Table('iris')
375+
cls.score = Specificity()
376+
377+
def test_specificity_iris(self):
378+
learner = LogisticRegressionLearner(preprocessors=[])
379+
res = TestOnTrainingData()(self.iris, [learner])
380+
self.assertAlmostEqual(self.score(res, average='weighted')[0],
381+
(1 + 0.99 + 0.95) / 3, 5)
382+
self.assertAlmostEqual(self.score(res, target=1)[0], 99 / (99 + 1), 5)
383+
self.assertAlmostEqual(self.score(res, target=1, average=None)[0],
384+
99 / (99 + 1), 5)
385+
self.assertAlmostEqual(self.score(res, target=1, average='weighted')[0],
386+
99 / (99 + 1), 5)
387+
self.assertAlmostEqual(self.score(res, target=0, average=None)[0], 1, 5)
388+
self.assertAlmostEqual(self.score(res, target=2, average=None)[0],
389+
95 / (95 + 5), 5)
390+
391+
def test_precision_multiclass(self):
392+
results = Results(
393+
domain=Domain([], DiscreteVariable(name="y", values="01234")),
394+
actual=[0, 4, 4, 1, 2, 0, 1, 2, 3, 2])
395+
results.predicted = np.array([[0, 4, 4, 1, 2, 0, 1, 2, 3, 2],
396+
[0, 1, 4, 1, 1, 0, 0, 2, 3, 1]])
397+
res = self.score(results, average='weighted')
398+
self.assertEqual(res[0], 1.)
399+
self.assertAlmostEqual(res[1], 0.9, 5)
400+
401+
for target, prob in ((0, 7 / 8),
402+
(1, 5 / 8),
403+
(2, 1),
404+
(3, 1),
405+
(4, 1)):
406+
res = self.score(results, target=target, average=None)
407+
self.assertEqual(res[0], 1.)
408+
self.assertEqual(res[1], prob)
409+
410+
def test_precision_binary(self):
411+
results = Results(
412+
domain=Domain([], DiscreteVariable(name="y", values="01")),
413+
actual=[0, 1, 1, 1, 0, 0, 1, 0, 0, 1])
414+
results.predicted = np.array([[0, 1, 1, 1, 0, 0, 1, 0, 0, 1],
415+
[0, 1, 1, 1, 0, 0, 1, 1, 1, 0]])
416+
res = self.score(results)
417+
self.assertEqual(res[0], 1.)
418+
self.assertAlmostEqual(res[1], 3 / 5)
419+
res_target = self.score(results, target=1)
420+
self.assertEqual(res[0], res_target[0])
421+
self.assertEqual(res[1], res_target[1])
422+
res_target = self.score(results, target=0)
423+
self.assertEqual(res_target[0], 1.)
424+
self.assertAlmostEqual(res_target[1], 4 / 5)
425+
426+
370427
if __name__ == '__main__':
371428
unittest.main()
372429
del TestScoreMetaType

0 commit comments

Comments
 (0)