pu-toolbox 1.0.0__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.
Files changed (77) hide show
  1. pu_toolbox/__init__.py +56 -0
  2. pu_toolbox/advisor/__init__.py +17 -0
  3. pu_toolbox/advisor/_types.py +130 -0
  4. pu_toolbox/advisor/recommender.py +195 -0
  5. pu_toolbox/advisor/rules.py +349 -0
  6. pu_toolbox/cli/__init__.py +52 -0
  7. pu_toolbox/cli/demo.py +62 -0
  8. pu_toolbox/cli/info.py +89 -0
  9. pu_toolbox/cli/run.py +198 -0
  10. pu_toolbox/core/__init__.py +46 -0
  11. pu_toolbox/core/base.py +320 -0
  12. pu_toolbox/core/config.py +19 -0
  13. pu_toolbox/core/exceptions.py +32 -0
  14. pu_toolbox/core/labels.py +185 -0
  15. pu_toolbox/core/random.py +44 -0
  16. pu_toolbox/core/tags.py +118 -0
  17. pu_toolbox/core/validation.py +327 -0
  18. pu_toolbox/diagnostics/__init__.py +29 -0
  19. pu_toolbox/diagnostics/report.py +522 -0
  20. pu_toolbox/diagnostics/sensitivity.py +340 -0
  21. pu_toolbox/estimators/__init__.py +0 -0
  22. pu_toolbox/estimators/bias_aware/__init__.py +5 -0
  23. pu_toolbox/estimators/bias_aware/lbe.py +117 -0
  24. pu_toolbox/estimators/bias_aware/pusb.py +75 -0
  25. pu_toolbox/estimators/bias_aware/pusb_kernel.py +295 -0
  26. pu_toolbox/estimators/classic/__init__.py +4 -0
  27. pu_toolbox/estimators/classic/elkan_noto.py +450 -0
  28. pu_toolbox/estimators/classic/llsvm.py +335 -0
  29. pu_toolbox/estimators/deep/__init__.py +17 -0
  30. pu_toolbox/estimators/deep/dgpu.py +417 -0
  31. pu_toolbox/estimators/deep/infomax_pu.py +483 -0
  32. pu_toolbox/estimators/deep/self_pu.py +837 -0
  33. pu_toolbox/estimators/deep/vision.py +209 -0
  34. pu_toolbox/estimators/deep/weighted_contrastive_pu.py +443 -0
  35. pu_toolbox/estimators/risk/__init__.py +17 -0
  36. pu_toolbox/estimators/risk/_class_prior.py +41 -0
  37. pu_toolbox/estimators/risk/dist_pu.py +147 -0
  38. pu_toolbox/estimators/risk/kldce.py +1093 -0
  39. pu_toolbox/estimators/risk/ldce.py +563 -0
  40. pu_toolbox/estimators/risk/nnpu.py +552 -0
  41. pu_toolbox/estimators/risk/pnu.py +320 -0
  42. pu_toolbox/estimators/risk/upu.py +494 -0
  43. pu_toolbox/losses/__init__.py +19 -0
  44. pu_toolbox/losses/llsvm.py +127 -0
  45. pu_toolbox/losses/nnpu.py +252 -0
  46. pu_toolbox/losses/pnu.py +102 -0
  47. pu_toolbox/losses/upu.py +150 -0
  48. pu_toolbox/metrics/__init__.py +19 -0
  49. pu_toolbox/metrics/classification.py +219 -0
  50. pu_toolbox/model_selection/__init__.py +6 -0
  51. pu_toolbox/model_selection/split.py +123 -0
  52. pu_toolbox/preprocessing/__init__.py +85 -0
  53. pu_toolbox/preprocessing/data_profiler.py +481 -0
  54. pu_toolbox/preprocessing/profiling.py +351 -0
  55. pu_toolbox/preprocessing/pu_labeling.py +410 -0
  56. pu_toolbox/preprocessing/selection_bias.py +326 -0
  57. pu_toolbox/prior/__init__.py +9 -0
  58. pu_toolbox/prior/kernel_mean.py +263 -0
  59. pu_toolbox/prior/pen_l1.py +87 -0
  60. pu_toolbox/prior/recpe.py +171 -0
  61. pu_toolbox/registry/__init__.py +47 -0
  62. pu_toolbox/registry/builtin_methods.py +458 -0
  63. pu_toolbox/registry/metadata.py +117 -0
  64. pu_toolbox/registry/registry.py +303 -0
  65. pu_toolbox/utils/__init__.py +25 -0
  66. pu_toolbox/utils/activations.py +16 -0
  67. pu_toolbox/utils/basis.py +163 -0
  68. pu_toolbox/utils/centroid.py +104 -0
  69. pu_toolbox/utils/serialization.py +65 -0
  70. pu_toolbox/workflows/__init__.py +14 -0
  71. pu_toolbox/workflows/pipeline.py +1006 -0
  72. pu_toolbox/workflows/report.py +260 -0
  73. pu_toolbox-1.0.0.dist-info/METADATA +186 -0
  74. pu_toolbox-1.0.0.dist-info/RECORD +77 -0
  75. pu_toolbox-1.0.0.dist-info/WHEEL +4 -0
  76. pu_toolbox-1.0.0.dist-info/entry_points.txt +2 -0
  77. pu_toolbox-1.0.0.dist-info/licenses/LICENSE +21 -0
