|
5 | 5 | import logging |
6 | 6 |
|
7 | 7 | import numpy as np |
| 8 | +from scipy.stats import rankdata |
8 | 9 | from sklearn.base import BaseEstimator, MetaEstimatorMixin, clone |
9 | 10 | from sklearn.metrics import check_scoring |
10 | 11 | from sklearn.preprocessing import LabelEncoder |
@@ -680,14 +681,15 @@ def _gl_transform(estimators, X, method, pb): |
680 | 681 |
|
681 | 682 | Returns |
682 | 683 | ------- |
683 | | - Xt : array, shape (n_samples, n_slices) |
684 | | - The transformed values generated by each estimator. |
685 | | - """ |
| 684 | + Xt : array, shape (n_samples, n_estimators, n_slices[, n_classes]) |
| 685 | + Predictions for each (estimator, slice) pair. The trailing axis is |
| 686 | + present when ``method`` returns multi-output (e.g. ``predict_proba``). |
| 687 | + """ # noqa: E501 |
686 | 688 | n_sample, n_iter = X.shape[0], X.shape[-1] |
| 689 | + # stack generalized data for faster prediction |
| 690 | + X_stack = X.transpose(np.r_[0, X.ndim - 1, range(1, X.ndim - 1)]) |
| 691 | + X_stack = X_stack.reshape((n_sample * n_iter,) + X_stack.shape[2:]) |
687 | 692 | for ii, est in enumerate(estimators): |
688 | | - # stack generalized data for faster prediction |
689 | | - X_stack = X.transpose(np.r_[0, X.ndim - 1, range(1, X.ndim - 1)]) |
690 | | - X_stack = X_stack.reshape(np.r_[n_sample * n_iter, X_stack.shape[2:]]) |
691 | 693 | transform = getattr(est, method) |
692 | 694 | _y_pred = transform(X_stack) |
693 | 695 | # unstack generalizations |
@@ -715,6 +717,100 @@ def _gl_init_pred(y_pred, X, n_train): |
715 | 717 | return y_pred |
716 | 718 |
|
717 | 719 |
|
| 720 | +def _resolve_scoring_for_classifier(scoring, estimators): |
| 721 | + """Promote scoring=None to 'accuracy' for default estimator. |
| 722 | +
|
| 723 | + scoring=None goes through sklearn's ``_PassthroughScorer``, which delegates |
| 724 | + to ``estimator.score(X, y)``. For a classifier that inherits |
| 725 | + ``ClassifierMixin.score`` unchanged, that's accuracy — which we can batch. |
| 726 | + We compare ``type(est).score.__qualname__`` rather than ``.__name__`` |
| 727 | + because the bare name is "score" regardless of the defining class. A bare |
| 728 | + method has qualname "ClassifierMixin.score"; any override resolves to |
| 729 | + "<Subclass>.score", which we leave untouched. |
| 730 | + """ |
| 731 | + if len(estimators) and getattr(scoring, "_score_func", None) is None: |
| 732 | + qname = getattr(type(estimators[0]).score, "__qualname__", "") |
| 733 | + if qname == "ClassifierMixin.score": |
| 734 | + scoring = check_scoring(estimators[0], "accuracy") |
| 735 | + return scoring |
| 736 | + |
| 737 | + |
| 738 | +def _detect_response_method(scoring): |
| 739 | + """Return (response_method, can_batch) for ``scoring``. |
| 740 | +
|
| 741 | + If we can batch the estimator (one of predict, predict_proba, |
| 742 | + decision_function, or a tuple of those) then we return that, and |
| 743 | + can_batch is True if additionally _score_func is not None. |
| 744 | + Otherwise we return None (for the response_method) and False |
| 745 | + """ |
| 746 | + score_func = getattr(scoring, "_score_func", None) |
| 747 | + rm = getattr(scoring, "_response_method", None) |
| 748 | + valid = {"predict", "predict_proba", "decision_function"} |
| 749 | + if rm == "default": |
| 750 | + response_method = "predict" |
| 751 | + elif isinstance(rm, str) and rm in valid: |
| 752 | + response_method = rm |
| 753 | + elif isinstance(rm, tuple) and all(m in valid for m in rm): |
| 754 | + response_method = rm |
| 755 | + else: |
| 756 | + response_method = None |
| 757 | + can_batch = score_func is not None and response_method is not None |
| 758 | + return response_method, can_batch |
| 759 | + |
| 760 | + |
| 761 | +def _make_batched_score(score_func, response_method, method, y, sign, kwargs): |
| 762 | + """Return a callable ``y_pred``->score per task, None if not recognised. |
| 763 | +
|
| 764 | + The returned callable expects ``y_pred`` of shape |
| 765 | + ``(n_sample, n_train, n_iter)`` and returns shape ``(n_train, n_iter)``. |
| 766 | + Falls back to None for any scorer with non-default ``kwargs`` or |
| 767 | + multi-target ``y``, both of which require a slice-by-slice loop. |
| 768 | + """ |
| 769 | + if kwargs or y.ndim != 1: |
| 770 | + return None |
| 771 | + name = getattr(score_func, "__name__", "") |
| 772 | + |
| 773 | + if name == "accuracy_score" and response_method == "predict": |
| 774 | + |
| 775 | + def batched_score(y_pred): |
| 776 | + return sign * (y_pred == y[:, None, None]).mean(axis=0) |
| 777 | + |
| 778 | + return batched_score |
| 779 | + |
| 780 | + if name == "balanced_accuracy_score" and response_method == "predict": |
| 781 | + classes = np.unique(y) |
| 782 | + |
| 783 | + def batched_score(y_pred): |
| 784 | + return sign * np.stack( |
| 785 | + [(y_pred[y == c] == c).mean(axis=0) for c in classes] |
| 786 | + ).mean(axis=0) |
| 787 | + |
| 788 | + return batched_score |
| 789 | + |
| 790 | + if name == "roc_auc_score" and method in ("predict_proba", "decision_function"): |
| 791 | + classes = np.unique(y) |
| 792 | + if len(classes) != 2: # multi-class needs ovr/ovo; defer |
| 793 | + return None |
| 794 | + pos = y == classes[1] |
| 795 | + n_pos, n_neg = int(pos.sum()), int((~pos).sum()) |
| 796 | + if not (n_pos and n_neg): # degenerate folds raise downstream in sklearn |
| 797 | + return None |
| 798 | + |
| 799 | + def batched_score(y_pred): |
| 800 | + # Mann-Whitney U identity with average-rank tie correction. |
| 801 | + # Equivalent to sklearn's roc_auc within floating point precision, |
| 802 | + # but different computation. |
| 803 | + ranks = rankdata(y_pred, method="average", axis=0) |
| 804 | + return ( |
| 805 | + sign |
| 806 | + * (ranks[pos].sum(axis=0) - n_pos * (n_pos + 1) / 2.0) |
| 807 | + / (n_pos * n_neg) |
| 808 | + ) |
| 809 | + |
| 810 | + return batched_score |
| 811 | + return None |
| 812 | + |
| 813 | + |
718 | 814 | def _gl_score(estimators, scoring, X, y, pb): |
719 | 815 | """Score GeneralizingEstimator in parallel. |
720 | 816 |
|
@@ -743,17 +839,63 @@ def _gl_score(estimators, scoring, X, y, pb): |
743 | 839 | """ |
744 | 840 | # FIXME: The level parallelization may be a bit high, and might be memory |
745 | 841 | # consuming. Perhaps need to lower it down to the loop across X slices. |
746 | | - score_shape = [len(estimators), X.shape[-1]] |
747 | | - for jj in range(X.shape[-1]): |
748 | | - for ii, est in enumerate(estimators): |
749 | | - _score = scoring(est, X[..., jj], y) |
750 | | - # Initialize array of predictions on the first score iteration |
751 | | - if (ii == 0) and (jj == 0): |
752 | | - dtype = type(_score) |
753 | | - score = np.zeros(score_shape, dtype) |
754 | | - score[ii, jj, ...] = _score |
755 | | - |
756 | | - pb.update(jj * len(estimators) + ii + 1) |
| 842 | + n_iter = X.shape[-1] |
| 843 | + n_train = len(estimators) |
| 844 | + score_shape = [n_train, n_iter] |
| 845 | + |
| 846 | + scoring = _resolve_scoring_for_classifier(scoring, estimators) |
| 847 | + response_method, can_batch = _detect_response_method(scoring) |
| 848 | + |
| 849 | + # If we can't batch, fall back to a simple nested loop for scoring |
| 850 | + if not can_batch: |
| 851 | + for jj in range(n_iter): |
| 852 | + for ii, est in enumerate(estimators): |
| 853 | + _score = scoring(est, X[..., jj], y) |
| 854 | + if (ii == 0) and (jj == 0): |
| 855 | + score = np.zeros(score_shape, type(_score)) |
| 856 | + score[ii, jj, ...] = _score |
| 857 | + pb.update(jj * n_train + ii + 1) |
| 858 | + return score |
| 859 | + |
| 860 | + # Resolve a single method name for _gl_transform: pick the first available |
| 861 | + # if response_method is a tuple (e.g. roc_auc). |
| 862 | + if isinstance(response_method, str): |
| 863 | + method = response_method |
| 864 | + else: |
| 865 | + for m in response_method: |
| 866 | + if hasattr(estimators[0], m): |
| 867 | + method = m |
| 868 | + break |
| 869 | + |
| 870 | + # Batch all predictions through _gl_transform. y_pred shape: |
| 871 | + # (n_sample, n_train, n_iter) or (n_sample, n_train, n_iter, n_classes). |
| 872 | + y_pred = _gl_transform(estimators, X, method, pb) |
| 873 | + |
| 874 | + # Binary predict_proba: take the positive-class column to match sklearn |
| 875 | + # scorer expectations for binary problems. |
| 876 | + if method == "predict_proba" and y_pred.ndim == 4 and y_pred.shape[-1] == 2: |
| 877 | + y_pred = y_pred[..., 1] |
| 878 | + |
| 879 | + # `scoring._kwargs or {}` also guards score_func(..., **kwargs) against |
| 880 | + # scoring._kwargs being None. |
| 881 | + score_func = scoring._score_func |
| 882 | + sign = scoring._sign |
| 883 | + kwargs = scoring._kwargs or {} |
| 884 | + batched_score = _make_batched_score( |
| 885 | + score_func, response_method, method, y, sign, kwargs |
| 886 | + ) |
| 887 | + |
| 888 | + # Reduce predictions to scores. Vectorised if we recognised the scorer, |
| 889 | + # otherwise nested loops over (estimator, slice). |
| 890 | + if batched_score is not None: |
| 891 | + score = batched_score(y_pred) |
| 892 | + else: |
| 893 | + for ii in range(n_train): |
| 894 | + for jj in range(n_iter): |
| 895 | + _score = sign * score_func(y, y_pred[:, ii, jj], **kwargs) |
| 896 | + if (ii == 0) and (jj == 0): |
| 897 | + score = np.zeros(score_shape, type(_score)) |
| 898 | + score[ii, jj, ...] = _score |
757 | 899 | return score |
758 | 900 |
|
759 | 901 |
|
|
0 commit comments