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 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)))
@@ -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