rulewise 0.1.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.
- rulewise/__init__.py +9 -0
- rulewise/_compat.py +36 -0
- rulewise/classifier.py +257 -0
- rulewise/classmining.py +76 -0
- rulewise/io.py +98 -0
- rulewise/miner.py +329 -0
- rulewise/mining.py +109 -0
- rulewise/plot.py +179 -0
- rulewise/report.py +45 -0
- rulewise/rules.py +145 -0
- rulewise/rulesmixin.py +133 -0
- rulewise/stats.py +135 -0
- rulewise-0.1.0.dist-info/METADATA +273 -0
- rulewise-0.1.0.dist-info/RECORD +17 -0
- rulewise-0.1.0.dist-info/WHEEL +5 -0
- rulewise-0.1.0.dist-info/licenses/LICENSE +21 -0
- rulewise-0.1.0.dist-info/top_level.txt +1 -0
rulewise/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from . import plot
|
|
2
|
+
from .classifier import RuleClassifier
|
|
3
|
+
from .miner import RuleMiner
|
|
4
|
+
from .report import ValidationReport
|
|
5
|
+
from .stats import adjust_pvalues, fisher_greater, wilson_interval
|
|
6
|
+
|
|
7
|
+
__all__ = ["RuleClassifier", "RuleMiner", "ValidationReport", "adjust_pvalues", "fisher_greater",
|
|
8
|
+
"wilson_interval", "plot"]
|
|
9
|
+
__version__ = "0.1.0"
|
rulewise/_compat.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Optional scikit-learn integration: use its base classes when installed, else tiny fallbacks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
|
|
6
|
+
try: # pragma: no cover - exercised implicitly depending on environment
|
|
7
|
+
from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
|
|
8
|
+
from sklearn.exceptions import NotFittedError
|
|
9
|
+
HAS_SKLEARN = True
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
HAS_SKLEARN = False
|
|
12
|
+
|
|
13
|
+
class NotFittedError(RuntimeError, ValueError):
|
|
14
|
+
"""Raised when a fitted attribute is needed before fit()."""
|
|
15
|
+
|
|
16
|
+
class BaseEstimator:
|
|
17
|
+
def get_params(self, deep=True):
|
|
18
|
+
return {k: getattr(self, k) for k in inspect.signature(type(self).__init__).parameters
|
|
19
|
+
if k != "self"}
|
|
20
|
+
|
|
21
|
+
def set_params(self, **params):
|
|
22
|
+
valid = self.get_params()
|
|
23
|
+
for k, v in params.items():
|
|
24
|
+
if k not in valid:
|
|
25
|
+
raise ValueError(f"invalid parameter {k!r}")
|
|
26
|
+
setattr(self, k, v)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
class TransformerMixin:
|
|
30
|
+
def fit_transform(self, X, y=None, **fit_params):
|
|
31
|
+
return self.fit(X, y, **fit_params).transform(X)
|
|
32
|
+
|
|
33
|
+
class ClassifierMixin:
|
|
34
|
+
def score(self, X, y):
|
|
35
|
+
import numpy as np
|
|
36
|
+
return float(np.mean(np.asarray(self.predict(X)) == np.asarray(y)))
|
rulewise/classifier.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""RuleClassifier: an associative classifier built only from statistically significant rules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import scipy.sparse as sp
|
|
7
|
+
|
|
8
|
+
from ._compat import BaseEstimator, ClassifierMixin, NotFittedError
|
|
9
|
+
from .io import to_transactions
|
|
10
|
+
from .miner import RuleMiner
|
|
11
|
+
from .report import ValidationReport
|
|
12
|
+
from .rulesmixin import RulesMixin
|
|
13
|
+
from .stats import wilson_interval
|
|
14
|
+
|
|
15
|
+
_CLASS = "__rulewise_class__"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RuleClassifier(RulesMixin, ClassifierMixin, BaseEstimator):
|
|
19
|
+
"""Classify with class-association rules that survive honest statistical testing.
|
|
20
|
+
|
|
21
|
+
Training mines rules ``antecedent -> class`` and keeps only those that are significantly
|
|
22
|
+
productive after multiple-testing correction (see :class:`RuleMiner`). Prediction finds the
|
|
23
|
+
matching rule with the highest Wilson lower bound on its confidence; that rule supplies the
|
|
24
|
+
probability of its class, and the remaining probability is shared among the other classes
|
|
25
|
+
in proportion to their training frequency. For two classes this is exactly the rule's
|
|
26
|
+
estimated probability for each class. When no rule matches, class frequencies are returned.
|
|
27
|
+
|
|
28
|
+
Because only significant rules are kept, the model can abstain from structure it cannot
|
|
29
|
+
justify: on data with no real signal it predicts the majority class and says so
|
|
30
|
+
(``rules_`` is empty).
|
|
31
|
+
|
|
32
|
+
Notes
|
|
33
|
+
-----
|
|
34
|
+
* ``min_support`` is a fraction of ALL training transactions, so rules for rare classes
|
|
35
|
+
need a lower value than for common ones.
|
|
36
|
+
* ``mode='direct'`` (default) uses all data to find and test rules; in cross-validated
|
|
37
|
+
benchmarks it predicted better than ``'holdout'`` on every dataset tried, most of all on
|
|
38
|
+
small data, because holdout discards half the sample. Its in-sample rule confidences are
|
|
39
|
+
optimistically biased, so judge probabilities by cross-validation, not by ``rules_``.
|
|
40
|
+
``mode='holdout'`` estimates rule confidences on data not used to find the rules.
|
|
41
|
+
* Best suited to data where the class follows a few conjunctions of features (tabular,
|
|
42
|
+
categorical, behavioural). It uses one best rule per prediction, so it is weak where
|
|
43
|
+
evidence is spread thinly over many features (e.g. image pixels); see
|
|
44
|
+
``benchmarks/RESULTS.md``.
|
|
45
|
+
* ``class_conditional=True`` (default) mines each class separately, enumerating only
|
|
46
|
+
itemsets frequent within a class. It yields exactly the rules the general miner would
|
|
47
|
+
(tested) but is much faster on dense data; ``False`` uses the general miner.
|
|
48
|
+
* ``add_negations=True`` also creates an item ``('not', x)`` for every feature ``x`` a row
|
|
49
|
+
lacks, so rules can express absence (e.g. ``a and b and not c``). Plain itemsets cannot.
|
|
50
|
+
It doubles the item count and densifies the data, so use it with a modest number of
|
|
51
|
+
features and a higher ``min_support``.
|
|
52
|
+
* Numeric features must be discretised first (e.g. scikit-learn's KBinsDiscretizer with
|
|
53
|
+
``encode='onehot'``); a sparse matrix is accepted directly.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, min_support=0.01, min_confidence=0.0, max_antecedent=3, alpha=0.05,
|
|
57
|
+
correction="holm", mode="direct", holdout_fraction=0.5, backend="auto",
|
|
58
|
+
max_cache_mb=128.0, add_negations=False, class_conditional=True,
|
|
59
|
+
random_state=0):
|
|
60
|
+
self.min_support = min_support
|
|
61
|
+
self.min_confidence = min_confidence
|
|
62
|
+
self.max_antecedent = max_antecedent
|
|
63
|
+
self.alpha = alpha
|
|
64
|
+
self.correction = correction
|
|
65
|
+
self.mode = mode
|
|
66
|
+
self.holdout_fraction = holdout_fraction
|
|
67
|
+
self.backend = backend
|
|
68
|
+
self.max_cache_mb = max_cache_mb
|
|
69
|
+
self.add_negations = add_negations
|
|
70
|
+
self.class_conditional = class_conditional
|
|
71
|
+
self.random_state = random_state
|
|
72
|
+
|
|
73
|
+
def __sklearn_tags__(self):
|
|
74
|
+
try:
|
|
75
|
+
tags = super().__sklearn_tags__()
|
|
76
|
+
except AttributeError: # scikit-learn < 1.6
|
|
77
|
+
return {}
|
|
78
|
+
tags.input_tags.sparse = True
|
|
79
|
+
return tags
|
|
80
|
+
|
|
81
|
+
def fit(self, X, y, item_names=None):
|
|
82
|
+
tx, names = to_transactions(X, item_names)
|
|
83
|
+
y = np.asarray(y)
|
|
84
|
+
if len(y) != len(tx):
|
|
85
|
+
raise ValueError(f"X has {len(tx)} transactions but y has {len(y)} labels")
|
|
86
|
+
self.classes_, y_idx = np.unique(y, return_inverse=True)
|
|
87
|
+
k = len(self.classes_)
|
|
88
|
+
self.priors_ = np.bincount(y_idx, minlength=k) / len(y_idx)
|
|
89
|
+
self._names = names
|
|
90
|
+
self._universe = None
|
|
91
|
+
if names is not None:
|
|
92
|
+
self.n_features_in_ = len(names)
|
|
93
|
+
if self.add_negations:
|
|
94
|
+
self._universe = sorted({i for t in tx for i in t}, key=str)
|
|
95
|
+
tx = self._negate(tx)
|
|
96
|
+
class_items = [(_CLASS, c) for c in range(k)]
|
|
97
|
+
aug = [t + [class_items[c]] for t, c in zip(tx, y_idx)]
|
|
98
|
+
miner = RuleMiner(min_support=self.min_support, min_confidence=self.min_confidence,
|
|
99
|
+
max_antecedent=self.max_antecedent, max_consequent=1, alpha=self.alpha,
|
|
100
|
+
correction=self.correction, mode=self.mode,
|
|
101
|
+
holdout_fraction=self.holdout_fraction, n_bootstrap=0,
|
|
102
|
+
backend=self.backend, max_cache_mb=self.max_cache_mb,
|
|
103
|
+
random_state=self.random_state)
|
|
104
|
+
miner._fit_tx(aug, targets=class_items, class_conditional=self.class_conditional)
|
|
105
|
+
self.miner_ = miner
|
|
106
|
+
self.diagnostics_ = dict(miner.diagnostics_)
|
|
107
|
+
self._build_rules(miner)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def _negate(self, tx):
|
|
111
|
+
out = []
|
|
112
|
+
for t in tx:
|
|
113
|
+
present = set(t)
|
|
114
|
+
out.append(list(present) + [("not", i) for i in self._universe if i not in present])
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def _build_rules(self, miner):
|
|
118
|
+
r = miner.rules_
|
|
119
|
+
if len(r) == 0:
|
|
120
|
+
self.rules_ = pd.DataFrame(columns=["antecedent", "class_", "support", "confidence",
|
|
121
|
+
"confidence_low", "confidence_high", "lift",
|
|
122
|
+
"leverage", "count", "count_antecedent",
|
|
123
|
+
"count_consequent", "p_value", "p_adjusted"])
|
|
124
|
+
self._A = sp.csr_matrix((0, len(miner.vocab_)), dtype=np.int32)
|
|
125
|
+
self._sizes = np.zeros(0, dtype=np.int32)
|
|
126
|
+
self._rule_class = np.zeros(0, dtype=int)
|
|
127
|
+
self._rule_conf = np.zeros(0)
|
|
128
|
+
return
|
|
129
|
+
r = r.copy()
|
|
130
|
+
r["_k"] = [c[0][1] for c in r.consequent]
|
|
131
|
+
r = r.sort_values(["confidence_low", "confidence", "count"],
|
|
132
|
+
ascending=[False, False, False]).reset_index(drop=True)
|
|
133
|
+
vocab = miner.vocab_
|
|
134
|
+
rows, cols = [], []
|
|
135
|
+
for i, ant in enumerate(r.antecedent):
|
|
136
|
+
for it in ant:
|
|
137
|
+
rows.append(i)
|
|
138
|
+
cols.append(vocab[it])
|
|
139
|
+
self._A = sp.csr_matrix((np.ones(len(rows), dtype=np.int32), (rows, cols)),
|
|
140
|
+
shape=(len(r), len(vocab)))
|
|
141
|
+
self._sizes = r.antecedent.map(len).to_numpy().astype(np.int32)
|
|
142
|
+
self._rule_class = r["_k"].to_numpy().astype(int)
|
|
143
|
+
self._rule_conf = r["confidence"].to_numpy()
|
|
144
|
+
r["class_"] = self.classes_[self._rule_class]
|
|
145
|
+
keep = ["antecedent", "class_", "support", "confidence", "confidence_low",
|
|
146
|
+
"confidence_high", "lift", "leverage", "count", "count_antecedent",
|
|
147
|
+
"p_value", "p_adjusted"]
|
|
148
|
+
self.rules_ = r[keep]
|
|
149
|
+
|
|
150
|
+
def _check(self):
|
|
151
|
+
if not hasattr(self, "miner_"):
|
|
152
|
+
raise NotFittedError("call fit() first")
|
|
153
|
+
|
|
154
|
+
def _best_rule(self, X):
|
|
155
|
+
"""Index of the highest-priority matching rule per row (-1 if none)."""
|
|
156
|
+
self._check_width(X)
|
|
157
|
+
tx, _ = to_transactions(X, default_names=self._names)
|
|
158
|
+
if self._universe is not None:
|
|
159
|
+
tx = self._negate(tx)
|
|
160
|
+
vocab = self.miner_.vocab_
|
|
161
|
+
rows, cols = [], []
|
|
162
|
+
for i, t in enumerate(tx):
|
|
163
|
+
for it in set(t):
|
|
164
|
+
j = vocab.get(it)
|
|
165
|
+
if j is not None:
|
|
166
|
+
rows.append(i)
|
|
167
|
+
cols.append(j)
|
|
168
|
+
n = len(tx)
|
|
169
|
+
best = np.full(n, -1, dtype=int)
|
|
170
|
+
if self._A.shape[0] == 0 or n == 0:
|
|
171
|
+
return best
|
|
172
|
+
Xc = sp.csr_matrix((np.ones(len(rows), dtype=np.int32), (rows, cols)),
|
|
173
|
+
shape=(n, len(vocab)))
|
|
174
|
+
M = (Xc @ self._A.T).tocsr()
|
|
175
|
+
M.data = (M.data == self._sizes[M.indices]).astype(np.int8)
|
|
176
|
+
M.eliminate_zeros()
|
|
177
|
+
M.sort_indices()
|
|
178
|
+
nz = np.diff(M.indptr) > 0
|
|
179
|
+
best[nz] = M.indices[M.indptr[:-1][nz]] # rules are stored in priority order
|
|
180
|
+
return best
|
|
181
|
+
|
|
182
|
+
def predict_proba(self, X):
|
|
183
|
+
self._check()
|
|
184
|
+
best = self._best_rule(X)
|
|
185
|
+
n, k = len(best), len(self.classes_)
|
|
186
|
+
P = np.tile(self.priors_, (n, 1))
|
|
187
|
+
hit = best >= 0
|
|
188
|
+
if hit.any() and k > 1:
|
|
189
|
+
c = self._rule_class[best[hit]]
|
|
190
|
+
conf = self._rule_conf[best[hit]]
|
|
191
|
+
rest = np.tile(self.priors_, (int(hit.sum()), 1))
|
|
192
|
+
rest[np.arange(len(c)), c] = 0.0
|
|
193
|
+
tot = rest.sum(axis=1, keepdims=True)
|
|
194
|
+
rest = np.where(tot > 0, rest / np.where(tot > 0, tot, 1.0), 1.0 / (k - 1))
|
|
195
|
+
P[hit] = rest * (1.0 - conf)[:, None]
|
|
196
|
+
P[np.nonzero(hit)[0], c] = conf
|
|
197
|
+
return P
|
|
198
|
+
|
|
199
|
+
def predict(self, X):
|
|
200
|
+
P = self.predict_proba(X)
|
|
201
|
+
return self.classes_[np.argmax(P, axis=1)]
|
|
202
|
+
|
|
203
|
+
def explain(self, X):
|
|
204
|
+
"""For each row, the rule that determined its probabilities (None if none matched)."""
|
|
205
|
+
self._check()
|
|
206
|
+
best = self._best_rule(X)
|
|
207
|
+
return [self.rules_.iloc[b].to_dict() if b >= 0 else None for b in best]
|
|
208
|
+
|
|
209
|
+
def validate(self, X, y, n_null=20, cv=5):
|
|
210
|
+
"""Validate on this data and return a :class:`ValidationReport`.
|
|
211
|
+
|
|
212
|
+
* Null calibration: refit with the labels randomly permuted (features untouched, so any
|
|
213
|
+
rule found is spurious); the share of permuted datasets with at least one rule
|
|
214
|
+
estimates the empirical error rate.
|
|
215
|
+
* ``cv``-fold cross-validated accuracy and log-loss against the majority-class and
|
|
216
|
+
class-prior baselines (does the rule set beat knowing only the class frequencies?).
|
|
217
|
+
"""
|
|
218
|
+
tx, names = to_transactions(X, default_names=getattr(self, "_names", None))
|
|
219
|
+
y = np.asarray(y)
|
|
220
|
+
n = len(tx)
|
|
221
|
+
params = self.get_params()
|
|
222
|
+
rng = np.random.default_rng(self.random_state)
|
|
223
|
+
n_rules = []
|
|
224
|
+
for _ in range(n_null):
|
|
225
|
+
m = RuleClassifier(**{**params, "random_state": int(rng.integers(1 << 31))})
|
|
226
|
+
m.fit(tx, rng.permutation(y))
|
|
227
|
+
n_rules.append(len(m.rules_))
|
|
228
|
+
n_rules = np.array(n_rules)
|
|
229
|
+
accs, lls, base_accs, base_lls = [], [], [], []
|
|
230
|
+
folds = np.array_split(rng.permutation(n), cv)
|
|
231
|
+
for k in range(cv):
|
|
232
|
+
test = folds[k]
|
|
233
|
+
train = np.concatenate([folds[j] for j in range(cv) if j != k])
|
|
234
|
+
m = RuleClassifier(**params).fit([tx[i] for i in train], y[train])
|
|
235
|
+
P = m.predict_proba([tx[i] for i in test])
|
|
236
|
+
yt = y[test]
|
|
237
|
+
col = {c: j for j, c in enumerate(m.classes_)}
|
|
238
|
+
p_true = np.array([P[r, col[c]] if c in col else 1e-6 for r, c in enumerate(yt)])
|
|
239
|
+
prior = np.array([m.priors_[col[c]] if c in col else 1e-6 for c in yt])
|
|
240
|
+
accs.append(float(np.mean(m.classes_[P.argmax(1)] == yt)))
|
|
241
|
+
lls.append(float(-np.mean(np.log(np.clip(p_true, 1e-6, 1)))))
|
|
242
|
+
base_accs.append(float(np.mean(yt == m.classes_[np.argmax(m.priors_)])))
|
|
243
|
+
base_lls.append(float(-np.mean(np.log(np.clip(prior, 1e-6, 1)))))
|
|
244
|
+
real = self.fit(tx, y)
|
|
245
|
+
self._names = names
|
|
246
|
+
hits = int((n_rules > 0).sum())
|
|
247
|
+
ci = wilson_interval(hits, n_null, 0.05)
|
|
248
|
+
return ValidationReport(
|
|
249
|
+
mode=self.mode, alpha=self.alpha,
|
|
250
|
+
honest_estimates=real.diagnostics_["honest_estimates"],
|
|
251
|
+
n_rules_real=len(real.rules_), replication_rate=real.diagnostics_["replication_rate"],
|
|
252
|
+
null_runs=n_null, null_kind="datasets with permuted labels",
|
|
253
|
+
null_rule_counts=[int(c) for c in n_rules], null_mean_rules=float(n_rules.mean()),
|
|
254
|
+
null_fwer=hits / n_null, null_fwer_ci=ci, calibrated=bool(ci[0] <= self.alpha),
|
|
255
|
+
cv_folds=cv, cv_accuracy=float(np.mean(accs)), cv_accuracy_sd=float(np.std(accs)),
|
|
256
|
+
majority_accuracy=float(np.mean(base_accs)), cv_log_loss=float(np.mean(lls)),
|
|
257
|
+
prior_log_loss=float(np.mean(base_lls)))
|
rulewise/classmining.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Class-conditional candidate generation for class-association rules.
|
|
2
|
+
|
|
3
|
+
For rules X -> class, an itemset X can only matter if it is frequent *within* that class, so
|
|
4
|
+
we mine each class's transactions separately (itemset size <= max_antecedent) instead of
|
|
5
|
+
enumerating every feature combination in the whole data. The resulting candidate set is
|
|
6
|
+
identical to the general pipeline's (verified by tests) but is far smaller to enumerate on
|
|
7
|
+
dense data.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from itertools import combinations
|
|
12
|
+
|
|
13
|
+
from .mining import encode, mine_itemsets
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def class_conditional_candidates(expl, vocab, target_ids, min_count, max_antecedent,
|
|
17
|
+
min_confidence, backend="auto"):
|
|
18
|
+
"""Return (candidates, n_itemsets_mined, backend_used).
|
|
19
|
+
|
|
20
|
+
candidates: list of (frozenset(antecedent ids), frozenset({class item id})) with
|
|
21
|
+
confidence >= min_confidence and lift > 1, exactly as the general pipeline would produce.
|
|
22
|
+
"""
|
|
23
|
+
n = len(expl)
|
|
24
|
+
tidsets, _ = encode(expl, vocab)
|
|
25
|
+
full = (1 << n) - 1
|
|
26
|
+
inv = {j: i for i, j in vocab.items()}
|
|
27
|
+
# Items present in every transaction can never be in a significant rule (lift 1, no
|
|
28
|
+
# added coverage); class items are never antecedents.
|
|
29
|
+
drop = {j for j, t in enumerate(tidsets) if t.bit_count() == n} | set(target_ids)
|
|
30
|
+
|
|
31
|
+
cache = {}
|
|
32
|
+
|
|
33
|
+
def count_x(X):
|
|
34
|
+
v = cache.get(X)
|
|
35
|
+
if v is None:
|
|
36
|
+
t = full
|
|
37
|
+
for i in X:
|
|
38
|
+
t &= tidsets[i]
|
|
39
|
+
v = cache[X] = t.bit_count()
|
|
40
|
+
return v
|
|
41
|
+
|
|
42
|
+
cands, n_sets, used = [], 0, "python"
|
|
43
|
+
for t in sorted(target_ids):
|
|
44
|
+
cnt_c = tidsets[t].bit_count()
|
|
45
|
+
if cnt_c < min_count:
|
|
46
|
+
continue
|
|
47
|
+
target_item = inv[t]
|
|
48
|
+
sub = [[i for i in row if vocab[i] not in drop] for row in expl if target_item in row]
|
|
49
|
+
n_sub = len(sub)
|
|
50
|
+
# Items present in every row of THIS class are strong predictors but would be dropped
|
|
51
|
+
# by the miner (and by PyFIM); mine without them and add them back exactly.
|
|
52
|
+
per_item = {}
|
|
53
|
+
for row in sub:
|
|
54
|
+
for i in set(row):
|
|
55
|
+
per_item[i] = per_item.get(i, 0) + 1
|
|
56
|
+
universal = sorted(vocab[i] for i, c in per_item.items() if c == n_sub)
|
|
57
|
+
base, used = mine_itemsets(sub, vocab, min_count, max_antecedent, backend)
|
|
58
|
+
sets = dict(base)
|
|
59
|
+
if universal:
|
|
60
|
+
with_empty = dict(base)
|
|
61
|
+
with_empty[frozenset()] = n_sub
|
|
62
|
+
for r in range(1, min(len(universal), max_antecedent) + 1):
|
|
63
|
+
for W in combinations(universal, r):
|
|
64
|
+
Wf = frozenset(W)
|
|
65
|
+
for S, c in with_empty.items():
|
|
66
|
+
if len(S) + r <= max_antecedent:
|
|
67
|
+
sets[S | Wf] = c
|
|
68
|
+
n_sets += len(sets)
|
|
69
|
+
for X, c_xc in sets.items():
|
|
70
|
+
if not X:
|
|
71
|
+
continue
|
|
72
|
+
cx = count_x(X)
|
|
73
|
+
conf = c_xc / cx
|
|
74
|
+
if conf >= min_confidence and conf * n > cnt_c: # same test as candidate_rules
|
|
75
|
+
cands.append((X, frozenset({t})))
|
|
76
|
+
return cands, n_sets, used
|
rulewise/io.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Input adapters: turn common data containers into lists of transactions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _from_lists(seq):
|
|
8
|
+
out = []
|
|
9
|
+
for t in seq:
|
|
10
|
+
if t is None:
|
|
11
|
+
out.append([])
|
|
12
|
+
elif isinstance(t, (str, bytes)):
|
|
13
|
+
raise TypeError("each transaction must be a collection of items, not a string "
|
|
14
|
+
f"(got {t!r}); split it first")
|
|
15
|
+
else:
|
|
16
|
+
out.append(list(t))
|
|
17
|
+
return out
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _names_for(width, names):
|
|
21
|
+
if names is None:
|
|
22
|
+
return list(range(width))
|
|
23
|
+
names = list(names)
|
|
24
|
+
if len(names) != width:
|
|
25
|
+
raise ValueError(f"item_names has {len(names)} entries but the data has {width} columns")
|
|
26
|
+
return names
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _from_dense(M, names):
|
|
30
|
+
M = np.asarray(M)
|
|
31
|
+
if M.ndim != 2:
|
|
32
|
+
raise ValueError("matrix input must be 2-dimensional")
|
|
33
|
+
if M.dtype.kind == "c":
|
|
34
|
+
raise ValueError("Complex data not supported")
|
|
35
|
+
if M.shape[1] == 0:
|
|
36
|
+
raise ValueError(f"0 feature(s) (shape={M.shape}) while a minimum of 1 is required.")
|
|
37
|
+
if M.dtype.kind == "O":
|
|
38
|
+
try:
|
|
39
|
+
M = M.astype(float)
|
|
40
|
+
except (TypeError, ValueError) as e:
|
|
41
|
+
raise TypeError("argument must be a string or a number (a 0/1 presence matrix), "
|
|
42
|
+
"or pass a collection of item collections instead") from e
|
|
43
|
+
names = _names_for(M.shape[1], names)
|
|
44
|
+
if M.dtype.kind == "f" and not np.isfinite(M).all():
|
|
45
|
+
raise ValueError("Input contains NaN or infinity; encode missing values as 0")
|
|
46
|
+
mask = M != 0
|
|
47
|
+
return [[names[j] for j in np.flatnonzero(row)] for row in mask], names
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _from_sparse(M, names):
|
|
51
|
+
M = M.tocsr()
|
|
52
|
+
names = _names_for(M.shape[1], names)
|
|
53
|
+
ind, ptr, dat = M.indices, M.indptr, M.data
|
|
54
|
+
out = []
|
|
55
|
+
for i in range(M.shape[0]):
|
|
56
|
+
sl = slice(ptr[i], ptr[i + 1])
|
|
57
|
+
cols = ind[sl][dat[sl] != 0]
|
|
58
|
+
out.append([names[j] for j in cols])
|
|
59
|
+
return out, names
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def to_transactions(X, item_names=None, default_names=None):
|
|
63
|
+
"""Return (transactions, names).
|
|
64
|
+
|
|
65
|
+
``names`` is the list of column labels when X was matrix-like (so later calls can map a
|
|
66
|
+
matrix back to the same items), otherwise None. Accepted inputs: iterables of item
|
|
67
|
+
collections, numpy 0/1 or boolean matrices, scipy sparse matrices, pandas DataFrames
|
|
68
|
+
(dense or sparse one-hot) and Series of lists, polars DataFrames (one-hot, or a single
|
|
69
|
+
list column) and Series of lists.
|
|
70
|
+
"""
|
|
71
|
+
mod = type(X).__module__.split(".")[0]
|
|
72
|
+
if item_names is None:
|
|
73
|
+
item_names = default_names
|
|
74
|
+
|
|
75
|
+
if mod == "polars":
|
|
76
|
+
import polars as pl
|
|
77
|
+
if isinstance(X, pl.Series):
|
|
78
|
+
return _from_lists(X.to_list()), None
|
|
79
|
+
if isinstance(X, pl.DataFrame):
|
|
80
|
+
if X.width == 1 and str(X.schema[X.columns[0]]).startswith(("List", "Array")):
|
|
81
|
+
return _from_lists(X.get_column(X.columns[0]).to_list()), None
|
|
82
|
+
return _from_dense(X.to_numpy(), item_names or X.columns)
|
|
83
|
+
if mod == "pandas":
|
|
84
|
+
import pandas as pd
|
|
85
|
+
if isinstance(X, pd.Series):
|
|
86
|
+
return _from_lists(X.tolist()), None
|
|
87
|
+
if isinstance(X, pd.DataFrame):
|
|
88
|
+
names = item_names or list(X.columns)
|
|
89
|
+
if len(X.columns) and all(isinstance(dt, pd.SparseDtype) for dt in X.dtypes):
|
|
90
|
+
return _from_sparse(X.sparse.to_coo(), names)
|
|
91
|
+
return _from_dense(X.to_numpy(), names)
|
|
92
|
+
if mod == "scipy":
|
|
93
|
+
import scipy.sparse as sp
|
|
94
|
+
if sp.issparse(X):
|
|
95
|
+
return _from_sparse(X, item_names)
|
|
96
|
+
if isinstance(X, np.ndarray) and X.ndim == 2:
|
|
97
|
+
return _from_dense(X, item_names)
|
|
98
|
+
return _from_lists(X), None
|