pypu-learn 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pypu/__init__.py +24 -0
- pypu/_version.py +24 -0
- pypu/base.py +184 -0
- pypu/models/__init__.py +42 -0
- pypu/models/naive.py +89 -0
- pypu/utils/__init__.py +6 -0
- pypu/utils/_show_versions.py +27 -0
- pypu/utils/validation.py +120 -0
- pypu_learn-0.0.1.dist-info/METADATA +137 -0
- pypu_learn-0.0.1.dist-info/RECORD +12 -0
- pypu_learn-0.0.1.dist-info/WHEEL +4 -0
- pypu_learn-0.0.1.dist-info/licenses/LICENSE +29 -0
pypu/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""PyPU: positive-unlabeled (PU) learning in Python.
|
|
2
|
+
|
|
3
|
+
PU learning trains a binary classifier from positive examples and unlabeled examples
|
|
4
|
+
(a mix of positives and negatives), without any example labeled negative. PyPU collects
|
|
5
|
+
PU learning methods behind one scikit-learn style interface: every model is fitted with
|
|
6
|
+
``fit(X, s)``, where ``s[i] == 1`` marks a labeled positive and ``s[i] == 0`` an unlabeled
|
|
7
|
+
sample.
|
|
8
|
+
|
|
9
|
+
Subpackages
|
|
10
|
+
-----------
|
|
11
|
+
models
|
|
12
|
+
The PU classifiers, e.g. ``from pypu.models import NaivePU``.
|
|
13
|
+
utils
|
|
14
|
+
Input validation and helpers shared by the models.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from pypu.utils import show_versions
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from pypu._version import __version__
|
|
21
|
+
except ImportError: # pragma: no cover - only when running from a source tree without installing
|
|
22
|
+
__version__ = "0.0.0+unknown"
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__", "show_versions"]
|
pypu/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pypu/base.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Base class shared by every PU classifier in PyPU."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABCMeta, abstractmethod
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.special import logit
|
|
9
|
+
from sklearn.base import BaseEstimator, ClassifierMixin
|
|
10
|
+
from sklearn.utils.validation import check_consistent_length, check_is_fitted, validate_data
|
|
11
|
+
|
|
12
|
+
from pypu.utils.validation import check_pu_labels
|
|
13
|
+
|
|
14
|
+
# Probabilities are clipped to [_EPS, 1 - _EPS] before taking log-odds.
|
|
15
|
+
_EPS = 1e-12
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BasePUClassifier(ClassifierMixin, BaseEstimator, metaclass=ABCMeta):
|
|
19
|
+
"""Base class for positive-unlabeled (PU) classifiers.
|
|
20
|
+
|
|
21
|
+
Every PU classifier in PyPU follows the scikit-learn estimator API, so it works with
|
|
22
|
+
:func:`~sklearn.base.clone`, :class:`~sklearn.pipeline.Pipeline` and the model selection
|
|
23
|
+
tools:
|
|
24
|
+
|
|
25
|
+
- ``fit(X, s)`` learns from features ``X`` and PU labels ``s``, where ``s[i] == 1``
|
|
26
|
+
marks a labeled positive and ``s[i] == 0`` an unlabeled sample (positive or negative).
|
|
27
|
+
- ``predict_proba(X)`` returns an array of shape ``(n_samples, 2)`` whose second column
|
|
28
|
+
is the estimated probability of the positive class.
|
|
29
|
+
- ``decision_function(X)`` returns one score per sample: higher means more likely
|
|
30
|
+
positive. By default it is the log-odds of the positive-class probability, so ``0``
|
|
31
|
+
corresponds to a probability of ``0.5``.
|
|
32
|
+
- ``predict(X)`` returns hard labels in ``{0, 1}``: a sample is predicted positive when
|
|
33
|
+
its positive-class probability is at least :attr:`threshold_`.
|
|
34
|
+
|
|
35
|
+
``score(X, y)`` (from :class:`~sklearn.base.ClassifierMixin`) computes the accuracy
|
|
36
|
+
against *true* labels ``y``, not against PU labels.
|
|
37
|
+
|
|
38
|
+
Subclasses implement :meth:`_fit` and :meth:`_predict_proba`, and may override
|
|
39
|
+
:meth:`_decision_function` (to return raw scores) and :meth:`_compute_threshold` (to
|
|
40
|
+
change the decision threshold). Input validation and the fitted attributes listed below
|
|
41
|
+
are handled by this class.
|
|
42
|
+
|
|
43
|
+
Attributes
|
|
44
|
+
----------
|
|
45
|
+
classes_ : ndarray of shape (2,)
|
|
46
|
+
The class labels, always ``[0, 1]``.
|
|
47
|
+
n_features_in_ : int
|
|
48
|
+
Number of features seen during :meth:`fit`.
|
|
49
|
+
feature_names_in_ : ndarray of shape (n_features_in_,)
|
|
50
|
+
Names of features seen during :meth:`fit`. Defined only when ``X`` has feature
|
|
51
|
+
names that are all strings, e.g. a pandas DataFrame.
|
|
52
|
+
threshold_ : float
|
|
53
|
+
Positive-class probability threshold used by :meth:`predict`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def fit(self, X, s):
|
|
57
|
+
"""Fit the model to PU data.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
X : array-like of shape (n_samples, n_features)
|
|
62
|
+
Training samples.
|
|
63
|
+
s : array-like of shape (n_samples,)
|
|
64
|
+
PU labels: ``1`` for a labeled positive, ``0`` for an unlabeled sample.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
self : object
|
|
69
|
+
The fitted estimator.
|
|
70
|
+
"""
|
|
71
|
+
X = self._validate_X(X, reset=True)
|
|
72
|
+
s = check_pu_labels(s)
|
|
73
|
+
check_consistent_length(X, s)
|
|
74
|
+
self._fit(X, s)
|
|
75
|
+
self.classes_ = np.array([0, 1])
|
|
76
|
+
self.threshold_ = float(self._compute_threshold(X, s))
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def predict_proba(self, X):
|
|
80
|
+
"""Estimate class probabilities.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
X : array-like of shape (n_samples, n_features)
|
|
85
|
+
Samples.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
proba : ndarray of shape (n_samples, 2)
|
|
90
|
+
Probability of the negative class (column 0) and of the positive class (column 1).
|
|
91
|
+
"""
|
|
92
|
+
check_is_fitted(self)
|
|
93
|
+
X = self._validate_X(X, reset=False)
|
|
94
|
+
proba = np.asarray(self._predict_proba(X), dtype=np.float64).reshape(-1)
|
|
95
|
+
return np.column_stack([1.0 - proba, proba])
|
|
96
|
+
|
|
97
|
+
def decision_function(self, X):
|
|
98
|
+
"""Score samples; higher means more likely positive.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
X : array-like of shape (n_samples, n_features)
|
|
103
|
+
Samples.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
scores : ndarray of shape (n_samples,)
|
|
108
|
+
One score per sample.
|
|
109
|
+
"""
|
|
110
|
+
check_is_fitted(self)
|
|
111
|
+
X = self._validate_X(X, reset=False)
|
|
112
|
+
return np.asarray(self._decision_function(X), dtype=np.float64).reshape(-1)
|
|
113
|
+
|
|
114
|
+
def predict(self, X):
|
|
115
|
+
"""Predict class labels.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
X : array-like of shape (n_samples, n_features)
|
|
120
|
+
Samples.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
y_pred : ndarray of shape (n_samples,)
|
|
125
|
+
``1`` for samples whose positive-class probability is at least
|
|
126
|
+
:attr:`threshold_`, ``0`` otherwise.
|
|
127
|
+
"""
|
|
128
|
+
proba = self.predict_proba(X)[:, 1]
|
|
129
|
+
return self.classes_[(proba >= self.threshold_).astype(np.int64)]
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------ hooks for subclasses
|
|
132
|
+
|
|
133
|
+
@abstractmethod
|
|
134
|
+
def _fit(self, X, s):
|
|
135
|
+
"""Fit the model.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
X : ndarray of shape (n_samples, n_features)
|
|
140
|
+
Validated training samples (float64 or float32).
|
|
141
|
+
s : ndarray of shape (n_samples,)
|
|
142
|
+
Validated PU labels (int64, 1 = labeled positive, 0 = unlabeled).
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
@abstractmethod
|
|
146
|
+
def _predict_proba(self, X):
|
|
147
|
+
"""Return the positive-class probability of each sample.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
X : ndarray of shape (n_samples, n_features)
|
|
152
|
+
Validated samples.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
proba : ndarray of shape (n_samples,)
|
|
157
|
+
Positive-class probabilities, in ``[0, 1]``.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def _decision_function(self, X):
|
|
161
|
+
"""Return a score per sample; by default the log-odds of the positive class."""
|
|
162
|
+
proba = np.clip(self._predict_proba(X), _EPS, 1.0 - _EPS)
|
|
163
|
+
return logit(proba)
|
|
164
|
+
|
|
165
|
+
def _compute_threshold(self, X, s):
|
|
166
|
+
"""Return the probability threshold used by :meth:`predict`; ``0.5`` by default.
|
|
167
|
+
|
|
168
|
+
Called at the end of :meth:`fit`, after :meth:`_fit`, with the validated training data.
|
|
169
|
+
"""
|
|
170
|
+
return 0.5
|
|
171
|
+
|
|
172
|
+
def _validate_X(self, X, *, reset):
|
|
173
|
+
"""Validate ``X``; ``reset=True`` in ``fit``, ``False`` when predicting."""
|
|
174
|
+
return validate_data(self, X, reset=reset, dtype=[np.float64, np.float32])
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ scikit-learn protocol
|
|
177
|
+
|
|
178
|
+
def __sklearn_is_fitted__(self):
|
|
179
|
+
return hasattr(self, "threshold_")
|
|
180
|
+
|
|
181
|
+
def __sklearn_tags__(self):
|
|
182
|
+
tags = super().__sklearn_tags__()
|
|
183
|
+
tags.classifier_tags.multi_class = False
|
|
184
|
+
return tags
|
pypu/models/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""PU learning models.
|
|
2
|
+
|
|
3
|
+
Every model follows the interface of :class:`pypu.base.BasePUClassifier`::
|
|
4
|
+
|
|
5
|
+
from pypu.models import NaivePU
|
|
6
|
+
|
|
7
|
+
clf = NaivePU().fit(X_train, s_train) # s: 1 = labeled positive, 0 = unlabeled
|
|
8
|
+
proba = clf.predict_proba(X_test)[:, 1]
|
|
9
|
+
|
|
10
|
+
Models are imported lazily: importing :mod:`pypu.models` does not import the optional
|
|
11
|
+
dependencies (such as PyTorch) of models that are not used.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from importlib import import_module
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
# Public model name -> module that defines it. Register every new model here, in __all__ and
|
|
20
|
+
# in the TYPE_CHECKING block below (tests check that the three agree).
|
|
21
|
+
_MODELS: dict[str, str] = {
|
|
22
|
+
"NaivePU": "pypu.models.naive",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
__all__ = ["NaivePU"]
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pypu.models.naive import NaivePU
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str):
|
|
32
|
+
try:
|
|
33
|
+
module_name = _MODELS[name]
|
|
34
|
+
except KeyError:
|
|
35
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
36
|
+
model = getattr(import_module(module_name), name)
|
|
37
|
+
globals()[name] = model # later lookups skip __getattr__
|
|
38
|
+
return model
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __dir__():
|
|
42
|
+
return sorted(set(globals()) | set(_MODELS))
|
pypu/models/naive.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Naive baseline that treats every unlabeled sample as negative."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.base import clone
|
|
7
|
+
from sklearn.linear_model import LogisticRegression
|
|
8
|
+
|
|
9
|
+
from pypu.base import BasePUClassifier
|
|
10
|
+
from pypu.utils.validation import check_prior, threshold_from_prior
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NaivePU(BasePUClassifier):
|
|
14
|
+
r"""Naive PU baseline: a classifier trained with unlabeled samples as negatives.
|
|
15
|
+
|
|
16
|
+
The wrapped classifier learns to separate labeled positives (``s == 1``) from unlabeled
|
|
17
|
+
samples (``s == 0``), so it estimates :math:`P(s = 1 \mid x)` rather than
|
|
18
|
+
:math:`P(y = 1 \mid x)`. Under the SCAR assumption (labeled positives are selected
|
|
19
|
+
completely at random) the two differ by the constant factor
|
|
20
|
+
:math:`c = P(s = 1 \mid y = 1)` [1]_: the ranking of samples, and therefore ROC AUC, is
|
|
21
|
+
preserved, but the probabilities are too low and a ``0.5`` threshold under-predicts
|
|
22
|
+
positives. Passing the class ``prior`` sets the threshold so that a ``prior`` fraction of
|
|
23
|
+
the training samples is predicted positive.
|
|
24
|
+
|
|
25
|
+
It is the usual lower bound when comparing PU learning methods.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
estimator : classifier, default=None
|
|
30
|
+
A scikit-learn classifier implementing ``predict_proba``. It is cloned before
|
|
31
|
+
fitting. ``None`` uses :class:`~sklearn.linear_model.LogisticRegression`.
|
|
32
|
+
prior : float, default=None
|
|
33
|
+
Class prior :math:`\pi = P(y = 1)`. If given, :attr:`threshold_` is set so that a
|
|
34
|
+
``prior`` fraction of the training samples is predicted positive. Otherwise the
|
|
35
|
+
threshold is ``0.5``.
|
|
36
|
+
|
|
37
|
+
Attributes
|
|
38
|
+
----------
|
|
39
|
+
estimator_ : classifier
|
|
40
|
+
The fitted classifier.
|
|
41
|
+
classes_ : ndarray of shape (2,)
|
|
42
|
+
The class labels, always ``[0, 1]``.
|
|
43
|
+
n_features_in_ : int
|
|
44
|
+
Number of features seen during ``fit``.
|
|
45
|
+
feature_names_in_ : ndarray of shape (n_features_in_,)
|
|
46
|
+
Names of features seen during ``fit``, when ``X`` has string feature names.
|
|
47
|
+
threshold_ : float
|
|
48
|
+
Positive-class probability threshold used by ``predict``.
|
|
49
|
+
|
|
50
|
+
References
|
|
51
|
+
----------
|
|
52
|
+
.. [1] C. Elkan and K. Noto. "Learning classifiers from only positive and unlabeled
|
|
53
|
+
data." KDD 2008.
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
>>> import numpy as np
|
|
58
|
+
>>> from sklearn.datasets import make_classification
|
|
59
|
+
>>> from pypu.models import NaivePU
|
|
60
|
+
>>> X, y = make_classification(n_samples=500, weights=[0.7], random_state=0)
|
|
61
|
+
>>> rng = np.random.default_rng(0)
|
|
62
|
+
>>> s = ((y == 1) & (rng.random(len(y)) < 0.5)).astype(int) # label half the positives
|
|
63
|
+
>>> clf = NaivePU(prior=y.mean()).fit(X, s)
|
|
64
|
+
>>> clf.predict_proba(X).shape
|
|
65
|
+
(500, 2)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, estimator=None, prior=None):
|
|
69
|
+
self.estimator = estimator
|
|
70
|
+
self.prior = prior
|
|
71
|
+
|
|
72
|
+
def _fit(self, X, s):
|
|
73
|
+
if self.prior is not None:
|
|
74
|
+
check_prior(self.prior, s)
|
|
75
|
+
estimator = LogisticRegression() if self.estimator is None else clone(self.estimator)
|
|
76
|
+
if not hasattr(estimator, "predict_proba"):
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"estimator must implement predict_proba; {type(estimator).__name__} does not."
|
|
79
|
+
)
|
|
80
|
+
self.estimator_ = estimator.fit(X, s)
|
|
81
|
+
|
|
82
|
+
def _predict_proba(self, X):
|
|
83
|
+
positive_column = np.flatnonzero(self.estimator_.classes_ == 1)[0]
|
|
84
|
+
return self.estimator_.predict_proba(X)[:, positive_column]
|
|
85
|
+
|
|
86
|
+
def _compute_threshold(self, X, s):
|
|
87
|
+
if self.prior is None:
|
|
88
|
+
return 0.5
|
|
89
|
+
return threshold_from_prior(self._predict_proba(X), self.prior)
|
pypu/utils/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Utilities shared by the PyPU models."""
|
|
2
|
+
|
|
3
|
+
from pypu.utils._show_versions import show_versions
|
|
4
|
+
from pypu.utils.validation import check_prior, check_pu_labels, threshold_from_prior
|
|
5
|
+
|
|
6
|
+
__all__ = ["check_prior", "check_pu_labels", "show_versions", "threshold_from_prior"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Print the versions of PyPU and its dependencies, for bug reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
# Distributions reported by show_versions: core dependencies first, then optional ones.
|
|
10
|
+
_DEPENDENCIES = ("numpy", "scipy", "scikit-learn", "lightgbm", "xgboost", "torch", "pyod")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def show_versions() -> None:
|
|
14
|
+
"""Print the versions of PyPU, Python and the main dependencies.
|
|
15
|
+
|
|
16
|
+
Include this output when reporting a bug.
|
|
17
|
+
"""
|
|
18
|
+
import pypu
|
|
19
|
+
|
|
20
|
+
print(f"pypu: {pypu.__version__}")
|
|
21
|
+
print(f"python: {sys.version.split()[0]} ({platform.platform()})")
|
|
22
|
+
for dist in _DEPENDENCIES:
|
|
23
|
+
try:
|
|
24
|
+
dist_version = version(dist)
|
|
25
|
+
except PackageNotFoundError:
|
|
26
|
+
dist_version = "not installed"
|
|
27
|
+
print(f"{dist}: {dist_version}")
|
pypu/utils/validation.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Validation of PU labels and class priors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numbers
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from sklearn.utils.multiclass import type_of_target
|
|
10
|
+
from sklearn.utils.validation import column_or_1d
|
|
11
|
+
|
|
12
|
+
_PU_LABELS_HINT = "PU labels must be 1 (labeled positive) or 0 (unlabeled)"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_pu_labels(s) -> np.ndarray:
|
|
16
|
+
"""Validate PU labels.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
s : array-like of shape (n_samples,)
|
|
21
|
+
PU labels: ``1`` for a labeled positive, ``0`` for an unlabeled sample.
|
|
22
|
+
Booleans are accepted (``True`` means labeled positive).
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
s : ndarray of shape (n_samples,), dtype int64
|
|
27
|
+
The validated labels.
|
|
28
|
+
|
|
29
|
+
Raises
|
|
30
|
+
------
|
|
31
|
+
ValueError
|
|
32
|
+
If the labels are not 0/1, or if there is no labeled positive or no unlabeled sample.
|
|
33
|
+
"""
|
|
34
|
+
s = column_or_1d(s, warn=True)
|
|
35
|
+
if s.size == 0:
|
|
36
|
+
raise ValueError("PU labels are empty.")
|
|
37
|
+
# Same error wording as scikit-learn for non-binary targets.
|
|
38
|
+
target_type = type_of_target(s, input_name="s", raise_unknown=True)
|
|
39
|
+
if target_type == "continuous":
|
|
40
|
+
raise ValueError(f"Unknown label type: continuous. {_PU_LABELS_HINT}.")
|
|
41
|
+
|
|
42
|
+
values = np.unique(s)
|
|
43
|
+
if target_type == "multiclass":
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Only binary classification is supported. {_PU_LABELS_HINT}, got values "
|
|
46
|
+
f"{values[:10].tolist()}."
|
|
47
|
+
)
|
|
48
|
+
if s.dtype.kind not in "biuf" or not np.isin(values, (0, 1)).all():
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"{_PU_LABELS_HINT}, got values {values[:10].tolist()}. If unlabeled samples are "
|
|
51
|
+
"encoded as -1, map them to 0."
|
|
52
|
+
)
|
|
53
|
+
if values.size < 2:
|
|
54
|
+
missing = "labeled positive (s == 1)" if values[0] == 0 else "unlabeled sample (s == 0)"
|
|
55
|
+
raise ValueError(f"PU labels contain only one class: there is no {missing}.")
|
|
56
|
+
return s.astype(np.int64)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_prior(prior, s=None) -> float:
|
|
60
|
+
r"""Validate a class prior :math:`\pi = P(y = 1)`.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
prior : float
|
|
65
|
+
The class prior, in the open interval ``(0, 1)``.
|
|
66
|
+
s : array-like of shape (n_samples,), default=None
|
|
67
|
+
Validated PU labels. When given, a warning is raised if ``prior`` is lower than the
|
|
68
|
+
fraction of labeled positives, which is impossible when the labeled positives are
|
|
69
|
+
drawn from the same sample as the unlabeled data.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
prior : float
|
|
74
|
+
The validated prior.
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
TypeError
|
|
79
|
+
If ``prior`` is not a real number.
|
|
80
|
+
ValueError
|
|
81
|
+
If ``prior`` is not in ``(0, 1)``.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(prior, bool) or not isinstance(prior, numbers.Real):
|
|
84
|
+
raise TypeError(f"prior must be a float in (0, 1), got {prior!r}.")
|
|
85
|
+
prior = float(prior)
|
|
86
|
+
if not 0.0 < prior < 1.0:
|
|
87
|
+
raise ValueError(f"prior must be in the open interval (0, 1), got {prior}.")
|
|
88
|
+
if s is not None:
|
|
89
|
+
labeled_fraction = float(np.mean(s))
|
|
90
|
+
if prior < labeled_fraction:
|
|
91
|
+
warnings.warn(
|
|
92
|
+
f"prior={prior} is lower than the fraction of labeled positives "
|
|
93
|
+
f"({labeled_fraction:.4f}). When the labeled positives come from the same sample "
|
|
94
|
+
"as the unlabeled data the prior cannot be lower than that fraction: check that "
|
|
95
|
+
"you passed the class prior P(y=1) and not the label frequency P(s=1 | y=1).",
|
|
96
|
+
UserWarning,
|
|
97
|
+
stacklevel=2,
|
|
98
|
+
)
|
|
99
|
+
return prior
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def threshold_from_prior(scores, prior: float) -> float:
|
|
103
|
+
"""Return the threshold that labels the top ``prior`` fraction of ``scores`` positive.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
scores : array-like of shape (n_samples,)
|
|
108
|
+
Scores where higher means more likely positive (e.g. positive-class probabilities).
|
|
109
|
+
prior : float
|
|
110
|
+
Fraction of samples to label positive, in ``(0, 1)``.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
threshold : float
|
|
115
|
+
The ``1 - prior`` quantile of ``scores``; samples with ``score >= threshold``
|
|
116
|
+
are labeled positive.
|
|
117
|
+
"""
|
|
118
|
+
prior = check_prior(prior)
|
|
119
|
+
scores = np.asarray(scores, dtype=np.float64).reshape(-1)
|
|
120
|
+
return float(np.quantile(scores, 1.0 - prior))
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pypu-learn
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Positive-unlabeled (PU) learning in Python, with a scikit-learn style API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/izavits/PyPU
|
|
6
|
+
Project-URL: Repository, https://github.com/izavits/PyPU
|
|
7
|
+
Project-URL: Issues, https://github.com/izavits/PyPU/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/izavits/PyPU/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Ilias Zavitsanos
|
|
10
|
+
License-Expression: BSD-3-Clause
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: machine-learning,positive-unlabeled,pu-learning,scikit-learn,semi-supervised,weak-supervision
|
|
13
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: numpy>=1.26
|
|
26
|
+
Requires-Dist: scikit-learn>=1.6
|
|
27
|
+
Requires-Dist: scipy>=1.11
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# PyPU
|
|
31
|
+
|
|
32
|
+
**Positive-unlabeled (PU) learning in Python, with a scikit-learn style API.**
|
|
33
|
+
|
|
34
|
+
[](https://github.com/izavits/PyPU/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/pypu-learn/)
|
|
36
|
+
[](https://pypi.org/project/pypu-learn/)
|
|
37
|
+
[](https://github.com/izavits/PyPU/blob/main/LICENSE)
|
|
38
|
+
|
|
39
|
+
> **Status: pre-alpha.** The common interface is in place and the methods are being ported.
|
|
40
|
+
> See the [roadmap](https://github.com/izavits/PyPU/blob/main/ROADMAP.md).
|
|
41
|
+
|
|
42
|
+
In PU learning the training data hold some examples labeled positive and many unlabeled
|
|
43
|
+
examples, which are a mix of positives and negatives. No example is labeled negative. This
|
|
44
|
+
happens whenever only one class gets recorded: confirmed frauds, known disease genes, reported
|
|
45
|
+
misstatements, clicked items.
|
|
46
|
+
|
|
47
|
+
PyPU collects PU learning methods behind one interface, in the spirit of
|
|
48
|
+
[PyOD](https://github.com/yzhao062/pyod) for outlier detection: import a model, `fit` it on
|
|
49
|
+
PU data, and predict on new data.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install pypu-learn
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The package is imported as `pypu` and needs Python 3.11 or newer. (The PyPI name `pypu`
|
|
58
|
+
belongs to an unrelated project, hence `pypu-learn`.)
|
|
59
|
+
|
|
60
|
+
Until the first release is on PyPI, install from GitHub:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install git+https://github.com/izavits/PyPU.git
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import numpy as np
|
|
70
|
+
from sklearn.datasets import make_classification
|
|
71
|
+
from sklearn.metrics import roc_auc_score
|
|
72
|
+
from sklearn.model_selection import train_test_split
|
|
73
|
+
|
|
74
|
+
from pypu.models import NaivePU
|
|
75
|
+
|
|
76
|
+
# A fully labeled dataset...
|
|
77
|
+
X, y = make_classification(n_samples=2000, weights=[0.8], random_state=0)
|
|
78
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=0)
|
|
79
|
+
|
|
80
|
+
# ...turned into PU data: label half of the training positives, leave the rest unlabeled.
|
|
81
|
+
rng = np.random.default_rng(0)
|
|
82
|
+
s_train = ((y_train == 1) & (rng.random(len(y_train)) < 0.5)).astype(int)
|
|
83
|
+
|
|
84
|
+
clf = NaivePU(prior=y_train.mean())
|
|
85
|
+
clf.fit(X_train, s_train) # s = 1: labeled positive, s = 0: unlabeled
|
|
86
|
+
|
|
87
|
+
scores = clf.predict_proba(X_test)[:, 1]
|
|
88
|
+
print("ROC AUC:", roc_auc_score(y_test, scores))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Data utilities that turn labeled datasets into PU data in one call are coming in 0.1.0.
|
|
92
|
+
|
|
93
|
+
## The interface
|
|
94
|
+
|
|
95
|
+
Every model derives from `pypu.base.BasePUClassifier` and is a scikit-learn estimator, so it
|
|
96
|
+
works with `clone`, `Pipeline` and `GridSearchCV`.
|
|
97
|
+
|
|
98
|
+
| Method | Returns |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `fit(X, s)` | The fitted model. `s[i] = 1` marks a labeled positive, `s[i] = 0` an unlabeled sample. |
|
|
101
|
+
| `predict_proba(X)` | An `(n_samples, 2)` array; column 1 is the positive-class probability. |
|
|
102
|
+
| `decision_function(X)` | One score per sample; higher means more likely positive. |
|
|
103
|
+
| `predict(X)` | Labels in `{0, 1}`, thresholding the positive-class probability at `threshold_`. |
|
|
104
|
+
|
|
105
|
+
Models that need the class prior π = P(y = 1) take it as a `prior` parameter.
|
|
106
|
+
|
|
107
|
+
## Methods
|
|
108
|
+
|
|
109
|
+
| Model | Description | Status |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `NaivePU` | Treats unlabeled samples as negatives; the usual lower bound | available |
|
|
112
|
+
| `IFPU` | Focal-loss PU risk with gradient boosting | planned (0.1.0) |
|
|
113
|
+
| `IFPNU` | Anomaly detector picks reliable negatives, then a focal PNU risk with gradient boosting | planned (0.1.0) |
|
|
114
|
+
| `SAREM` | EM for labels selected at random (SAR) | planned (0.1.0) |
|
|
115
|
+
| `ElkanNoto`, `PUBagging` | Classic PU baselines | planned (0.1.0) |
|
|
116
|
+
| `PUHellingerTree`, `PUHellingerForest` | Hellinger-distance decision trees and forests for PU data | planned (0.2.0) |
|
|
117
|
+
| `NNPU`, `LBE`, `PUe`, `ALMPU` | Neural PU methods (optional PyTorch dependency) | planned (0.3.0) |
|
|
118
|
+
|
|
119
|
+
Also planned: creating PU data from labeled datasets (uniform SCAR selection, KNN-biased and
|
|
120
|
+
other SAR selection mechanisms), class-prior estimation, and PU-specific metrics.
|
|
121
|
+
|
|
122
|
+
## Contributing
|
|
123
|
+
|
|
124
|
+
Contributions are welcome. See
|
|
125
|
+
[CONTRIBUTING.md](https://github.com/izavits/PyPU/blob/main/CONTRIBUTING.md) for the
|
|
126
|
+
development setup, how to add a model, and the release process. Changes are listed in
|
|
127
|
+
[CHANGELOG.md](https://github.com/izavits/PyPU/blob/main/CHANGELOG.md).
|
|
128
|
+
|
|
129
|
+
## Citing
|
|
130
|
+
|
|
131
|
+
If you use PyPU in your research, please cite it; GitHub's "Cite this repository" button uses
|
|
132
|
+
[CITATION.cff](https://github.com/izavits/PyPU/blob/main/CITATION.cff).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
PyPU is distributed under the
|
|
137
|
+
[BSD 3-Clause License](https://github.com/izavits/PyPU/blob/main/LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pypu/__init__.py,sha256=jQpD4zxbfx6oNWpMpQwf_WBPLBkBLb9kMke1X33xGUA,839
|
|
2
|
+
pypu/_version.py,sha256=8OsTLsIVB9D0HdPTmt5rVwyVUBe9xTVGkRslXicxzkM,520
|
|
3
|
+
pypu/base.py,sha256=7m-uVcrY1MybqpmF_B55h64b-iZOiCZKHN_nS-mm8nk,6581
|
|
4
|
+
pypu/models/__init__.py,sha256=5BIn2dKwYsEXtaqfCJAjMCustsN29-iShQSZZyAnNsA,1221
|
|
5
|
+
pypu/models/naive.py,sha256=pckviwY6gzcGq_9vd5Yav68I63jhBX6_sVPr83aEvVQ,3607
|
|
6
|
+
pypu/utils/__init__.py,sha256=k89kme_ki-1m_74aef-xGNeEDBVWY-l7VSIG1WIVOmQ,268
|
|
7
|
+
pypu/utils/_show_versions.py,sha256=F2lLrp9oDNFw_ys7_yXy8S1TirSLc_Wc_gvY9Kx7mdk,865
|
|
8
|
+
pypu/utils/validation.py,sha256=k6Fb31s4Sqez2dJNg8Csy1cEowrGNkxNssekPr9cOL8,4225
|
|
9
|
+
pypu_learn-0.0.1.dist-info/METADATA,sha256=K4wm80yqIKkqz79FEQ9ZY8mZnGgjUKvOBdeNLkS8l6c,5897
|
|
10
|
+
pypu_learn-0.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
pypu_learn-0.0.1.dist-info/licenses/LICENSE,sha256=5QchMJ0UqJ3ORojl0D3c_skp3PlswPioxsTF3pEjV6g,1524
|
|
12
|
+
pypu_learn-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Ilias Zavitsanos
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|