pu_toolbox/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """PU Learning Toolbox -- Positive-Unlabeled Learning in Python."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .advisor import ScoringConfig, recommend_from_profile, recommend_methods
6
+ from .diagnostics import analyze_pu_sensitivity, build_diagnostic_report
7
+ from .estimators.bias_aware.lbe import LBEClassifier
8
+ from .estimators.bias_aware.pusb import PUSBClassifier
9
+ from .estimators.bias_aware.pusb_kernel import PUSBKernelClassifier
10
+ from .estimators.classic.elkan_noto import ElkanNotoClassifier
11
+ from .estimators.classic.llsvm import LLSVMClassifier
12
+ from .estimators.deep.dgpu import DGPUClassifier
13
+ from .estimators.deep.infomax_pu import InfoMaxPUClassifier
14
+ from .estimators.deep.self_pu import SelfPUClassifier
15
+ from .estimators.deep.weighted_contrastive_pu import WeightedContrastivePUClassifier
16
+ from .estimators.risk.dist_pu import DistPUClassifier
17
+ from .estimators.risk.kldce import KLDCEClassifier
18
+ from .estimators.risk.ldce import LDCEClassifier
19
+ from .estimators.risk.nnpu import NonNegativePUClassifier
20
+ from .estimators.risk.pnu import PNUClassifier
21
+ from .estimators.risk.upu import UPUClassifier
22
+ from .preprocessing import make_sar_dataset, profile_pu_data
23
+ from .prior.kernel_mean import KernelMeanPriorEstimator
24
+ from .prior.pen_l1 import ClassPriorEstimator
25
+ from .prior.recpe import ReCPEEstimator
26
+ from .workflows import PipelineReport, PUPipeline
27
+
28
+ __all__ = [
29
+ "ClassPriorEstimator",
30
+ "DGPUClassifier",
31
+ "DistPUClassifier",
32
+ "ElkanNotoClassifier",
33
+ "InfoMaxPUClassifier",
34
+ "KLDCEClassifier",
35
+ "KernelMeanPriorEstimator",
36
+ "LBEClassifier",
37
+ "LDCEClassifier",
38
+ "LLSVMClassifier",
39
+ "NonNegativePUClassifier",
40
+ "PNUClassifier",
41
+ "PUSBClassifier",
42
+ "PUSBKernelClassifier",
43
+ "PipelineReport",
44
+ "PUPipeline",
45
+ "ReCPEEstimator",
46
+ "ScoringConfig",
47
+ "SelfPUClassifier",
48
+ "UPUClassifier",
49
+ "WeightedContrastivePUClassifier",
50
+ "analyze_pu_sensitivity",
51
+ "build_diagnostic_report",
52
+ "make_sar_dataset",
53
+ "profile_pu_data",
54
+ "recommend_from_profile",
55
+ "recommend_methods",
56
+ ]
@@ -0,0 +1,17 @@
1
+ """Algorithm advisor — data-driven PU method recommendation."""
2
+
3
+ from ._types import MethodCandidate as MethodCandidate
4
+ from ._types import RecommendationResult as RecommendationResult
5
+ from .recommender import recommend_from_profile as recommend_from_profile
6
+ from .recommender import recommend_methods as recommend_methods
7
+ from .rules import DEFAULT_CONFIG as DEFAULT_CONFIG
8
+ from .rules import ScoringConfig as ScoringConfig
9
+
10
+ __all__ = [
11
+ "DEFAULT_CONFIG",
12
+ "MethodCandidate",
13
+ "RecommendationResult",
14
+ "ScoringConfig",
15
+ "recommend_from_profile",
16
+ "recommend_methods",
17
+ ]
@@ -0,0 +1,130 @@
1
+ """Data types for algorithm recommendations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from ..registry.metadata import AlgorithmMetadata
11
+
12
+ __all__ = [
13
+ "MethodCandidate",
14
+ "RecommendationResult",
15
+ ]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class MethodCandidate:
20
+ """A single recommended method with score and rationale."""
21
+
22
+ name: str
23
+ score: float
24
+ rank: int
25
+ reasons: tuple[str, ...]
26
+ warnings: tuple[str, ...]
27
+ metadata: AlgorithmMetadata
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ return {
31
+ "name": self.name,
32
+ "score": round(self.score, 1),
33
+ "rank": self.rank,
34
+ "reasons": list(self.reasons),
35
+ "warnings": list(self.warnings),
36
+ "family": str(self.metadata.family.value),
37
+ "backend": str(self.metadata.backend.value),
38
+ "maturity": str(self.metadata.maturity.value),
39
+ }
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RecommendationResult:
44
+ """Ranked algorithm recommendations with filters and warnings."""
45
+
46
+ candidates: tuple[MethodCandidate, ...]
47
+ filters_applied: dict[str, Any]
48
+ global_warnings: tuple[str, ...]
49
+ provenance: dict[str, Any]
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ return {
53
+ "schema_version": "1.0",
54
+ "candidates": [c.to_dict() for c in self.candidates],
55
+ "filters_applied": self.filters_applied,
56
+ "global_warnings": list(self.global_warnings),
57
+ "provenance": self.provenance,
58
+ }
59
+
60
+ def to_json(self, *, indent: int = 2) -> str:
61
+ return json.dumps(
62
+ self.to_dict(),
63
+ ensure_ascii=False,
64
+ indent=indent,
65
+ allow_nan=False,
66
+ )
67
+
68
+ def to_markdown(self) -> str:
69
+ lines = [
70
+ "# PU Method Recommendations",
71
+ "",
72
+ ]
73
+ if self.global_warnings:
74
+ lines.append("## Warnings")
75
+ lines.append("")
76
+ for w in self.global_warnings:
77
+ lines.append(f"- {w}")
78
+ lines.append("")
79
+ lines.extend(
80
+ [
81
+ "## Recommended Methods",
82
+ "",
83
+ "| Rank | Method | Score | Family | Backend | Maturity |",
84
+ "|---:|---|---:|---|---|---|",
85
+ ]
86
+ )
87
+ for c in self.candidates:
88
+ lines.append(
89
+ f"| {c.rank} | {c.name} | {c.score:.1f} "
90
+ f"| {c.metadata.family.value} | {c.metadata.backend.value} "
91
+ f"| {c.metadata.maturity.value} |"
92
+ )
93
+ lines.append("")
94
+ for c in self.candidates:
95
+ lines.append(f"### {c.rank}. {c.name}")
96
+ lines.append("")
97
+ if c.reasons:
98
+ for r in c.reasons:
99
+ lines.append(f"- {r}")
100
+ if c.warnings:
101
+ lines.append("")
102
+ for w in c.warnings:
103
+ lines.append(f"- **Warning**: {w}")
104
+ lines.append("")
105
+ return "\n".join(lines)
106
+
107
+ def save(
108
+ self,
109
+ path: str | Path,
110
+ *,
111
+ format: Literal["json", "markdown"] | None = None,
112
+ ) -> Path:
113
+ destination = Path(path)
114
+ fmt = format
115
+ if fmt is None:
116
+ suffix = destination.suffix.lower()
117
+ if suffix == ".json":
118
+ fmt = "json"
119
+ elif suffix in {".md", ".markdown"}:
120
+ fmt = "markdown"
121
+ else:
122
+ raise ValueError(
123
+ "Cannot infer report format. Use a .json/.md suffix or pass format=."
124
+ )
125
+ if fmt not in {"json", "markdown"}:
126
+ raise ValueError(f"Unknown format {fmt!r}; expected 'json' or 'markdown'.")
127
+ content = self.to_markdown() if fmt == "markdown" else self.to_json() + "\n"
128
+ destination.parent.mkdir(parents=True, exist_ok=True)
129
+ destination.write_text(content, encoding="utf-8")
130
+ return destination
@@ -0,0 +1,195 @@
1
+ # ruff: noqa: N803
2
+
3
+ """Algorithm recommender — match data profiles to registered PU methods."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from ..core.tags import Assumption, Maturity, Scenario
13
+ from ..preprocessing import PUDataProfile, profile_pu_data
14
+ from ..registry.registry import list_algorithms
15
+ from ._types import MethodCandidate, RecommendationResult
16
+ from .rules import DEFAULT_CONFIG, ScoringConfig, global_warnings, method_warnings, score_method
17
+
18
+ __all__ = [
19
+ "recommend_from_profile",
20
+ "recommend_methods",
21
+ ]
22
+
23
+
24
+ def recommend_methods(
25
+ X: Any,
26
+ y_pu: np.ndarray,
27
+ *,
28
+ scenario: Scenario | str | None = None,
29
+ assumption: Assumption | str | None = None,
30
+ class_prior: float | None = None,
31
+ has_gpu: bool = False,
32
+ top_k: int = 5,
33
+ random_state: int | None = 42,
34
+ config: ScoringConfig | None = None,
35
+ ) -> RecommendationResult:
36
+ """Recommend PU methods by profiling data and matching against the registry.
37
+
38
+ Parameters
39
+ ----------
40
+ X : array-like of shape (n_samples, n_features)
41
+ y_pu : array-like of shape (n_samples,)
42
+ PU labels (1 = labeled positive, 0 = unlabeled).
43
+ scenario : Scenario or str, optional
44
+ Data collection scenario constraint.
45
+ assumption : Assumption or str, optional
46
+ Labeling mechanism assumption constraint.
47
+ class_prior : float, optional
48
+ Known class prior P(Y=1). Methods requiring it are excluded when None.
49
+ has_gpu : bool
50
+ Whether GPU is available.
51
+ top_k : int
52
+ Maximum number of candidates to return.
53
+ random_state : int, optional
54
+ Seed for the data profiler.
55
+ config : ScoringConfig, optional
56
+ Custom scoring weights. Uses ``DEFAULT_CONFIG`` when not provided.
57
+
58
+ Returns
59
+ -------
60
+ RecommendationResult
61
+ """
62
+ profile = profile_pu_data(X, y_pu, class_prior=class_prior, random_state=random_state)
63
+ return recommend_from_profile(
64
+ profile,
65
+ scenario=scenario,
66
+ assumption=assumption,
67
+ class_prior=class_prior,
68
+ has_gpu=has_gpu,
69
+ top_k=top_k,
70
+ config=config,
71
+ )
72
+
73
+
74
+ def recommend_from_profile(
75
+ profile: PUDataProfile,
76
+ *,
77
+ scenario: Scenario | str | None = None,
78
+ assumption: Assumption | str | None = None,
79
+ class_prior: float | None = None,
80
+ class_prior_source: str | None = None,
81
+ has_gpu: bool = False,
82
+ top_k: int = 5,
83
+ config: ScoringConfig | None = None,
84
+ ) -> RecommendationResult:
85
+ """Recommend PU methods from an existing data profile.
86
+
87
+ Skips data profiling when a ``PUDataProfile`` is already available.
88
+ See :func:`recommend_methods` for parameter descriptions.
89
+
90
+ .. note::
91
+
92
+ ``class_prior`` controls hard filtering (excluding methods that
93
+ require π when it is unavailable). It must be consistent with
94
+ the value used to build *profile*; passing a different value
95
+ produces contradictory results.
96
+
97
+ ``class_prior_source`` records where *class_prior* came from
98
+ ("user" / "estimated" / ...); it only affects the global-warning
99
+ wording, never filtering or scoring.
100
+ """
101
+ if top_k < 1:
102
+ raise ValueError(f"top_k must be >= 1, got {top_k}")
103
+
104
+ cfg = config or DEFAULT_CONFIG
105
+
106
+ # register_all_builtin_methods is idempotent (registry-level dedup),
107
+ # so calling it per recommendation is safe and never goes stale.
108
+ from ..registry.builtin_methods import register_all_builtin_methods
109
+
110
+ register_all_builtin_methods()
111
+
112
+ scenario_enum = _resolve_enum(scenario, Scenario) if scenario is not None else None
113
+ assumption_enum = _resolve_enum(assumption, Assumption) if assumption is not None else None
114
+
115
+ all_methods = list_algorithms(trainable_only=True)
116
+
117
+ filters_applied: dict[str, Any] = {}
118
+ filtered = [m for m in all_methods if m.maturity != Maturity.DEPRECATED]
119
+ if len(filtered) != len(all_methods):
120
+ filters_applied["maturity"] = "excluded deprecated"
121
+
122
+ if scenario_enum is not None:
123
+ filtered = [m for m in filtered if scenario_enum in m.scenario]
124
+ filters_applied["scenario"] = scenario_enum.value
125
+
126
+ if profile.summary.get("is_sparse", False):
127
+ filtered = [m for m in filtered if m.supports_sparse]
128
+ filters_applied["sparse_support"] = True
129
+
130
+ if class_prior is None:
131
+ filtered = [m for m in filtered if not m.requires_class_prior]
132
+ filters_applied["class_prior_required"] = "excluded (not provided)"
133
+
134
+ if assumption_enum is not None:
135
+ filtered = [m for m in filtered if assumption_enum in m.assumption]
136
+ filters_applied["assumption"] = assumption_enum.value
137
+
138
+ scored = []
139
+ for meta in filtered:
140
+ sc, reasons = score_method(meta, profile, assumption_enum, has_gpu, cfg)
141
+ warns = method_warnings(meta, profile, assumption_enum)
142
+ scored.append((meta, sc, reasons, warns))
143
+
144
+ scored.sort(key=lambda t: t[1], reverse=True)
145
+ scored = scored[:top_k]
146
+
147
+ candidates = tuple(
148
+ MethodCandidate(
149
+ name=meta.name,
150
+ score=sc,
151
+ rank=i + 1,
152
+ reasons=tuple(reasons),
153
+ warnings=tuple(warns),
154
+ metadata=meta,
155
+ )
156
+ for i, (meta, sc, reasons, warns) in enumerate(scored)
157
+ )
158
+
159
+ gw = list(global_warnings(profile, class_prior, class_prior_source))
160
+ if profile.summary.get("is_sparse", False) and not filtered:
161
+ # No registered method accepts sparse input; say so instead of
162
+ # silently returning zero candidates.
163
+ gw.append(
164
+ "Sparse data: no registered method supports sparse input. "
165
+ "Densify the matrix (e.g. X.toarray()) or profile dense data."
166
+ )
167
+
168
+ provenance = {
169
+ "n_samples": profile.summary.get("n_samples"),
170
+ "n_features": profile.summary.get("n_features"),
171
+ "n_candidates_before_filter": len(all_methods),
172
+ "n_candidates_after_filter": len(filtered),
173
+ "top_k": top_k,
174
+ "has_gpu": has_gpu,
175
+ }
176
+
177
+ return RecommendationResult(
178
+ candidates=candidates,
179
+ filters_applied=filters_applied,
180
+ global_warnings=tuple(gw),
181
+ provenance=provenance,
182
+ )
183
+
184
+
185
+ def _resolve_enum(value: str | Any, enum_cls: type) -> Any:
186
+ if isinstance(value, enum_cls):
187
+ return value
188
+ if isinstance(value, Enum):
189
+ raise ValueError(f"Expected {enum_cls.__name__}, got {type(value).__name__}: {value!r}")
190
+ for member in enum_cls:
191
+ if member.value.lower() == str(value).lower():
192
+ return member
193
+ if member.name.lower() == str(value).lower():
194
+ return member
195
+ raise ValueError(f"Unknown {enum_cls.__name__} value: {value!r}")