From 08cb40682212dddfedd572c0e43c07de5937d5b5 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 10 Nov 2026 10:55:01 +0000 Subject: [PATCH 1/2] Ordinal "at least / at most" conditions (opt-in ordinal_conditions) A rule can now state `x IS at least t` or `x IS at most t` over the ordered terms of a variable, besides a single term. A variable with n terms offers 3n - 4 conditions (the n terms, and at least / at most t_j for j = 1..n-2); the membership is the bounded sum min(1, sum of the covered terms), a shouldered trapezoid on a strong partition. The term gene is widened to the condition codes (no gene is added), so sampling, crossover, mutation, the array / population / streamed / PyTorch objectives and duplicate-rule detection handle it. Estimators: BaseFuzzyRulesClassifier (winning rule, mixture, mixture_search) and BaseFuzzyRulesRegressor (crisp and fuzzy). Printing, rule_conditions() (operator / term without parsing), text persistence, pickles, pattern stability and the latex / chart helpers handle it. Off by default: encoding, rules, files and seeded fits equal 3.4.0 (replayed from a recorded fixture). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qtw1LhwWgNJnXyJzGsLR2p --- LLM/DEVELOPMENT.md | 7 + README.md | 21 + benchmarks/benchmark_ordinal_conditions.py | 135 ++++ docs/source/changelog.rst | 21 + docs/source/index.rst | 1 + docs/source/ordinal_conditions.rst | 155 ++++ ex_fuzzy/_torch_fitness.py | 7 +- ex_fuzzy/bootstrapping_test.py | 6 +- ex_fuzzy/evolutionary_fit.py | 83 ++- ex_fuzzy/evolutionary_fit_regression.py | 34 +- ex_fuzzy/fuzzy_sets.py | 231 +++++- ex_fuzzy/pattern_stability.py | 18 +- ex_fuzzy/persistence.py | 45 +- ex_fuzzy/rule_mixture.py | 3 +- ex_fuzzy/rules.py | 54 +- ex_fuzzy/vis_rules.py | 18 +- tests/data/ordinal_default_golden.json | 823 +++++++++++++++++++++ tests/golden_ordinal_default.py | 64 ++ tests/test_ordinal_conditions.py | 653 ++++++++++++++++ 19 files changed, 2326 insertions(+), 53 deletions(-) create mode 100644 benchmarks/benchmark_ordinal_conditions.py create mode 100644 docs/source/ordinal_conditions.rst create mode 100644 tests/data/ordinal_default_golden.json create mode 100644 tests/golden_ordinal_default.py create mode 100644 tests/test_ordinal_conditions.py diff --git a/LLM/DEVELOPMENT.md b/LLM/DEVELOPMENT.md index 2057e781..4682c63c 100644 --- a/LLM/DEVELOPMENT.md +++ b/LLM/DEVELOPMENT.md @@ -105,6 +105,13 @@ kernels in `rules.py`, read [SPEED_UP.md](performance/SPEED_UP.md) and its score, weight or accuracy. `RuleSimple` equality and hashing, rule-base duplicate removal and the array decoder's per-class dedup must agree, and the first occurrence keeps its weight. +- A condition of a rule is an integer code of its variable (`fuzzyVariable.condition`): the term index + or, with `ordinal_conditions`, a code past the terms for `at least` / `at most`. The extra conditions + are extra rows of `fuzzyVariable.compute_memberships` (and columns of the packed table), and the term + gene is remapped onto `FitRuleBase.n_term_codes`, which equals the term counts when the option is off, + so every evaluator and decoder handles them without a branch. Code that sizes or indexes by terms + (`len(variable)`) must use `fuzzy_sets.condition_count` / `condition_label` instead. Off, the encoding + and every seeded fit must equal the recorded ones (`tests/test_ordinal_conditions.py`). - Do not present kernel timings as complete-fit gains or benchmark prototypes as shipped capabilities. diff --git a/README.md b/README.md index 5b425daf..56598356 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,27 @@ print(f"Test R2: {regressor.score(X_test, y_test):.3f}") regressor.print_rules() ``` +### Ordinal "at least / at most" conditions + +With `ordinal_conditions=True` the rules of `BaseFuzzyRulesClassifier` and `BaseFuzzyRulesRegressor` may state a +range of ordered terms, `IF x IS at least Medium` or `IF x IS at most Low`, besides a single term. The membership +is the bounded sum of the terms covered; the default (`False`) leaves rules, files and fits unchanged +([details](docs/source/ordinal_conditions.rst)). + +```python +from ex_fuzzy import BaseFuzzyRulesClassifier +from sklearn.datasets import load_iris + +X, y = load_iris(return_X_y=True) +model = BaseFuzzyRulesClassifier(nRules=15, nAnts=2, n_linguistic_variables=4, ordinal_conditions=True) +model.fit(X, y) +model.print_rules() # IF 0 IS at least Medium AND 3 IS at least High WITH DS ..., ACC ... + +for rule in model.rule_base.get_rules(): # the operator without parsing the text + for c in rule.conditions(model.rule_base.antecedents): + print(c.variable, c.operator, c.term) # operator: "is", "at_least" or "at_most" +``` + ## 📊 Visualizations Ex-Fuzzy provides beautiful visualizations to understand your fuzzy models: diff --git a/benchmarks/benchmark_ordinal_conditions.py b/benchmarks/benchmark_ordinal_conditions.py new file mode 100644 index 00000000..02c79453 --- /dev/null +++ b/benchmarks/benchmark_ordinal_conditions.py @@ -0,0 +1,135 @@ +"""Small check of ``ordinal_conditions``: the option off and on, on scikit-learn toy data. + +Per dataset and arm it reports, over stratified folds and seeds, the macro F1 (classification) or R^2 +(regression) on the held-out fold, the number of rules, the mean number of conditions per rule and the fit +time. Partitions are fixed (4 strong trapezoidal terms from the training fold), so the arms differ only in the +conditions the search can write. Arms: + + off ordinal_conditions=False, the default + on ordinal_conditions=True, the same budget (generations x population) + on-2x ordinal_conditions=True, twice the generations (the search space is larger) + + python -u benchmarks/benchmark_ordinal_conditions.py --out results.json + +It is a sanity check on a few small datasets, not a benchmark of the method. +""" +import argparse +import json +import statistics +import time +import warnings + +import numpy as np +from sklearn.datasets import load_breast_cancer, load_diabetes, load_iris, load_wine +from sklearn.metrics import f1_score, r2_score +from sklearn.model_selection import KFold, StratifiedKFold + +from ex_fuzzy import BaseFuzzyRulesClassifier, BaseFuzzyRulesRegressor, utils + +ARMS = {'off': (False, 1), 'on': (True, 1), 'on-2x': (True, 2)} + + +def mean_conditions(rules_list): + return float(np.mean([sum(code != -1 for code in rule.antecedents) for rule in rules_list])) if rules_list else 0.0 + + +def run_classification(name, data, args, inference, progress): + X, y = data + rows = {arm: [] for arm in ARMS} + for seed in range(args.seeds): + folds = StratifiedKFold(args.folds, shuffle=True, random_state=seed).split(X, y) + for fold, (train, test) in enumerate(folds): + variables = utils.construct_partitions(X[train], n_partitions=args.terms, detect_categorical=False) + for arm, (ordinal, factor) in ARMS.items(): + options = dict(inference=inference, mixture_search=True, mixture_non_negative=True) \ + if inference == 'mixture' else {} + clf = BaseFuzzyRulesClassifier( + nRules=args.rules, nAnts=args.ants, linguistic_variables=variables, ordinal_conditions=ordinal, + n_gen=args.gens * factor, pop_size=args.pop, random_state=seed, patience=None, **options) + start = time.perf_counter() + clf.fit(X[train], y[train]) + seconds = time.perf_counter() - start + rule_list = clf.rule_base.get_rules() + rows[arm].append(dict( + score=f1_score(y[test], clf.predict(X[test]), average='macro'), rules=len(rule_list), + conditions=mean_conditions(rule_list), seconds=seconds, + ordinal_rules=int(sum(any(c.operator != 'is' for c in r.conditions(clf.rule_base.antecedents)) + for r in rule_list)))) + progress(f'{name}/{inference} seed={seed} fold={fold}') + return rows + + +def run_regression(name, data, args, progress): + X, y = data + rows = {arm: [] for arm in ARMS} + for seed in range(args.seeds): + for fold, (train, test) in enumerate(KFold(args.folds, shuffle=True, random_state=seed).split(X)): + variables = utils.construct_partitions(X[train], n_partitions=args.terms, detect_categorical=False) + for arm, (ordinal, factor) in ARMS.items(): + reg = BaseFuzzyRulesRegressor(nRules=args.rules, nAnts=args.ants, linguistic_variables=variables, + ordinal_conditions=ordinal) + start = time.perf_counter() + reg.fit(X[train], y[train], n_gen=args.gens * factor, pop_size=args.pop, random_state=seed) + seconds = time.perf_counter() - start + rule_list = reg.get_rulebase().get_rules() + rows[arm].append(dict( + score=r2_score(y[test], reg.predict(X[test])), rules=len(rule_list), + conditions=mean_conditions(rule_list), seconds=seconds, + ordinal_rules=int(sum(any(c.operator != 'is' for c in r.conditions(reg.rule_base.antecedents)) + for r in rule_list)))) + progress(f'{name} seed={seed} fold={fold}') + return rows + + +def summarize(rows): + out = {} + for arm, runs in rows.items(): + out[arm] = {key: (statistics.mean(r[key] for r in runs), statistics.pstdev(r[key] for r in runs)) + for key in ('score', 'rules', 'conditions', 'seconds', 'ordinal_rules')} + return out + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--folds', type=int, default=5) + parser.add_argument('--seeds', type=int, default=2) + parser.add_argument('--terms', type=int, default=4) + parser.add_argument('--rules', type=int, default=20) + parser.add_argument('--ants', type=int, default=3) + parser.add_argument('--gens', type=int, default=60) + parser.add_argument('--pop', type=int, default=50) + parser.add_argument('--mixture-gens', type=int, default=20, help='generations of the (slow) mixture search arm') + parser.add_argument('--mixture-seeds', type=int, default=1, help='seeds of the (slow) mixture search arm') + parser.add_argument('--out', default=None) + args = parser.parse_args() + warnings.simplefilter('ignore') + + classification = {'iris': load_iris(return_X_y=True), 'wine': load_wine(return_X_y=True), + 'breast_cancer': load_breast_cancer(return_X_y=True)} + jobs = [('classification', name, 'winning_rule') for name in classification] + jobs += [('classification', 'iris', 'mixture'), ('classification', 'wine', 'mixture'), ('regression', 'diabetes', None)] + total = sum(args.folds * (args.mixture_seeds if inference == 'mixture' else args.seeds) for _, _, inference in jobs) + done = 0 + + def progress(message): + nonlocal done + done += 1 + print(f'[{done}/{total}] {message}', flush=True) + + results = {} + for kind, name, inference in jobs: + if kind == 'regression': + results[name] = summarize(run_regression(name, load_diabetes(return_X_y=True), args, progress)) + else: + local = argparse.Namespace(**vars(args)) + if inference == 'mixture': + local.gens, local.seeds = args.mixture_gens, args.mixture_seeds + results[f'{name}/{inference}'] = summarize(run_classification(name, classification[name], local, inference, progress)) + print(json.dumps(results, indent=1), flush=True) + if args.out: + with open(args.out, 'w') as handle: + json.dump(results, handle, indent=1) + + +if __name__ == '__main__': + main() diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 88781b2a..0f0bfc00 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,27 @@ and this project adheres to `Semantic Versioning Optional[np.ndarray]: if not isinstance(feature, (list, tuple, np.ndarray)): return None values = np.asarray(feature) - if values.dtype.kind not in 'biuf' or values.shape != (len(variable), n_samples): + if values.dtype.kind not in 'biuf' or values.shape != (condition_count(variable), n_samples): return None rows.append(values) table = np.empty((sum(len(values) for values in rows) + 1, n_samples)) @@ -206,6 +207,8 @@ def build(cls, problem, device) -> Optional['TorchObjective']: n_samples, n_features = X.shape if problem.lvs is None: counts = np.asarray(problem.n_lv_possible, dtype=np.int64) + if any(getattr(problem, 'ordinal_variables', ())): + return None # The device builds the plain trapezoids only; ordinal conditions stay on the CPU. categorical = problem.categorical_boolean_mask # Categorical variables keep their crisp partitions, and a # single-term partition decodes genes beyond its own; both, and @@ -222,7 +225,7 @@ def build(cls, problem, device) -> Optional['TorchObjective']: table = _membership_rows(problem._precomputed_truth, problem.lvs, n_samples) if table is None: return None - counts = np.asarray([len(variable) for variable in problem.lvs], dtype=np.int64) + counts = np.asarray([condition_count(variable) for variable in problem.lvs], dtype=np.int64) columns = None n_rules = problem.nRules diff --git a/ex_fuzzy/bootstrapping_test.py b/ex_fuzzy/bootstrapping_test.py index cdd2e9b4..3fa67a38 100644 --- a/ex_fuzzy/bootstrapping_test.py +++ b/ex_fuzzy/bootstrapping_test.py @@ -120,7 +120,11 @@ def membership_randomrule(rule, X: np.array, fuzzy_variables: list[fs.fuzzyVaria for i in range(len(rule[0])): if rule[0][i] != -1: - membership = membership * fuzzy_variables[i][rule[0][i]].membership(X[:, i]) + if getattr(fuzzy_variables[i], 'ordinal', False) and rule[0][i] >= len(fuzzy_variables[i]): + # An at-least / at-most condition: the bounded sum of the terms it covers. + membership = membership * fuzzy_variables[i].compute_memberships(X[:, i])[rule[0][i]] + else: + membership = membership * fuzzy_variables[i][rule[0][i]].membership(X[:, i]) return membership diff --git a/ex_fuzzy/evolutionary_fit.py b/ex_fuzzy/evolutionary_fit.py index a720c357..ffd7cf91 100644 --- a/ex_fuzzy/evolutionary_fit.py +++ b/ex_fuzzy/evolutionary_fit.py @@ -138,6 +138,8 @@ class BaseFuzzyRulesClassifier(ClassifierMixin, BaseEstimator): mixture_pruning = 'winning_rule' mixture_prune_threshold = rule_mixture.MIXTURE_PRUNE_THRESHOLD mixture_ = None + # Classifiers pickled before ordinal conditions existed searched single terms only. + ordinal_conditions = False def __init__(self, nRules: int = 30, nAnts: int = 4, fuzzy_type: fs.FUZZY_SETS = fs.FUZZY_SETS.t1, tolerance: float = 0.0, class_names: list[str] = None, n_linguistic_variables: Union[list, int] = 3, verbose=False, linguistic_variables: list[fs.fuzzyVariable] = None, categorical_mask: list[int] = None, @@ -148,7 +150,8 @@ def __init__(self, nRules: int = 30, nAnts: int = 4, fuzzy_type: fs.FUZZY_SETS batch_size: Optional[int] = None, initial_data_fraction: Optional[float] = None, full_data_at: float = 0.5, inference: str = 'winning_rule', mixture_C: float = 1.0, mixture_non_negative: bool = False, mixture_search: bool = False, mixture_pruning: str = 'mixture', - mixture_prune_threshold: float = rule_mixture.MIXTURE_PRUNE_THRESHOLD) -> None: + mixture_prune_threshold: float = rule_mixture.MIXTURE_PRUNE_THRESHOLD, + ordinal_conditions: bool = False) -> None: """ Inits the optimizer with the corresponding parameters. @@ -225,6 +228,15 @@ class c with strength w"; if False (default) the weights are unconstrained and r mixture_prune_threshold: largest centred contribution, in log-odds, at which ``mixture_pruning='mixture'`` still deletes a rule (default 0.02; 0 deletes only the rules without any effect: those that never fire, repeat the antecedents of an earlier rule, or got equal weights for every class). + ordinal_conditions: if True, a rule may also state ``x IS at least t`` or ``x IS at most t`` for the + terms ``t`` of a numerical variable other than its lowest and highest, besides a single term. The + terms of a variable are ordered as listed, low to high. The condition covers ``t`` and every term + above (or below) it and has the bounded-sum membership ``min(1, sum of their memberships)``, which + on a strong partition is the shouldered trapezoid of that range. The term gene of the search + selects among more options (a variable with n terms offers 3n - 4), so the search space grows. + Categorical variables keep single terms. Type-1 and interval Type-2 sets, not combined with + ``candidate_rules``. Off by default: encoding, rules, printing and fits are then unchanged + (``docs/source/ordinal_conditions.rst``). """ rules.resolve_ds_mode(ds_mode) # Reject unknown modes early; fit resolves the code. rule_mixture.check_inference(inference, mixture_C, mixture_search, mixture_pruning, mixture_prune_threshold) @@ -247,6 +259,7 @@ class c with strength w"; if False (default) the weights are unconstrained and r self.mixture_search = mixture_search self.mixture_pruning = mixture_pruning self.mixture_prune_threshold = mixture_prune_threshold + self.ordinal_conditions = ordinal_conditions # Every constructor argument is kept under its own name, as # scikit-learn's get_params, clone and repr require. The attributes # below them are the derived state the rest of the library reads. @@ -406,6 +419,9 @@ def fit(self, X: np.array, y: np.array, n_gen: int = CONSTRUCTOR, pop_size: int raise ValueError('initial_data_fraction needs the pymoo backend.') if initial_data_fraction is not None and candidate_rules is not None: raise ValueError('initial_data_fraction cannot be combined with candidate_rules.') + if self.ordinal_conditions and candidate_rules is not None: + raise ValueError('ordinal_conditions searches the conditions of the rules; it cannot be combined with ' + 'candidate_rules, which only chooses among given rules.') rule_mixture.check_inference(self.inference, self.mixture_C, self.mixture_search, self.mixture_pruning, self.mixture_prune_threshold) if self.mixture_search: @@ -445,6 +461,7 @@ def fit(self, X: np.array, y: np.array, n_gen: int = CONSTRUCTOR, pop_size: int # The search works on consequent indexes; predict maps them back. y = self._encode_labels(y) + ordinal = {'ordinal_conditions': True} if self.ordinal_conditions else {} # passed only when on # mixture_search scores every candidate as a mixture of rules; the problem is otherwise the same. problem_class = _MixtureSearchProblem if self.mixture_search else FitRuleBase @@ -474,14 +491,14 @@ def fit(self, X: np.array, y: np.array, n_gen: int = CONSTRUCTOR, pop_size: int n_linguistic_variables=self.n_linguist_variables, fuzzy_type=self.fuzzy_type, domain=self.domain, thread_runner=self.thread_runner, alpha=self.alpha_, beta=self.beta_, ds_mode=ds_mode, categorical_mask=categorical_mask, allow_unknown=self.allow_unknown, backend_name=self.backend.name(), var_names=lvs_names, - pareto=pareto, batch_size=batch_size) + pareto=pareto, batch_size=batch_size, **ordinal) else: # If Fuzzy variables are already precomputed. problem = problem_class(X, y, nRules=self.nRules, nAnts=self.nAnts, n_classes=len(np.unique(y)), linguistic_variables=self.lvs, domain=self.domain, tolerance=self.tolerance, thread_runner=self.thread_runner, alpha=self.alpha_, beta=self.beta_, ds_mode=ds_mode, allow_unknown=self.allow_unknown, backend_name=self.backend.name(), var_names=lvs_names, - pareto=pareto, batch_size=batch_size) + pareto=pareto, batch_size=batch_size, **ordinal) else: self.fuzzy_type = candidate_rules.fuzzy_type() self.n_linguist_variables = candidate_rules.n_linguistic_variables() @@ -615,7 +632,9 @@ def handle_checkpoint(gen: int, best_individual: np.array): searched_partitions = self.lvs is None self.rule_base, self.eval_performance = self._finalized_rule_base(problem, best_individual, X, y) - self.lvs = self.rule_base.rule_bases[0].antecedents if searched_partitions else self.lvs + # With ordinal conditions the rules refer to the variables of the fit (copies that offer them), which + # the p-value and bootstrapping tools read from ``lvs``. + self.lvs = self.rule_base.rule_bases[0].antecedents if (searched_partitions or self.ordinal_conditions) else self.lvs if p_value_compute: self.p_value_validation(bootstrap_size) @@ -1207,7 +1226,8 @@ class FitRuleBase(Problem): Supports type 1 and iv fs (iv-type 2) """ - def _init_optimize_vl(self, fuzzy_type: fs.FUZZY_SETS, n_linguist_variables: int, domain: list[(float, float)] = None, categorical_variables: list[int] = None, X=None): + def _init_optimize_vl(self, fuzzy_type: fs.FUZZY_SETS, n_linguist_variables: int, domain: list[(float, float)] = None, categorical_variables: list[int] = None, X=None, + ordinal_conditions: bool = False): """ Inits the corresponding fields if no linguistic partitions were given. @@ -1215,6 +1235,7 @@ def _init_optimize_vl(self, fuzzy_type: fs.FUZZY_SETS, n_linguist_variables: int fuzzy_type: FUZZY_SET enum type in fuzzy_sets module. The kind of fuzzy set used. n_linguistic_variables: number of linguistic variables per antecedent. domain: list of the limits for each variable. If None (default) the classifier will compute them empirically. + ordinal_conditions: if True, every numerical variable also offers ``at least`` / ``at most`` conditions. """ from . import utils @@ -1239,21 +1260,31 @@ def _init_optimize_vl(self, fuzzy_type: fs.FUZZY_SETS, n_linguist_variables: int else: self.n_lv_possible.append(n_linguist_variables[ix]) + # Term codes a gene can select per variable: the terms, and with ordinal conditions the extra + # ``at least`` / ``at most`` ones of the numerical variables (categories are not ordered). + self.ordinal_variables = [bool(ordinal_conditions) and self.categorical_mask[ix] <= 0 and self.n_lv_possible[ix] >= 3 + for ix in range(len(self.n_lv_possible))] + self.n_term_codes = [fs.ordinal_alphabet_size(n) if ordinal else n + for n, ordinal in zip(self.n_lv_possible, self.ordinal_variables)] - def _init_precomputed_vl(self, linguist_variables: list[fs.fuzzyVariable], X: np.array): + + def _init_precomputed_vl(self, linguist_variables: list[fs.fuzzyVariable], X: np.array, ordinal_conditions: bool = False): """ Inits the corresponding fields if linguistic partitions for each variable are given. Args: linguistic_variables: list of fuzzyVariables type. X: np array samples x features. + ordinal_conditions: if True, the search works on copies of the variables that offer ``at least`` / + ``at most`` conditions (the given variables are not changed). """ - self.lvs = linguist_variables + self.lvs = fs.enable_ordinal_conditions(linguist_variables) if ordinal_conditions else linguist_variables self.vl_names = [lv.linguistic_variable_names() for lv in self.lvs] self.n_lv_possible = [len(lv.linguistic_variable_names()) for lv in self.lvs] + self.n_term_codes = [fs.condition_count(lv) for lv in self.lvs] self.fuzzy_type = self.lvs[0].fs_type self.domain = None - self._precomputed_truth = rules.compute_antecedents_memberships(linguist_variables, X) + self._precomputed_truth = rules.compute_antecedents_memberships(self.lvs, X) vl_names = [ # Linguistic variable names prenamed for some specific cases. [], @@ -1267,7 +1298,7 @@ def _init_precomputed_vl(self, linguist_variables: list[fs.fuzzyVariable], X: np def __init__(self, X: np.array, y: np.array, nRules: int, nAnts: int, n_classes: int, thread_runner: Optional[Any]=None, linguistic_variables:list[fs.fuzzyVariable]=None, n_linguistic_variables:int=3, fuzzy_type=fs.FUZZY_SETS.t1, domain:list=None, categorical_mask: np.array=None, tolerance:float=0.01, alpha:float=DEFAULT_COMPACTNESS_WEIGHT, beta:float=DEFAULT_COVERAGE_WEIGHT, ds_mode: int =0, allow_unknown:bool=False, backend_name:str='pymoo', var_names:list=None, pareto: bool = False, - batch_size: Optional[int] = None) -> None: + batch_size: Optional[int] = None, ordinal_conditions: bool = False) -> None: """ Cosntructor method. Initializes the classifier with the number of antecedents, linguist variables and the kind of fuzzy set desired. @@ -1292,6 +1323,10 @@ def __init__(self, X: np.array, y: np.array, nRules: int, nAnts: int, n_classes: batch_size: None (default) to compute each candidate's firing strengths for all the samples at once, or a number of samples to compute them for at a time. Every candidate is still scored on all the samples and gets the same score; only memory and time change. Type-1 and interval Type-2 sets. + ordinal_conditions: if True, a rule may also say ``x IS at least t`` or ``x IS at most t`` for the + terms ``t`` of a numerical variable other than its lowest and highest, besides a single term. The + term gene then selects among more options (see ``n_term_codes``); categorical variables keep + single terms. Type-1 and interval Type-2 sets. Off by default: the encoding is unchanged. """ if var_names is not None: self.var_names = var_names @@ -1325,12 +1360,13 @@ def __init__(self, X: np.array, y: np.array, nRules: int, nAnts: int, n_classes: categorical_mask = self.categorical_mask if linguistic_variables is not None: - self._init_precomputed_vl(linguistic_variables, X) + self._init_precomputed_vl(linguistic_variables, X, ordinal_conditions) else: if isinstance(n_linguistic_variables, int): n_linguistic_variables = [n_linguistic_variables] * self.X.shape[1] self._init_optimize_vl( - fuzzy_type=fuzzy_type, n_linguist_variables=n_linguistic_variables, categorical_variables=categorical_mask, domain=domain, X=X) + fuzzy_type=fuzzy_type, n_linguist_variables=n_linguistic_variables, categorical_variables=categorical_mask, domain=domain, X=X, + ordinal_conditions=ordinal_conditions) self.batch_size = batch_size if batch_size is not None: @@ -1346,8 +1382,10 @@ def __init__(self, X: np.array, y: np.array, nRules: int, nAnts: int, n_classes: # smaller variable more likely under uniform sampling. Bounding slot j by # feature j's count would stop later, larger variables from ever using # their extra terms. Equal term counts give the same bounds and the same - # decoding as before. - max_terms = max(self.n_lv_possible) + # decoding as before. With ordinal conditions the gene selects among + # ``n_term_codes`` (terms, then ``at least`` / ``at most`` conditions) in + # the same way; without them the codes are the terms. + max_terms = max(self.n_term_codes) vl_antecedent_bounds = np.array( [[-1, max_terms - 1] for ax in range(self.nAnts)] * self.nRules) # -1 means not caring antecedent_bounds = np.concatenate( @@ -1650,7 +1688,7 @@ def encode_rulebase(self, rule_base: rules.MasterRuleBase, optimize_lv: bool) -> raise ValueError('Encoding a rule base needs one antecedent slot per feature (nAnts equal to the number of features).') gene = np.zeros((self.single_gen_size,)) - max_terms = max(self.n_lv_possible) + max_terms = max(self.n_term_codes) rule_consequents = rule_base.get_consequents() nreal_rules = len(rule_consequents) @@ -1671,7 +1709,7 @@ def encode_rulebase(self, rule_base: rules.MasterRuleBase, optimize_lv: bool) -> for ax, linguistic_variable in enumerate(rule.antecedents): gene[first_pointer + ax] = ax # Inverse of the decoder's proportional term map. - gene[second_pointer + ax] = term_gene(linguistic_variable, self.n_lv_possible[ax], max_terms) + gene[second_pointer + ax] = term_gene(linguistic_variable, self.n_term_codes[ax], max_terms) # Update the fourth section of the gene: consequents using the fourth pointer gene[fourth_pointer + i0] = rule_consequents[i0] @@ -1769,6 +1807,8 @@ def _decode_antecedents(self, x: np.array, fuzzy_type: fs.FUZZY_SETS) -> list: linguistic_variables.append(proper_FS) linguistic_variable = fs.fuzzyVariable(self.var_names[fuzzy_variable], linguistic_variables) + if self.ordinal_variables[fuzzy_variable]: + linguistic_variable.ordinal = True # decoded partitions are sorted: no order check needed antecedents.append(linguistic_variable) else: @@ -1822,7 +1862,7 @@ def _construct_ruleBase(self, x: np.array, fuzzy_type: fs.FUZZY_SETS) -> rules.M except AttributeError: x = x.astype(int) - max_terms = max(self.n_lv_possible) + max_terms = max(self.n_term_codes) for i0 in range(self.nRules): # Reconstruct the rules first_pointer = i0 * self.nAnts chosen_ants = x[first_pointer:first_pointer + self.nAnts] @@ -1834,10 +1874,7 @@ def _construct_ruleBase(self, x: np.array, fuzzy_type: fs.FUZZY_SETS) -> rules.M init_rule_antecedents = np.zeros( (self.X.shape[1],)) - 1 # -1 is dont care for jx, ant in enumerate(chosen_ants): - if self.lvs is not None: - antecedent_parameters[jx] = remap_term(antecedent_parameters[jx], len(self.lvs[ant]), max_terms) - else: - antecedent_parameters[jx] = remap_term(antecedent_parameters[jx], self.n_lv_possible[ant], max_terms) + antecedent_parameters[jx] = remap_term(antecedent_parameters[jx], self.n_term_codes[ant], max_terms) init_rule_antecedents[ant] = antecedent_parameters[jx] @@ -1969,7 +2006,7 @@ def _population_scores(self, X: np.ndarray, *args, **kwargs) -> Optional[np.ndar if values.ndim != 2 or values.dtype.kind not in 'biuf': return None integer_values = values.astype(int) - term_counts = np.asarray([len(lv) for lv in self.lvs]) + term_counts = np.asarray(self.n_term_codes) return popfit.score_population( integer_values, packed, np.asarray(self.y), self.nRules, self.nAnts, self.X.shape[1], self.n_classes, term_counts, @@ -2347,9 +2384,9 @@ def _array_score(self, x: np.array, **kwargs) -> Optional[float]: from . import _array_fitness as arrfit if self.lvs is None: - term_counts = np.asarray(self.n_lv_possible) + term_counts = np.asarray(self.n_term_codes) elif isinstance(self.lvs, (list, tuple)): - term_counts = np.asarray([len(lv) for lv in self.lvs]) + term_counts = np.asarray(self.n_term_codes) else: return None # Other partition containers keep the object decoder. try: diff --git a/ex_fuzzy/evolutionary_fit_regression.py b/ex_fuzzy/evolutionary_fit_regression.py index e4e35b67..f2ac248b 100644 --- a/ex_fuzzy/evolutionary_fit_regression.py +++ b/ex_fuzzy/evolutionary_fit_regression.py @@ -152,7 +152,7 @@ def __init__( f"Rule {ix} has {len(rule.antecedents)} antecedents; expected {n_features}." ) for feature, term in enumerate(rule.antecedents): - if term < -1 or term >= len(self.antecedents[feature].linguistic_variable_names()): + if term < -1 or term >= fs.condition_count(self.antecedents[feature]): raise ValueError(f"Rule {ix} contains invalid term index {term} for feature {feature}.") def fuzzy_type(self) -> fs.FUZZY_SETS: @@ -197,7 +197,7 @@ def compute_rule_antecedent_memberships( def _antecedent_string(self, rule: rules.RuleSimple) -> str: """Render the IF part of a rule.""" return " AND ".join( - f"{variable.name} IS {variable.linguistic_variable_names()[rule[feature]]}" + f"{variable.name} IS {fs.condition_label(variable, rule[feature])}" for feature, variable in enumerate(self.antecedents) if rule[feature] >= 0 ) @@ -425,6 +425,11 @@ class FitRuleBaseRegression(Problem): fitted rule base all see each antecedent set once (the first occurrence keeps its consequent). Off by default, which leaves every objective value unchanged. + + With ``ordinal_conditions`` the term genes also select ``at least t_j`` and + ``at most t_j`` conditions (``j = 1 .. n - 2`` of a variable with ``n`` + terms), coded after the terms (see :func:`fuzzy_sets.ordinal_alphabet_size`), + over copies of the given variables. Off by default, which changes nothing. """ def __init__( @@ -443,6 +448,7 @@ def __init__( rule_mode: str = "additive", tolerance: float = 0.0, unique_antecedents: bool = False, + ordinal_conditions: bool = False, ) -> None: """Initialize the vectorized regression optimization problem.""" self.X = _as_2d_float_array(X) @@ -469,11 +475,12 @@ def __init__( self.n_output_lvs = int(n_output_lvs) self.n_universe_points = int(n_universe_points) - self.lvs = linguistic_variables + self.lvs = fs.enable_ordinal_conditions(linguistic_variables) if ordinal_conditions else linguistic_variables self.nRules = int(nRules) self.nAnts = int(nAnts) + # Term codes per feature: the terms, and with ordinal conditions the extra conditions after them. self.n_lv = np.array( - [len(variable.linguistic_variable_names()) for variable in self.lvs], dtype=int + [fs.condition_count(variable) for variable in self.lvs], dtype=int ) self.y_min = float(np.min(self.y)) if y_min is None else float(y_min) self.y_max = float(np.max(self.y)) if y_max is None else float(y_max) @@ -996,12 +1003,26 @@ class BaseFuzzyRulesRegressor(RegressorMixin, BaseEstimator): the objective and absent from the fitted rule base, so every rule slot left to the search describes a distinct antecedent set. The default leaves the objective, and so seeded fits, unchanged. + + ``ordinal_conditions`` (default ``False``) lets a rule also state ``x IS at + least t`` or ``x IS at most t`` for the terms ``t`` of an input other than its + lowest and highest, the terms being ordered as listed, low to high. Such a + condition covers ``t`` and every term above (below) it, with the bounded-sum + membership ``min(1, sum of their memberships)``. The term genes select among + ``3n - 4`` conditions for a variable of ``n`` terms, so the search space is + larger; the fitted rule base prints ``IS at least t`` / ``IS at most t`` and + ``rules.rule_conditions`` reads the operator. Off, the encoding, rules and + fits are unchanged. + ``n_linguistic_variables`` is the number of terms of each input partition built from the data. It is ignored when ``linguistic_variables`` is given: the term counts are then read from those variables, and they may differ between features. """ + # Regressors pickled before ordinal conditions existed searched single terms only. + ordinal_conditions = False + def __init__( self, nRules: int = 30, @@ -1017,6 +1038,7 @@ def __init__( verbose: bool = False, backend: str = "pymoo", unique_antecedents: bool = False, + ordinal_conditions: bool = False, ) -> None: """Configure the regressor; optimization is performed in :meth:`fit`.""" self.nRules = nRules @@ -1032,6 +1054,7 @@ def __init__( self.verbose = verbose self.backend = backend self.unique_antecedents = unique_antecedents + self.ordinal_conditions = ordinal_conditions def _validate_parameters(self) -> None: """Validate estimator hyperparameters without mutating them.""" @@ -1050,6 +1073,8 @@ def _validate_parameters(self) -> None: raise ValueError("consequent_type must be either 'crisp' or 'fuzzy'.") _validate_rule_mode(self.rule_mode, self.tolerance) _validate_unique_antecedents(self.unique_antecedents) + if not isinstance(self.ordinal_conditions, (bool, np.bool_)): + raise ValueError("ordinal_conditions must be True or False.") if self.consequent_type == "fuzzy": if not isinstance(self.n_output_lvs, (int, np.integer)) or self.n_output_lvs < 2: raise ValueError("n_output_lvs must be an integer of at least two.") @@ -1122,6 +1147,7 @@ def fit( rule_mode=self.rule_mode, tolerance=float(self.tolerance), unique_antecedents=self.unique_antecedents, + **({"ordinal_conditions": True} if self.ordinal_conditions else {}), ) backend = ev_backends.get_backend(self.backend) diff --git a/ex_fuzzy/fuzzy_sets.py b/ex_fuzzy/fuzzy_sets.py index eaae20fc..b7632020 100644 --- a/ex_fuzzy/fuzzy_sets.py +++ b/ex_fuzzy/fuzzy_sets.py @@ -17,6 +17,7 @@ logic applications and modern machine learning workflows. """ import enum +import warnings from typing import Generator import numpy as np @@ -799,6 +800,129 @@ def shape(self) -> str: return 'gaussian' +#: Operators of a condition on a linguistic variable (see :class:`fuzzyVariable`): ``is`` one term, or +#: ``at_least`` / ``at_most`` a term of the ordered partition. +CONDITION_OPERATORS = ('is', 'at_least', 'at_most') +_OPERATOR_TEXT = {'is': '', 'at_least': 'at least ', 'at_most': 'at most '} + + +def ordinal_alphabet_size(n_terms: int) -> int: + """ + Number of conditions a variable with ``n_terms`` ordered terms offers when ordinal conditions are on. + + The ``n_terms`` single terms, then ``at least t_j`` and ``at most t_j`` for ``j = 1 .. n_terms - 2``: + ``at least t_0`` is always true (don't care) and ``at least t_(n-1)`` is ``t_(n-1)`` itself, and the same + holds for ``at most``, so those are not offered. A variable with fewer than 3 terms keeps its ``n_terms``. + """ + return n_terms + 2 * (n_terms - 2) if n_terms >= 3 else n_terms + + +def decode_ordinal_condition(code: int, n_terms: int) -> tuple: + """ + ``(operator, term_index)`` of a condition code of a variable with ``n_terms`` terms. + + Codes ``0 .. n_terms - 1`` are the single terms (``'is'``); the next ``n_terms - 2`` are ``'at_least'`` + terms ``1 .. n_terms - 2``; the last ``n_terms - 2`` are ``'at_most'`` terms ``1 .. n_terms - 2``. + """ + if not 0 <= code < ordinal_alphabet_size(n_terms): + raise ValueError(f'Condition code {code} is outside 0..{ordinal_alphabet_size(n_terms) - 1} ' + f'for a variable with {n_terms} terms.') + if code < n_terms: + return 'is', int(code) + extra = code - n_terms + if extra < n_terms - 2: + return 'at_least', int(extra + 1) + return 'at_most', int(extra - (n_terms - 2) + 1) + + +def encode_ordinal_condition(operator: str, term: int, n_terms: int) -> int: + """Inverse of :func:`decode_ordinal_condition`.""" + if operator not in CONDITION_OPERATORS: + raise ValueError(f'Unknown operator {operator!r}; use one of {list(CONDITION_OPERATORS)}.') + if operator == 'is': + if not 0 <= term < n_terms: + raise ValueError(f'Term {term} is outside 0..{n_terms - 1}.') + return int(term) + if not 1 <= term <= n_terms - 2: + raise ValueError(f"'{operator}' is offered for the terms 1..{n_terms - 2} of a variable with {n_terms} " + f'terms; got {term}.') + return int(n_terms + (term - 1) + (0 if operator == 'at_least' else n_terms - 2)) + + +def ordinal_memberships(terms: np.ndarray) -> np.ndarray: + """ + Memberships of every condition of an ordinal variable, from the memberships of its terms. + + Args: + terms: ``(n_terms, ...)`` memberships of the ordered terms; trailing axes (samples, and the + lower/upper axis of interval Type-2 sets) are carried along. + + Returns: + ``(ordinal_alphabet_size(n_terms), ...)``: the terms, then ``at least t_j`` and ``at most t_j`` for + ``j = 1 .. n_terms - 2``. A union of terms has the bounded sum ``min(1, sum of their memberships)`` + (the Lukasiewicz t-conorm). On a strong partition (memberships adding up to 1) this is the exact + complement of the other terms, a shouldered trapezoid; on a partition whose memberships add up to + more than 1 it saturates at 1; for interval Type-2 sets the lower and upper bounds are summed apart. + """ + n_terms = terms.shape[0] + if n_terms < 3: + return terms + at_most = np.minimum(1.0, np.cumsum(terms, axis=0)[1:n_terms - 1]) + at_least = np.minimum(1.0, np.cumsum(terms[::-1], axis=0)[::-1][1:n_terms - 1]) + return np.concatenate([terms, at_least, at_most], axis=0) + + +def condition_count(variable) -> int: + """Number of conditions (term codes) a variable offers: its terms, plus the ordinal ones if it has them.""" + if getattr(variable, 'ordinal', False): + return variable.n_conditions() + names = getattr(variable, 'linguistic_variable_names', None) # also for variable-like objects without __len__ + return len(names()) if names is not None else len(variable) + + +def condition_label(variable, code: int) -> str: + """Text of condition ``code`` of ``variable``: the term name, ``at least `` or ``at most ``.""" + if getattr(variable, 'ordinal', False): + return variable.condition_names()[int(code)] + return variable.linguistic_variable_names()[code] + + +def _term_position(fuzzy_set): + """Where a Type-1 term sits on the axis (centre of its core or mean), or None if unknown.""" + parameters = getattr(fuzzy_set, 'membership_parameters', None) + if parameters is None: + return None + parameters = np.asarray(parameters, dtype=float).ravel() + if isinstance(fuzzy_set, FS) and not isinstance(fuzzy_set, gaussianFS) and len(parameters) == 4: + return float((parameters[1] + parameters[2]) / 2) + if isinstance(fuzzy_set, gaussianFS): + return float(parameters[0]) + return None + + +def enable_ordinal_conditions(variables: list) -> list: + """ + Copies of ``variables`` that offer ``at least`` / ``at most`` conditions, ready for the search. + + Variables that cannot have them are returned as they are: categorical ones (their categories are not + ordered) and those with fewer than 3 terms. A variable that already has them is returned as it is. The + originals are never changed (the copies share their fuzzy sets). + + Raises: + ValueError: for general Type-2 sets, whose memberships are not a number or an interval per term. + """ + out = [] + for variable in variables: + if getattr(variable, 'ordinal', False) or not variable.ordinal_eligible(strict=True): + out.append(variable) + continue + copy = fuzzyVariable(variable.name, variable.linguistic_variables, variable.units) + copy.ordinal = True + copy._warn_if_unordered() + out.append(copy) + return out + + class fuzzyVariable(): """ Fuzzy Variable Container and Management Class. @@ -826,9 +950,19 @@ class fuzzyVariable(): Note: All fuzzy sets in the variable must be of the same type (t1 or t2). + + The terms are ordered as they are listed, from low to high. With ``ordinal_conditions=True`` the + variable also offers the conditions ``at least t_j`` and ``at most t_j`` (``j = 1 .. n - 2``), whose + membership is the bounded sum ``min(1, sum of the memberships of the terms they cover)``; see + :func:`ordinal_alphabet_size`. A condition is named by an integer code: the term index for a single + term, then the ``at least`` codes, then the ``at most`` ones (:meth:`condition`). Off by default, + and then nothing about the variable changes. """ - def __init__(self, name: str, fuzzy_sets: list[FS], units: str = None) -> None: + #: Class default, so that variables pickled before ordinal conditions existed load as they were. + ordinal = False + + def __init__(self, name: str, fuzzy_sets: list[FS], units: str = None, ordinal_conditions: bool = False) -> None: """ Creates a fuzzy variable with the specified fuzzy sets. @@ -836,9 +970,12 @@ def __init__(self, name: str, fuzzy_sets: list[FS], units: str = None) -> None: name (str): Name of the fuzzy variable fuzzy_sets (list[FS]): List of fuzzy sets that comprise the linguistic variables units (str, optional): Units of the fuzzy variable for display purposes + ordinal_conditions (bool, optional): offer ``at least`` / ``at most`` conditions over the ordered + terms (default False). Needs 3 or more Type-1 or interval Type-2 terms that are not categorical. Raises: - ValueError: If fuzzy_sets is empty or contains mixed types + ValueError: If fuzzy_sets is empty or contains mixed types, or ``ordinal_conditions`` is asked + of a variable that cannot have them Example: >>> fs1 = gaussianFS([0, 1], "Low", 10) @@ -854,6 +991,81 @@ def __init__(self, name: str, fuzzy_sets: list[FS], units: str = None) -> None: self.fs_type = self.linguistic_variables[0].type() + if ordinal_conditions: + if not self.ordinal_eligible(strict=True): + raise ValueError('ordinal_conditions needs at least 3 terms that are not categorical ' + f'(variable {name!r}).') + self.ordinal = True + self._warn_if_unordered() + + + def ordinal_eligible(self, strict: bool = False) -> bool: + """ + Whether this variable can offer ordinal conditions: 3 or more ordered, non-categorical Type-1 or + interval Type-2 terms. With ``strict`` a general Type-2 variable raises instead of returning False. + """ + if self.fs_type == FUZZY_SETS.gt2: + if strict: + raise ValueError('Ordinal conditions support Type-1 and interval Type-2 sets, not general Type-2 sets.') + return False + if isinstance(self.linguistic_variables[0], (categoricalFS, categoricalIVFS)): + return False + return len(self.linguistic_variables) >= 3 + + + def _warn_if_unordered(self) -> None: + """Warn when the terms are not listed from low to high, which is what ``at least`` / ``at most`` read.""" + positions = [_term_position(term) for term in self.linguistic_variables] + if None not in positions and any(b < a for a, b in zip(positions, positions[1:])): + warnings.warn(f'The terms of variable {self.name!r} are not listed from low to high; ' + "'at least' and 'at most' follow the listed order.", stacklevel=3) + + + def n_conditions(self) -> int: + """Number of conditions (term codes) the variable offers: its terms, plus the ordinal ones if enabled.""" + n_terms = len(self.linguistic_variables) + return ordinal_alphabet_size(n_terms) if self.ordinal else n_terms + + + def condition(self, code: int) -> tuple: + """``(operator, term_index)`` of a condition code; operator is ``'is'``, ``'at_least'`` or ``'at_most'``.""" + if not self.ordinal: + if not 0 <= code < len(self.linguistic_variables): + raise ValueError(f'Condition code {code} is outside 0..{len(self.linguistic_variables) - 1}.') + return 'is', int(code) + return decode_ordinal_condition(code, len(self.linguistic_variables)) + + + def condition_code(self, operator: str, term: int) -> int: + """Code of the condition ``operator`` + term index (inverse of :meth:`condition`).""" + if not self.ordinal and operator != 'is': + raise ValueError(f"Variable {self.name!r} has no ordinal conditions; cannot encode '{operator}'.") + return encode_ordinal_condition(operator, term, len(self.linguistic_variables)) + + + def condition_terms(self, code: int) -> list: + """Indexes of the terms a condition covers: one for ``is``, ``t_j .. t_(n-1)`` for ``at_least t_j``, + ``t_0 .. t_j`` for ``at_most t_j``.""" + operator, term = self.condition(code) + if operator == 'is': + return [term] + if operator == 'at_least': + return list(range(term, len(self.linguistic_variables))) + return list(range(term + 1)) + + + def condition_names(self) -> list: + """Text of every condition, by code: the term names, then ``at least ``, then ``at most ``. + Without ordinal conditions this is :meth:`linguistic_variable_names`.""" + names = self.linguistic_variable_names() + if not self.ordinal: + return names + out = list(names) + for code in range(len(names), self.n_conditions()): + operator, term = self.condition(code) + out.append(_OPERATOR_TEXT[operator] + str(names[term])) + return out + def append(self, fs: FS) -> None: """ @@ -893,8 +1105,17 @@ def compute_memberships(self, x: np.array) -> list: x: numeric value or array. Computes the membership to each of the FS in the fuzzy variables. Returns: - list of floats. Membership to each of the FS in the fuzzy variables. + list of floats. Membership to each of the FS in the fuzzy variables. With ordinal conditions + there is one more row per extra condition, after the terms (see :func:`ordinal_memberships`). """ + res = self._term_memberships(x) + if self.ordinal: + res = ordinal_memberships(res) + return res + + + def _term_memberships(self, x: np.array) -> np.ndarray: + """Memberships to the terms only, whatever the conditions on offer.""" res = [] # Categorical variables, and sets without a domain, are not clipped. domain = getattr(self.linguistic_variables[0], 'domain', None) @@ -984,7 +1205,7 @@ def validate(self, X, verbose:bool=False) -> bool: return False # Get the fuzzy sets memberships - memberships = self.compute_memberships(X) + memberships = self._term_memberships(X) memberships = np.array(memberships) cond1 = True @@ -1010,7 +1231,7 @@ def validate(self, X, verbose:bool=False) -> bool: # Property 5: The smallest fuzzy set must be the first one and the biggest fuzzy set must be the last one. The smallest should start at the left of the domain and the biggest should end at the right of the domain. - cond5 = (self.compute_memberships(self[0].domain[0])[0] >= 0.99) and (self.compute_memberships(self[0].domain[1])[-1] >= 0.99) + cond5 = (self._term_memberships(self[0].domain[0])[0] >= 0.99) and (self._term_memberships(self[0].domain[1])[-1] >= 0.99) if not cond5 and verbose: print('Property 5 violated: The smallest fuzzy set must be the first one and the biggest fuzzy set must be the last one. The smallest should start at the left of the domain and the biggest should end at the right of the domain.') diff --git a/ex_fuzzy/pattern_stability.py b/ex_fuzzy/pattern_stability.py index 77a4c653..009a245d 100644 --- a/ex_fuzzy/pattern_stability.py +++ b/ex_fuzzy/pattern_stability.py @@ -266,7 +266,7 @@ def _key_order(key: tuple) -> tuple: def _variable_names(mrule_base: rl.MasterRuleBase) -> tuple: names = [str(var.name) for var in mrule_base.antecedents] - term_names = [[str(term) for term in var.linguistic_variable_names()] for var in mrule_base.antecedents] + term_names = [[str(term) for term in var.condition_names()] for var in mrule_base.antecedents] if len(set(names)) != len(names) or any(len(set(terms)) != len(terms) for terms in term_names): raise ValueError('Variable names, and the term names of each variable, must be unique to identify rules.') return names, term_names @@ -563,7 +563,7 @@ def var_reports(self, class_vars: dict, antecedents, cutoff:int=10): print(f'Variable {antecedents[jx].name}') initiated = True - print(f'{antecedents[jx][key].name} appears %.2f times per experiment.' % float(class_vars[jx][key] / self.n)) + print(f'{fs.condition_label(antecedents[jx], key)} appears %.2f times per experiment.' % float(class_vars[jx][key] / self.n)) if initiated: print() @@ -681,7 +681,7 @@ def pie_chart_basic(self, var_ix: int, class_ix: int): for key in var.keys(): if key != -1 and var[key] > 0: - labels.append(antecedents[var_ix][key].name) + labels.append(fs.condition_label(antecedents[var_ix], key)) sizes.append(var[key]) import matplotlib.pyplot as plt @@ -711,7 +711,7 @@ def pie_chart_var(self, var_ix: int): ax1[class_ix].set_title(f'Class {self.classes_names[class_ix]}') for key in var.keys(): if key != -1 and var[key] > 0: - labels.append(antecedents[var_ix][key].name) + labels.append(fs.condition_label(antecedents[var_ix], key)) sizes.append(var[key]) self._plot_usage_pie( @@ -745,7 +745,7 @@ def pie_chart_class(self, class_ix: int, var_list:list[int] = None): ax1[0, plot_ix].set_title(f'Variable {antecedents[var_ix].name}') for key in var.keys(): if key != -1 and var[key] > 0: - labels.append(antecedents[var_ix][key].name) + labels.append(fs.condition_label(antecedents[var_ix], key)) sizes.append(var[key]) self._plot_usage_pie( @@ -799,5 +799,11 @@ def gen_colormap(self, antecedents): from matplotlib import colormaps palette = [colormaps['coolwarm'](a) for a in np.linspace(0, 1, largest_vl_n)] - return dict(zip(label_names, palette)) + colors = dict(zip(label_names, palette)) + for antecedent in antecedents: # an at-least / at-most condition takes the color of the term it refers to + if getattr(antecedent, 'ordinal', False): + for code in range(len(antecedent), antecedent.n_conditions()): + colors.setdefault(antecedent.condition_names()[code], + colors.get(antecedent[antecedent.condition(code)[1]].name, palette[0])) + return colors diff --git a/ex_fuzzy/persistence.py b/ex_fuzzy/persistence.py index b34128f9..980029b6 100644 --- a/ex_fuzzy/persistence.py +++ b/ex_fuzzy/persistence.py @@ -23,6 +23,11 @@ _WITH_SPLIT = re.compile(r'\sWITH\s') _AND_SPLIT = re.compile(r'\sAND\s') _IS_SPLIT = re.compile(r'\sIS\s') +# A condition over ordered terms reads ``IS at least `` or ``IS at most ``. +_ORDINAL_PREFIX = (('at least ', 'at_least'), ('at most ', 'at_most')) +_ORDINAL_TEXT = re.compile(r'\sIS\s+at (?:least|most) ') +# Line after the header of a variable that offers ordinal conditions (written only for those). +_ORDINAL_LINE = 'Ordinal conditions' _IF_PREFIX = re.compile(r'^IF\s+') _THEN_SPLIT = re.compile(r'\sTHEN\s') _STATISTIC = {key: re.compile(rf'(?:^|[\s,]){key}\s+([^\s,]+)') for key in ('DS', 'ACC', 'WGHT')} @@ -37,6 +42,26 @@ def _statistic(text: str, key: str): from . import rules +def _condition_code(variables: list, index: int, value_names: list, text: str) -> int: + """ + Code of the condition ``text`` (````, ``at least `` or ``at most ``) of variable ``index``. + + A variable without ordinal conditions is replaced in ``variables`` by a copy that has them when an + ``at least`` / ``at most`` condition is read, so the rules of a printed model load without its variable + file saying so. + """ + if text in value_names: + return value_names.index(text) + for prefix, operator in _ORDINAL_PREFIX: + if text.startswith(prefix) and text[len(prefix):].strip() in value_names: + variable = variables[index] + if not variable.ordinal: + variable = fs.enable_ordinal_conditions([variable])[0] + variables[index] = variable + return variable.condition_code(operator, value_names.index(text[len(prefix):].strip())) + return value_names.index(text) # unknown value: the ValueError of a missing term + + def _extract_mod_word(text): """ Extract modifier word from a string containing MOD pattern. @@ -148,11 +173,15 @@ def load_fuzzy_rules(rules_printed: str, fuzzy_variables: list) -> rules.MasterR Note: The text format supports: - Multiple rule bases for different consequent classes + - Ordinal conditions ``IS at least term`` / ``IS at most term``; a variable without them is replaced + in the returned rule base by a copy that has them - Rule modifiers using (MOD modifier_name) syntax - Accuracy and weight information in parentheses - Don't care conditions (variables not mentioned in antecedents) """ consequent = 0 + if _ORDINAL_TEXT.search(rules_printed): + fuzzy_variables = list(fuzzy_variables) # a condition may swap a variable for a copy that has them linguistic_variables_names = [linguistic_variable.name for linguistic_variable in fuzzy_variables] value_names = {fuzzy_variables[ix].name : [x.name for x in fuzzy_variables[ix]] for ix in range(len(fuzzy_variables))} # Named modifiers are printed by name, any other exponent by its value. @@ -185,7 +214,8 @@ def load_fuzzy_rules(rules_printed: str, fuzzy_variables: list) -> rules.MasterR antecedent_name = antecedent_name.strip() antecedent_value = antecedent_value.strip() antecedent_index = linguistic_variables_names.index(antecedent_name) - antecedent_value_index = value_names[antecedent_name].index(antecedent_value) + antecedent_value_index = _condition_code( + fuzzy_variables, antecedent_index, value_names[antecedent_name], antecedent_value) init_rule_antecedents[antecedent_index] = antecedent_value_index if modifier_word is not None: @@ -276,14 +306,17 @@ def load_fuzzy_variables(fuzzy_variables_printed: str) -> list: fuzzy_variables = [] active_linguistic_variables = False + ordinal_variable = False lines = fuzzy_variables_printed.splitlines() for line in lines: if line.startswith('$$$') or line.startswith('$Fuzzy') or line.startswith('$Categorical'): #New linguistic or categorical variable if active_linguistic_variables: - fuzzy_variables.append(fs.fuzzyVariable(linguistic_variable_name, linguistic_var_fuzzy_sets, fvar_units)) + fuzzy_variables.append(fs.fuzzyVariable(linguistic_variable_name, linguistic_var_fuzzy_sets, fvar_units, + ordinal_conditions=ordinal_variable)) linguistic_var_fuzzy_sets = [] + ordinal_variable = False header = line.split(':') linguistic_variable_name = header[1].strip() fvar_units = header[2].strip() if len(header) > 2 else None @@ -291,6 +324,8 @@ def load_fuzzy_variables(fuzzy_variables_printed: str) -> list: active_linguistic_variables = True elif line == '': pass + elif line == _ORDINAL_LINE: + ordinal_variable = True else: processes_line = line.split(';') categorical_class = fs.categoricalFS if fuzzy_set_type == fs.FUZZY_SETS.t1 else fs.categoricalIVFS @@ -332,7 +367,8 @@ def load_fuzzy_variables(fuzzy_variables_printed: str) -> list: linguistic_var_fuzzy_sets.append(constructed_fs) if active_linguistic_variables: - object_fvar = fs.fuzzyVariable(linguistic_variable_name, linguistic_var_fuzzy_sets, fvar_units) + object_fvar = fs.fuzzyVariable(linguistic_variable_name, linguistic_var_fuzzy_sets, fvar_units, + ordinal_conditions=ordinal_variable) fuzzy_variables.append(object_fvar) return fuzzy_variables @@ -366,6 +402,7 @@ def print_fuzzy_variable(fuzzy_variable: fs.fuzzyVariable) -> str: The output format varies based on fuzzy set types: - Categorical variables use '$Categorical variable:' prefix - Regular fuzzy variables use '$$$ Linguistic variable:' prefix + - A variable with ordinal conditions has the line ``Ordinal conditions`` after its header - Type-2 fuzzy sets include additional parameters for upper membership functions - Supports trapezoidal, triangular, and gaussian membership functions """ @@ -385,6 +422,8 @@ def print_fuzzy_variable(fuzzy_variable: fs.fuzzyVariable) -> str: if fuzzy_variable.units is not None: fuzzy_variable_printed += ' : ' + fuzzy_variable.units fuzzy_variable_printed += '\n' + if getattr(fuzzy_variable, 'ordinal', False): + fuzzy_variable_printed += _ORDINAL_LINE + '\n' for fuzzy_set in fuzzy_variable.linguistic_variables: if isinstance(fuzzy_set, fs.gaussianIVFS): diff --git a/ex_fuzzy/rule_mixture.py b/ex_fuzzy/rule_mixture.py index 18836b24..800bcf78 100644 --- a/ex_fuzzy/rule_mixture.py +++ b/ex_fuzzy/rule_mixture.py @@ -33,6 +33,7 @@ import numpy as np +from . import fuzzy_sets as fs from . import rules as rl #: Inference modes of ``BaseFuzzyRulesClassifier(inference=...)``. @@ -387,7 +388,7 @@ def antecedent_text(rule: rl.RuleSimple, antecedents: list) -> str: term = rule[index] if term == -1: continue - part = f'{variable.name} IS {variable.linguistic_variable_names()[term]}' + part = f'{variable.name} IS {fs.condition_label(variable, term)}' if modifiers is not None and modifiers[index] != 1: part += f' (MOD {rl.modifiers_names.get(modifiers[index], modifiers[index])})' parts.append(part) diff --git a/ex_fuzzy/rules.py b/ex_fuzzy/rules.py index 7697a57b..a377f2aa 100644 --- a/ex_fuzzy/rules.py +++ b/ex_fuzzy/rules.py @@ -145,7 +145,10 @@ def pack_membership_table_from_variables(antecedents: list, X: np.ndarray, tail: set works: only the packing changes, not the membership functions. """ sets = [variable.linguistic_variables for variable in antecedents] - total = sum(len(group) for group in sets) + # A variable with ordinal conditions has more columns than terms: the conditions after the terms. + ordinal = [getattr(variable, 'ordinal', False) for variable in antecedents] + widths = [fs.condition_count(variable) for variable in antecedents] + total = sum(widths) if not total or len(sets) != X.shape[1]: return None table = np.empty((X.shape[0], total + 1) + tail) @@ -154,6 +157,7 @@ def pack_membership_table_from_variables(antecedents: list, X: np.ndarray, tail: offsets, index = [], 0 for feature, group in enumerate(sets): offsets.append(index) + first = index column = X[:, feature] domain = getattr(group[0], 'domain', None) if domain is not None: # fuzzyVariable.compute_memberships clips the same way @@ -164,9 +168,13 @@ def pack_membership_table_from_variables(antecedents: list, X: np.ndarray, tail: return None table[:, index] = values index += 1 + if ordinal[feature]: + terms = np.moveaxis(table[:, first:index], 1, 0) + table[:, first:first + widths[feature]] = np.moveaxis(fs.ordinal_memberships(terms), 0, 1) + index = first + widths[feature] table[:, -1] = 1. table.flags.writeable = False - return table, np.asarray(offsets), np.asarray([len(group) for group in sets]) + return table, np.asarray(offsets), np.asarray(widths) def _gather_firing_from_arrays(antecedents: np.ndarray, truth, n_samples: int, @@ -393,6 +401,39 @@ def consequent_centroid(self) -> np.array: +#: One condition of a rule, read structurally (no text parsing). +#: +#: ``feature`` is the index of the variable, ``variable`` its name, ``operator`` one of ``'is'``, ``'at_least'`` +#: or ``'at_most'``, ``term`` the name of the term the operator refers to, ``term_index`` its index in the +#: variable, ``terms`` the names of every term the condition covers (low to high) and ``code`` the integer +#: stored in ``RuleSimple.antecedents``. +Condition = namedtuple('Condition', ['feature', 'variable', 'operator', 'term', 'term_index', 'terms', 'code']) + + +def rule_conditions(rule: 'RuleSimple', antecedents: list) -> list: + """ + The conditions of a rule as :data:`Condition` tuples, in variable order, without the don't-cares. + + A downstream tool can render ``IF x IS at least Fair`` from ``operator == 'at_least'`` and + ``term == 'Fair'`` without parsing the printed rule. A rule over variables without ordinal conditions + only has ``'is'`` conditions. + + Args: + rule: the rule. + antecedents: the fuzzy variables of its rule base (``rule_base.antecedents``). + """ + out = [] + for feature, variable in enumerate(antecedents): + code = int(rule[feature]) + if code == -1: + continue + operator, term_index = variable.condition(code) + names = variable.linguistic_variable_names() + out.append(Condition(feature, variable.name, operator, names[term_index], term_index, + [names[ix] for ix in variable.condition_terms(code)], code)) + return out + + class RuleSimple(): """ Simplified Rule Representation for Optimized Computation. @@ -533,6 +574,10 @@ def __str__(self): return aux + def conditions(self, antecedents: list) -> list: + """The rule's conditions as :data:`Condition` tuples (see :func:`rule_conditions`).""" + return rule_conditions(self, antecedents) + def __len__(self): """ Returns the number of antecedents in the rule. @@ -2027,6 +2072,7 @@ def generate_rule_string(rule: RuleSimple, antecedents: list, bootstrap_results: Generates a string with the rule. The text is ``IF IS