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..db4416c2 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,31 @@ 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" +``` + +`ordinal_conditions` also takes a list of features (names or indexes) to give them to some variables only, and +`compound_terms={"local_hour": [("Night", ["Very High", "Low"])]}` adds a named union of terms, which need not be +contiguous (a clock that wraps around midnight): `IF local_hour IS Night`; its `Condition.operator` is `"any_of"`. + ## 📊 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..8d2142e3 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -10,6 +10,37 @@ and this project adheres to `Semantic Versioning ``, whose membership is the bounded sum of the + listed terms, which need not be contiguous (a union that wraps around midnight is two shoulders). Entries are + validated (at least 2 distinct terms, not all of them, no repeated set, no ordinal duplicate, a name that is + unique within its variable), work with or without ordinal conditions through the same codes, rows and + evaluators, print by their name, are read as ``Condition(operator='any_of', terms=..., name=...)`` and + round-trip through the rule and variable text and pickle. Same estimators; refused with ``candidate_rules``. + Off by default and then bit-identical to 3.4.0 + [3.4.0] - 2026-11-09 ==================== diff --git a/docs/source/index.rst b/docs/source/index.rst index a196fbaf..736db21d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -265,6 +265,7 @@ Documentation Contents extending persistence rule_mixture + ordinal_conditions classifiers pattern_stats diff --git a/docs/source/ordinal_conditions.rst b/docs/source/ordinal_conditions.rst new file mode 100644 index 00000000..d1798ad7 --- /dev/null +++ b/docs/source/ordinal_conditions.rst @@ -0,0 +1,233 @@ +=================================== +Ordinal "at least / at most" rules +=================================== + +A condition of a rule is by default one linguistic term: ``IF no2 IS Fair``. When the terms of a variable are +ordered (Low, Medium, High, ...) a rule often means a range: "Fair **or worse**", "Medium **or better**". +``ordinal_conditions=True`` lets the genetic search write such conditions: + +.. code-block:: text + + IF no2_last IS at least Fair AND humidity IS at most Medium WITH DS 0.41, ACC 0.83 + +* ``x IS at least t`` is the union of ``t`` and every term above it (up to the highest term). +* ``x IS at most t`` is the union of every term below ``t`` (from the lowest term) and ``t``. + +The terms of a variable are ordered as they are listed in its partition, from low to high. The partitions the +library builds (``utils.construct_partitions``) are listed in that order; a partition you give is checked and +a ``UserWarning`` says when its terms are not listed from low to high. + +Two further options select where the conditions apply and add **named compound terms** (see below): +``ordinal_conditions`` also takes a collection of features, so that a cyclic variable such as a clock hour does +not get ``at most Medium``, and ``compound_terms`` adds named unions of terms that need not be contiguous. + +The option is **off by default**. Off, nothing changes: the gene encoding, the rules, the printed text, the +saved files and every seeded fit are those of 3.4.0 (a test replays nine fits recorded on that release). + +Usage +===== + +.. code-block:: python + + from sklearn.datasets import load_iris + from ex_fuzzy import BaseFuzzyRulesClassifier + + X, y = load_iris(return_X_y=True) + model = BaseFuzzyRulesClassifier(nRules=15, nAnts=2, n_linguistic_variables=4, ordinal_conditions=True, + n_gen=30, pop_size=50, random_state=0, patience=None) + model.fit(X, y) + model.print_rules() + # Rules for consequent: 2 + # IF 0 IS at least Medium AND 3 IS at least High WITH DS 0.19, ACC 0.96 + + # The operator of every condition, without parsing the text: + for rule in model.rule_base.get_rules(): + for condition in rule.conditions(model.rule_base.antecedents): + print(condition.variable, condition.operator, condition.term, condition.terms) + # ... + # 3 at_most Medium ['Low', 'Medium'] + # 0 at_least Medium ['Medium', 'High', 'Very High'] + # 3 at_least High ['High', 'Very High'] + +The option exists on the three estimators the search runs through: ``BaseFuzzyRulesClassifier`` (winning-rule +and mixture inference, also with ``mixture_search``), ``BaseFuzzyRulesRegressor`` (crisp and fuzzy +consequents) and the problems behind them, ``FitRuleBase`` and ``FitRuleBaseRegression``. It works with +searched partitions and with ``linguistic_variables`` you give (the given variables are not modified: the +search uses copies), and with Type-1 and interval Type-2 sets. It is refused for general Type-2 sets and +together with ``candidate_rules`` (which only chooses among given rules). Categorical variables keep single +terms, since their categories are not ordered; so do variables with fewer than 3 terms. + +Per-variable selection +====================== + +``ordinal_conditions`` accepts ``True`` (every variable that can have them), ``False`` (the default) or a +**collection of features**, by name or by index (a list, tuple, set or array; names are the variable names, or +the column names of a DataFrame): + +.. code-block:: python + + BaseFuzzyRulesClassifier(linguistic_variables=variables, ordinal_conditions=['temp', 'level']) + BaseFuzzyRulesClassifier(n_linguistic_variables=4, ordinal_conditions=[1, 2]) # by index + +The other variables keep single terms (and any compound terms). A feature named explicitly must be able to +have the conditions (3 or more terms, not categorical), otherwise a ``ValueError`` names it; an unknown name +or an index out of range is an error too. A bare string, ``{'a': 1}`` or a number is rejected: pass a list. +With ``True`` the variables that cannot have them are skipped quietly. An empty collection, ``False`` and +``None`` are off, and then the problem is built exactly as in 3.4.0. + +Named compound terms +==================== + +Some variables are not ordinal in the way ``at least`` assumes. The terms of a clock hour are cyclic, so a +useful condition is ``local_hour IS Night`` = the last term **or** the first one. ``compound_terms`` adds such +conditions explicitly: + +.. code-block:: python + + model = BaseFuzzyRulesClassifier( + linguistic_variables=variables, + ordinal_conditions=['temp'], # not the clock + compound_terms={'local_hour': [('Night', ['Very High', 'Low']), # wraps around midnight + ('Daytime', ['Medium', 'High'])]}, + ...) + # IF local_hour IS Night AND temp IS at least Medium WITH DS ..., ACC ... + +* **Shape.** ``compound_terms`` is ``None`` or a mapping ``{feature: [(name, [term, ...]), ...]}``. A feature is + a name or an index (as in ``ordinal_conditions``); each term is a term name or a term index. Every entry adds + one condition ``x IS `` to that variable, whose membership is the bounded sum of the listed terms. The + terms need not be contiguous, so a union that wraps around is allowed. The set is kept sorted low to high + (``[last, first]`` and ``[first, last]`` are the same condition). +* **Validation** (``ValueError`` with the variable and the compound named): at least 2 distinct terms (a term + listed twice is an error); not every term of the variable (it would be a don't care); an unknown term name or + an index out of range; no two compounds of a variable with the same set; no compound with the set of an + ordinal condition the variable has (for example ``[T1, T2, T3]`` with ``at least T1``); the **name must be + unique within its variable**: it cannot be the name of a term, of another compound, or of an ordinal + condition, and it cannot be empty, contain ``;``, line breaks, parentheses or the words ``AND``, ``IS``, + ``WITH``, ``THEN``, nor start with ``at least`` / ``at most`` (the rule text must be readable back). Two + keys for the same feature are an error too. +* **Together or alone.** Compound terms work with or without ``ordinal_conditions`` on the same variable, with + searched partitions (terms by name ``Low``... or index) or ``linguistic_variables``, with Type-1 and interval + Type-2 sets, and also on categorical variables (any set of categories is a valid union). They are refused for + general Type-2 sets and with ``candidate_rules``. A variable needs 3 or more terms (2 of 2 terms is a don't + care). With ordinal conditions on, a contiguous run that touches an end repeats one of them (with 3 terms + ``[T0, T1]`` is ``at most T1``) and is refused; ``[T0, T2]`` is not and is accepted. +* **Membership.** The bounded sum of the listed terms, as for ``at least`` / ``at most``. A union that wraps + around on a linear axis is **two shoulders, one at each end**: for ``[last, first]`` it is 1 at both ends of + the domain, falls over the ramp of the first term, is 0 in the middle and rises over the ramp of the last + term. On a strong partition it equals one minus the memberships of the other terms. Nothing here knows the + axis is cyclic: a value just before and just after midnight are the two ends and both are covered. +* **Codes.** After the single terms and the ordinal conditions: with ``n`` terms, the compounds are + ``n_conditions - len(compound) ... n_conditions - 1``, in the order given (``condition_code('any_of', name)``). + They go through the same machinery (rows of ``compute_memberships``, the gather table, the remap, canonical + codes for duplicates, and the array, ``batch_size``, population and PyTorch objectives; the latter declines + with searched partitions). + +A variable can be built with them directly: ``fuzzyVariable(name, sets, ordinal_conditions=False, +compound_terms=[(name, [terms])...])`` or ``fuzzy_sets.enable_conditions(variables, ordinal_conditions, +compound_terms)``. + +A variable can also offer the conditions on its own, for any code that reads rules: +``fuzzyVariable(name, sets, ordinal_conditions=True)`` or ``fuzzy_sets.enable_ordinal_conditions(variables)``. + +Which conditions exist +====================== + +For a variable with ``n`` ordered terms ``t_0 ... t_(n-1)``: the ``n`` single terms, ``at least t_j`` for +``j = 1 ... n - 2`` and ``at most t_j`` for ``j = 1 ... n - 2``, that is ``3n - 4`` conditions (8 for 4 terms, +5 for 3). ``at least t_0`` is always true (a don't care) and ``at least t_(n-1)`` is ``t_(n-1)``, and likewise +``at most t_(n-1)`` and ``at most t_0``, so they are not offered. General "between" intervals are not offered. +No two conditions cover the same set of terms, so a set has exactly one code. + +Membership +========== + +The membership of a union is the **bounded sum**, the Lukasiewicz t-conorm: + +.. math:: + + \mu_{\text{at least } t_j}(x) = \min\Bigl(1, \sum_{k \ge j} \mu_{t_k}(x)\Bigr), \qquad + \mu_{\text{at most } t_j}(x) = \min\Bigl(1, \sum_{k \le j} \mu_{t_k}(x)\Bigr) + +* **Strong partitions.** On a strong (Ruspini) partition, where the memberships of a point add up to 1, the + sum never exceeds 1 and ``at least t_j`` is exactly ``1 - (memberships of t_0 ... t_(j-1))``: again a + shouldered trapezoid, with the ramp of the term below ``t_j``. The partitions built by + ``utils.construct_partitions`` (trapezoidal) and ``construct_breakpoint_partition`` are strong. The terms of a + partition found by the search (``linguistic_variables=None``) never add up to more than 1 but can add up to + less (a point between two terms may belong to neither fully); the bounded sum is then just the sum, and + ``at least t_j + at most t_(j-1)`` is below 1 there. +* **Partitions that are not strong.** Gaussian terms overlap more than a partition of unity: their sum + exceeds 1 and the bounded sum saturates at 1 there. Nothing else changes. +* **Interval Type-2.** The lower and the upper bound are summed apart (and clipped to 1 each), which is the + interval-arithmetic image of the bounded sum. +* **t-norm.** A condition is one antecedent with a membership in [0, 1]; the rule still combines its antecedents + with its t-norm (the product by default), so the union takes part exactly as a single term would. The bounded + sum is the dual of the Lukasiewicz t-norm, not of the product, so a union is not the De Morgan dual of a + product intersection of complements; it is chosen because it keeps the trapezoids and + ``at least t_j + at most t_(j-1) = 1`` on strong partitions. +* A firing strength is still a degree of match, not a probability, contribution or risk. + +How the search encodes it +========================= + +The term gene is widened, no gene is added. Without the option a term gene of slot ``i`` holds ``-1`` (don't +care) or a value in ``0 ... max_terms - 1``, which the decoder maps onto the terms of whichever variable the +slot's variable gene selected (``_fitness.remap_term``). With the option the gene holds a **condition code** +in ``0 ... max_codes - 1``, and the same proportional map is applied to the code count of the variable +(``FitRuleBase.n_term_codes``; the number of terms when the option is off): + +====================== =============================== +code condition +====================== =============================== +``0 ... n-1`` ``IS t_code`` (single terms) +``n ... 2n-3`` ``IS at least t_j``, ``j = code - n + 1`` +``2n-2 ... 3n-5`` ``IS at most t_j``, ``j = code - (2n-2) + 1`` +====================== =============================== + +``RuleSimple.antecedents`` stores the same code (``-1`` for a don't care), so a rule is identified, compared, +hashed and de-duplicated by its codes as before; because a set of terms has one code, two rules whose +conditions are the same sets are duplicates in the decoders (object, array, population and PyTorch), in +``RuleBase`` and in the mixture. Sampling, crossover and mutation work on the gene bounds, which are the +wider ones, so they need no change. The fitness routes (array, streamed with ``batch_size``, population and the +PyTorch objective on fixed partitions) take the extra conditions as extra columns of the same membership +table and agree with the object decoder bit for bit. With ``linguistic_variables=None`` the PyTorch objective, +which builds trapezoids itself, declines and the CPU scores the fit. + +``fuzzyVariable.condition(code)`` gives ``(operator, term_index)``, ``condition_code(operator, term_index)`` +the inverse, ``condition_terms(code)`` the terms covered and ``condition_names()`` the printed names. +``rules.rule_conditions(rule, antecedents)`` (also ``rule.conditions(antecedents)``) returns ``Condition`` +tuples ``(feature, variable, operator, term, term_index, terms, code, name)`` with ``operator`` one of ``'is'``, +``'at_least'``, ``'at_most'`` and ``'any_of'``. For the first three ``term`` / ``term_index`` are the term the +operator refers to, ``terms`` the terms covered and ``name`` is ``None``; for ``'any_of'`` ``term`` and +``term_index`` are ``None``, ``terms`` lists the constituent term names (low to high) and ``name`` is the given +name of the compound term. ``fuzzyVariable.condition(code)`` returns ``('any_of', position in +variable.compound)`` for it. + +Search space and budget +======================= + +A variable of ``n`` terms offers ``3n - 4`` conditions instead of ``n``: 5 instead of 3, 8 instead of 4. The +gene length is unchanged but the space is larger, and uniform sampling draws a single term in only ``n / (3n - 4)`` +of the cases (60 % for 3 terms, 50 % for 4). A search with the same ``n_gen`` and ``pop_size`` therefore has +less of its budget on single terms; if the comparison with the default must be fair, give the option a larger +budget or compare the best of several seeds (see ``benchmarks/benchmark_ordinal_conditions.py``). Each +condition still counts as one in the compactness penalty and in ``nAnts``. + +Printing, saving and loading +============================ + +``print_rules`` writes ``IS at least Fair`` / ``IS at most Fair`` and ``IS `` for a compound term (also ``print_mixture``, the regression +rules and ``vis_rules.rules_to_latex``, which writes ``$\geq$ Fair`` / ``$\leq$ Fair``). ``persistence.load_fuzzy_rules`` +reads them back: a variable without ordinal conditions in the list you pass is replaced, in the returned rule +base, by a copy that has them; the name of a compound term needs the variables to carry it, so load the +variables first. ``save_fuzzy_variables`` writes the line ``Ordinal conditions`` after the header of a +variable that has them and one line ``Compound;;`` per compound term (variables without +either are written exactly as before), and ``load_fuzzy_variables`` reads them. Pickles keep the option; a classifier, regressor or variable pickled before +it existed loads with it off. ``pattern_stability`` rule keys name the condition (``('x', 'at least Fair')``) +and ``consensus_rule_base`` rebuilds them. + +Not covered +=========== + +``FuzzyRulesClassifier`` (fuzzy association rules), ``RuleMineClassifier``, ``RuleFineTuneClassifier``, the +FERL learners and the null distribution of the bootstrapped rule p-values (random rules over single terms) +do not take the option. diff --git a/ex_fuzzy/_torch_fitness.py b/ex_fuzzy/_torch_fitness.py index 531be37c..490e5f0e 100644 --- a/ex_fuzzy/_torch_fitness.py +++ b/ex_fuzzy/_torch_fitness.py @@ -25,6 +25,7 @@ import numpy as np +from .fuzzy_sets import condition_count from ._fitness import _macro_f1, _penalized_objective from ._population_fitness import _RouteProbe @@ -167,7 +168,7 @@ def _membership_rows(truth, variables, n_samples: int) -> 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 list(problem.n_term_codes) != list(problem.n_lv_possible): + return None # The device builds the plain trapezoids only; extra 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..f1a7493c 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 fs.has_extra_conditions(fuzzy_variables[i]) and rule[0][i] >= len(fuzzy_variables[i]): + # An at-least / at-most / compound 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..d991e448 100644 --- a/ex_fuzzy/evolutionary_fit.py +++ b/ex_fuzzy/evolutionary_fit.py @@ -138,6 +138,9 @@ 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 + compound_terms = None 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 +151,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: Union[bool, list] = False, compound_terms: Optional[dict] = None) -> None: """ Inits the optimizer with the corresponding parameters. @@ -225,6 +229,25 @@ 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. + A collection of features (names or indexes) gives the conditions to those variables only; each + must be able to have them (3 or more terms, not categorical), the others keep single terms. With + True, categorical variables and variables with fewer than 3 terms 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``). + compound_terms: None (default), or a mapping from a feature (name or index) to a list of + ``(name, [terms])`` entries (terms by name or index). Each entry adds one condition to that variable, + ``x IS ``, whose membership is the bounded sum of the listed terms, which need not be + contiguous: ``('Night', ['Night', 'Evening', 'Late'])`` over cyclic hours, or a union that wraps + around such as ``[last, first]`` (two shoulders, one at each end of the axis). Each entry needs at + least 2 distinct terms and not all of the variable's terms, the sets must differ from each other + and from the ordinal conditions the variable has, or a ``ValueError`` is raised. They work with or + without ``ordinal_conditions``, with the same restrictions. """ 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 +270,9 @@ 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 + fs.check_condition_options(ordinal_conditions, compound_terms) + self.ordinal_conditions = ordinal_conditions + self.compound_terms = compound_terms # 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 +432,10 @@ 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.') + conditions_requested = fs.conditions_requested(self.ordinal_conditions, self.compound_terms) + if conditions_requested and candidate_rules is not None: + raise ValueError('ordinal_conditions and compound_terms search the conditions of the rules; they 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 +475,11 @@ 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 = {} # passed only when asked for, so that the default call is the one of 3.4.0 + if fs.conditions_requested(self.ordinal_conditions, None): + ordinal['ordinal_conditions'] = self.ordinal_conditions + if self.compound_terms: + ordinal['compound_terms'] = self.compound_terms # 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 +509,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 +650,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 or compound 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 conditions_requested) else self.lvs if p_value_compute: self.p_value_validation(bootstrap_size) @@ -1207,7 +1244,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=False, compound_terms=None): """ Inits the corresponding fields if no linguistic partitions were given. @@ -1215,6 +1253,9 @@ 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: True for ``at least`` / ``at most`` conditions on every numerical variable, or a + collection of features (names or indexes) for those only. + compound_terms: mapping from a feature to its named compound terms, or None. """ from . import utils @@ -1239,21 +1280,60 @@ 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). + fs.check_condition_options(ordinal_conditions, compound_terms) + if isinstance(ordinal_conditions, (bool, np.bool_)) or ordinal_conditions is None: + everywhere, chosen = bool(ordinal_conditions), set() + else: + everywhere, chosen = False, fs.resolve_features(ordinal_conditions, [self.var_names], 'ordinal_conditions') + self.ordinal_variables = [] + for ix, n_terms in enumerate(self.n_lv_possible): + eligible = self.categorical_mask[ix] <= 0 and n_terms >= 3 + if ix in chosen and not eligible: + raise ValueError(f'ordinal_conditions: variable {self.var_names[ix]!r} cannot offer at least / at most ' + 'conditions (it needs 3 or more terms that are not categorical).') + self.ordinal_variables.append(bool((everywhere or ix in chosen) and eligible)) + self.compound_variables = {} + for key, entries in (compound_terms or {}).items(): + ix = fs._resolve_feature(key, [self.var_names], 'compound_terms') + if ix in self.compound_variables: + raise ValueError(f'compound_terms: feature {self.var_names[ix]!r} is given twice.') + if self.categorical_mask[ix] > 0: + term_names = [term.name for term in self.categorical_variables[ix]] + else: + term_names = list(self.vl_names[ix]) + ordinal_sets = fs._ordinal_sets(term_names) if self.ordinal_variables[ix] else {} + self.compound_variables[ix] = fs.resolve_compound_entries(entries, term_names, self.var_names[ix], ordinal_sets) + if self.categorical_mask[ix] > 0: # the decoded categorical variable is built once + self.categorical_variables[ix] = fs.enable_conditions( + [self.categorical_variables[ix]], False, {0: [(name, list(sets)) for name, sets in self.compound_variables[ix]]})[0] + self.n_term_codes = [(fs.ordinal_alphabet_size(n) if ordinal else n) + len(self.compound_variables.get(ix, ())) + for ix, (n, ordinal) in enumerate(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=False, + compound_terms=None): """ 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: True, or a collection of features, if the search works on copies of the variables + that offer ``at least`` / ``at most`` conditions (the given variables are not changed). + compound_terms: mapping from a feature to named compound terms the copies also offer, or None. """ - self.lvs = linguist_variables + if fs.conditions_requested(ordinal_conditions, compound_terms): + self.lvs = fs.enable_conditions(linguist_variables, ordinal_conditions, compound_terms, names=[self.var_names]) + else: + self.lvs = 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 +1347,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=False, compound_terms: Optional[dict] = None) -> None: """ Cosntructor method. Initializes the classifier with the number of antecedents, linguist variables and the kind of fuzzy set desired. @@ -1292,6 +1372,14 @@ 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. A collection + of features (names or indexes) restricts the conditions to those variables. + compound_terms: mapping from a feature (name or index) to ``[(name, [terms])...]``: named conditions + whose membership is the bounded sum of the listed terms, coded after the terms and the ordinal + conditions (see ``BaseFuzzyRulesClassifier``). """ if var_names is not None: self.var_names = var_names @@ -1325,12 +1413,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, compound_terms) 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, compound_terms=compound_terms) self.batch_size = batch_size if batch_size is not None: @@ -1346,8 +1435,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 +1741,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 +1762,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 +1860,10 @@ 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 + if fuzzy_variable in self.compound_variables: + linguistic_variable.compound = self.compound_variables[fuzzy_variable] antecedents.append(linguistic_variable) else: @@ -1822,7 +1917,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 +1929,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 +2061,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 +2439,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..7f27b91d 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,15 @@ 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. ``ordinal_conditions`` may also be a + collection of features (names or indexes) to give them to those only, and + ``compound_terms`` maps a feature to ``[(name, [terms])...]``, named + conditions whose membership is the bounded sum of the listed terms, coded + after the ordinal ones. Off by default, which changes nothing. """ def __init__( @@ -443,6 +452,8 @@ def __init__( rule_mode: str = "additive", tolerance: float = 0.0, unique_antecedents: bool = False, + ordinal_conditions=False, + compound_terms: Optional[dict] = None, ) -> None: """Initialize the vectorized regression optimization problem.""" self.X = _as_2d_float_array(X) @@ -469,11 +480,15 @@ def __init__( self.n_output_lvs = int(n_output_lvs) self.n_universe_points = int(n_universe_points) - self.lvs = linguistic_variables + if fs.conditions_requested(ordinal_conditions, compound_terms): + self.lvs = fs.enable_conditions(linguistic_variables, ordinal_conditions, compound_terms) + else: + self.lvs = 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 +1011,31 @@ 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 (``True`` + for every input that can have them, or a collection of input names or indexes). + ``compound_terms`` (default ``None``) maps an input to ``[(name, [terms])...]``, + each entry a condition ``x IS `` whose membership is the bounded sum of + the listed terms, which need not be contiguous. 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 + compound_terms = None + def __init__( self, nRules: int = 30, @@ -1017,6 +1051,8 @@ def __init__( verbose: bool = False, backend: str = "pymoo", unique_antecedents: bool = False, + ordinal_conditions=False, + compound_terms: Optional[dict] = None, ) -> None: """Configure the regressor; optimization is performed in :meth:`fit`.""" self.nRules = nRules @@ -1032,6 +1068,8 @@ def __init__( self.verbose = verbose self.backend = backend self.unique_antecedents = unique_antecedents + self.ordinal_conditions = ordinal_conditions + self.compound_terms = compound_terms def _validate_parameters(self) -> None: """Validate estimator hyperparameters without mutating them.""" @@ -1050,6 +1088,7 @@ 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) + fs.check_condition_options(self.ordinal_conditions, self.compound_terms) 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 +1161,9 @@ def fit( rule_mode=self.rule_mode, tolerance=float(self.tolerance), unique_antecedents=self.unique_antecedents, + **({"ordinal_conditions": self.ordinal_conditions} + if fs.conditions_requested(self.ordinal_conditions, None) else {}), + **({"compound_terms": self.compound_terms} if self.compound_terms else {}), ) backend = ev_backends.get_backend(self.backend) diff --git a/ex_fuzzy/fuzzy_sets.py b/ex_fuzzy/fuzzy_sets.py index eaae20fc..19740698 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,345 @@ def shape(self) -> str: return 'gaussian' +#: Operators of a condition on a linguistic variable (see :class:`fuzzyVariable`): ``is`` one term, +#: ``at_least`` / ``at_most`` a term of the ordered partition, or ``any_of`` a named compound of terms. +CONDITION_OPERATORS = ('is', 'at_least', 'at_most', 'any_of') +_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[:3]: + raise ValueError(f'Unknown operator {operator!r}; use one of {list(CONDITION_OPERATORS[:3])}.') + 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 compound_memberships(terms: np.ndarray, sets: list) -> np.ndarray: + """ + Memberships of named compound terms: the bounded sum ``min(1, sum of the listed terms)`` of each set. + + Args: + terms: ``(n_terms, ...)`` memberships of the terms (as in :func:`ordinal_memberships`). + sets: one sequence of term indexes per compound. The terms need not be contiguous: a union such as + ``[last, first]`` is two shoulders of the axis, one at each end. + + Returns: + ``(len(sets), ...)``. + """ + return np.stack([np.minimum(1.0, terms[list(indexes)].sum(axis=0)) for indexes in sets], axis=0) + + +def has_extra_conditions(variable) -> bool: + """Whether a variable offers conditions beyond its single terms (ordinal ones or named compound terms).""" + return bool(getattr(variable, 'ordinal', False)) or bool(getattr(variable, 'compound', ())) + + +def condition_count(variable) -> int: + """Number of conditions (term codes) a variable offers: its terms, plus the extra ones if it has them.""" + if has_extra_conditions(variable): + 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 ``, ``at most `` or the + name of a compound term.""" + if has_extra_conditions(variable): + 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 + + +_FORBIDDEN_NAME_WORDS = ('AND', 'IS', 'WITH', 'THEN') + + +def check_condition_options(ordinal_conditions, compound_terms) -> None: + """ + Reject a malformed ``ordinal_conditions`` or ``compound_terms`` argument of an estimator. + + ``ordinal_conditions`` is a bool or a collection (list, tuple, set, array) of features, by name or + index; ``compound_terms`` is None or a mapping from a feature to a list of ``(name, [terms])``. + """ + if not (ordinal_conditions is None or isinstance(ordinal_conditions, (bool, np.bool_))): + if isinstance(ordinal_conditions, (str, bytes, dict)) or not hasattr(ordinal_conditions, '__iter__'): + raise ValueError('ordinal_conditions must be True, False or a collection of features (names or indexes), ' + f'got {ordinal_conditions!r}.') + if compound_terms is not None and not hasattr(compound_terms, 'items'): + raise ValueError('compound_terms must be None or a mapping from a feature (name or index) to a list of ' + f'(name, [terms]), got {type(compound_terms).__name__}.') + + +def conditions_requested(ordinal_conditions, compound_terms) -> bool: + """Whether any ordinal or compound condition is asked for (False, None, an empty collection: none).""" + if isinstance(ordinal_conditions, (bool, np.bool_)): + ordinal = bool(ordinal_conditions) + else: + ordinal = ordinal_conditions is not None and len(list(ordinal_conditions)) > 0 + return ordinal or bool(compound_terms) + + +def _resolve_feature(key, names: list, what: str) -> int: + """Index of the feature ``key`` (a name in one of the ``names`` lists, or an integer index).""" + if isinstance(key, (int, np.integer)) and not isinstance(key, (bool, np.bool_)): + if not 0 <= int(key) < len(names[0]): + raise ValueError(f'{what}: feature index {int(key)} is outside 0..{len(names[0]) - 1}.') + return int(key) + for candidates in names: + if key in list(candidates): + return list(candidates).index(key) + raise ValueError(f'{what}: unknown feature {key!r}; use one of {list(names[0])} or an index.') + + +def resolve_features(selection, names: list, what: str = 'ordinal_conditions') -> set: + """ + Indexes of the features in ``selection``, a collection of names or indexes. + + Args: + selection: collection of feature names or indexes. + names: lists of names to match, each with one name per feature (for example the variable names and + the column names of the data); the first one is shown in errors. + """ + out = set() + for key in selection: + out.add(_resolve_feature(key, names, what)) + return out + + +def resolve_compound_entries(entries, term_names: list, variable_name, ordinal_sets: dict = None, + existing: tuple = ()) -> tuple: + """ + Validate the compound terms of one variable and return them as ``((name, (term indexes...)), ...)``. + + Args: + entries: ``[(name, [terms])...]`` with each term a term name or an index. + term_names: names of the variable's terms. + variable_name: for the error messages. + ordinal_sets: ``{name: set of term indexes}`` of the ordinal conditions the variable offers, if any. + existing: compounds the variable already has (same name and set is accepted again). + + Raises: + ValueError: for fewer than 2 distinct terms, every term of the variable, an unknown term, a name + that is not usable or already taken, a set listed twice, or the set of an ordinal condition. + """ + n_terms = len(term_names) + out = list(existing) + new_names = set() + taken_names = set(map(str, term_names)) | set((ordinal_sets or {}).keys()) + for entry in entries: + try: + name, terms = entry + except (TypeError, ValueError): + raise ValueError(f'Variable {variable_name!r}: a compound term is (name, [terms]); got {entry!r}.') from None + if isinstance(terms, (str, bytes)) or not hasattr(terms, '__iter__'): + raise ValueError(f'Variable {variable_name!r}: the terms of compound {name!r} must be a list of term ' + f'names or indexes, got {terms!r}.') + if not isinstance(name, str) or not name.strip() or name != name.strip(): + raise ValueError(f'Variable {variable_name!r}: a compound term needs a non-empty name without ' + f'leading or trailing spaces, got {name!r}.') + words = name.split() + if (any(ch in name for ch in ';\n\r()') or any(word in _FORBIDDEN_NAME_WORDS for word in words) + or name.startswith(('at least ', 'at most '))): + raise ValueError(f"Variable {variable_name!r}: compound name {name!r} cannot contain ';', line breaks, " + "parentheses or the words AND, IS, WITH, THEN, nor start with 'at least' or 'at most' " + '(the rule text must stay readable back).') + indexes = [] + for term in terms: + if isinstance(term, (int, np.integer)) and not isinstance(term, (bool, np.bool_)): + if not 0 <= int(term) < n_terms: + raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists term index {int(term)}, ' + f'outside 0..{n_terms - 1}.') + indexes.append(int(term)) + elif term in list(map(str, term_names)) or term in list(term_names): + indexes.append([str(t) for t in term_names].index(str(term))) + else: + raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists unknown term {term!r}; ' + f'the terms are {list(term_names)}.') + if len(set(indexes)) != len(indexes): + raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists a term twice.') + if len(indexes) < 2: + raise ValueError(f'Variable {variable_name!r}: compound {name!r} needs at least 2 distinct terms ' + '(one term is already a condition).') + if len(indexes) == n_terms: + raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists every term, so it is always true ' + '(a don\'t care).') + key = tuple(sorted(indexes)) + if name in new_names: + raise ValueError(f'Variable {variable_name!r}: compound name {name!r} is used twice; the name of a ' + 'compound term must be unique within its variable.') + new_names.add(name) + same_name = [item for item in existing if item[0] == name] + if same_name: + if same_name[0][1] == key: + continue # the same compound again (a refit over variables that already have it) + raise ValueError(f'Variable {variable_name!r}: compound name {name!r} is already used by another ' + 'compound term of the variable; names must be unique within a variable.') + if name in taken_names: + raise ValueError(f'Variable {variable_name!r}: compound name {name!r} is already the name of a term or of ' + 'an ordinal condition of the variable; names must be unique within a variable.') + for other_name, other_key in out: + if other_key == key: + raise ValueError(f'Variable {variable_name!r}: compounds {other_name!r} and {name!r} list the same terms.') + for ordinal_name, ordinal_key in (ordinal_sets or {}).items(): + if tuple(sorted(ordinal_key)) == key: + raise ValueError(f'Variable {variable_name!r}: compound {name!r} covers the same terms as the ordinal ' + f'condition {ordinal_name!r}, which the variable already has.') + out.append((name, key)) + return tuple(out) + + +def _ordinal_sets(term_names: list) -> dict: + """``{condition name: set of term indexes}`` of the ordinal conditions of a variable with these terms.""" + n_terms = len(term_names) + sets = {} + for code in range(n_terms, ordinal_alphabet_size(n_terms)): + operator, term = decode_ordinal_condition(code, n_terms) + sets[_OPERATOR_TEXT[operator] + str(term_names[term])] = ( + set(range(term, n_terms)) if operator == 'at_least' else set(range(term + 1))) + return sets + + +def enable_conditions(variables: list, ordinal_conditions=True, compound_terms=None, names: list = None) -> list: + """ + Copies of ``variables`` that offer ordinal and/or named compound conditions, ready for the search. + + Args: + variables: list of :class:`fuzzyVariable`. + ordinal_conditions: ``True`` for ``at least`` / ``at most`` conditions on every variable that can have + them (categorical variables and variables with fewer than 3 terms keep single terms); a collection + of features (names or indexes) for those features only, which must then be able to have them; + ``False`` or ``None`` for none. + compound_terms: None, or a mapping from a feature (name or index) to ``[(name, [terms])...]``, each + entry a new condition whose membership is the bounded sum of the listed terms (names or indexes). + names: further lists of feature names to match features against (the variable names always are). + + Returns: + a list of the same length: variables that gain nothing are returned as they are, and the originals are + never changed (the copies share their fuzzy sets). + + Raises: + ValueError: for general Type-2 sets, an unknown feature or term, and every invalid compound term + (see :func:`resolve_compound_entries`). + """ + check_condition_options(ordinal_conditions, compound_terms) + all_names = [[variable.name for variable in variables]] + [list(item) for item in (names or [])] + if isinstance(ordinal_conditions, (bool, np.bool_)) or ordinal_conditions is None: + explicit = set() + everywhere = bool(ordinal_conditions) + else: + explicit = resolve_features(ordinal_conditions, all_names, 'ordinal_conditions') + everywhere = False + compound = {} + for key, entries in (compound_terms or {}).items(): + feature = _resolve_feature(key, all_names, 'compound_terms') + if feature in compound: + raise ValueError(f'compound_terms: feature {all_names[0][feature]!r} is given twice.') + compound[feature] = list(entries) + out = [] + for feature, variable in enumerate(variables): + want_ordinal = (everywhere or feature in explicit) + if want_ordinal and feature in explicit and not variable.ordinal_eligible(strict=True): + raise ValueError(f'ordinal_conditions: variable {variable.name!r} cannot offer at least / at most ' + 'conditions (it needs 3 or more terms that are not categorical).') + if want_ordinal and not variable.ordinal_eligible(strict=True): + want_ordinal = False # a bool True skips such variables quietly + entries = compound.get(feature, ()) + if entries and variable.fs_type == FUZZY_SETS.gt2: + raise ValueError('Compound terms support Type-1 and interval Type-2 sets, not general Type-2 sets.') + ordinal = bool(variable.ordinal) or want_ordinal + if ordinal == bool(variable.ordinal) and not entries: + out.append(variable) + continue + copy = fuzzyVariable(variable.name, variable.linguistic_variables, variable.units) + copy._set_conditions(ordinal, entries, existing=variable.compound) + out.append(copy) + return out + + +def enable_ordinal_conditions(variables: list) -> list: + """ + Copies of ``variables`` that offer ``at least`` / ``at most`` conditions (see :func:`enable_conditions`). + + 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. + + Raises: + ValueError: for general Type-2 sets, whose memberships are not a number or an interval per term. + """ + return enable_conditions(variables, True) + + class fuzzyVariable(): """ Fuzzy Variable Container and Management Class. @@ -826,9 +1166,25 @@ 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, then the named compound terms + (:meth:`condition`). ``compound_terms=[(name, [terms])...]`` adds one condition per entry, whose + membership is the bounded sum of the listed terms, which need not be contiguous (a union that wraps + around, such as ``[last, first]``, is two shoulders, one at each end of the axis). 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 + #: Named compound terms, ``((name, (term indexes...)), ...)``; empty unless asked for. + compound = () + + def __init__(self, name: str, fuzzy_sets: list[FS], units: str = None, ordinal_conditions: bool = False, + compound_terms: list = None) -> None: """ Creates a fuzzy variable with the specified fuzzy sets. @@ -836,9 +1192,16 @@ 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. + compound_terms (list, optional): ``[(name, [terms])...]``, each entry one more condition whose + membership is the bounded sum of the listed terms (names or indexes): at least 2 distinct + terms and not all of them; the sets must differ from each other and from the ordinal + conditions of the variable. 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 +1217,136 @@ 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}).') + if ordinal_conditions or compound_terms: + self._set_conditions(bool(ordinal_conditions), compound_terms or ()) + + + def _set_conditions(self, ordinal: bool, compound_entries, existing: tuple = ()) -> None: + """Validate and set the ordinal flag and the compound terms (``existing`` ones are kept).""" + if self.fs_type == FUZZY_SETS.gt2: + raise ValueError('Ordinal and compound conditions support Type-1 and interval Type-2 sets, not general ' + 'Type-2 sets.') + term_names = self.linguistic_variable_names() + ordinal_sets = _ordinal_sets(term_names) if ordinal and len(term_names) >= 3 else {} + compound = resolve_compound_entries(compound_entries, term_names, self.name, ordinal_sets, tuple(existing)) + for compound_name, key in compound: # the existing ones too: an ordinal flag may be new + for ordinal_name, ordinal_key in ordinal_sets.items(): + if tuple(sorted(ordinal_key)) == key: + raise ValueError(f'Variable {self.name!r}: compound {compound_name!r} covers the same terms as ' + f'the ordinal condition {ordinal_name!r}, which the variable already has.') + self.compound = compound + if ordinal and not self.ordinal: + 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 _ordinal_size(self) -> int: + """Codes of the terms and the ordinal conditions (the compound terms come after them).""" + n_terms = len(self.linguistic_variables) + return ordinal_alphabet_size(n_terms) if self.ordinal else n_terms + + + def n_conditions(self) -> int: + """Number of conditions (term codes) the variable offers: its terms, then the ordinal ones if + enabled, then the named compound terms.""" + return self._ordinal_size() + len(self.compound) + + + def condition(self, code: int) -> tuple: + """ + ``(operator, reference)`` of a condition code. + + The operator is ``'is'`` (reference: term index), ``'at_least'`` or ``'at_most'`` (reference: term + index ``t_j``) or ``'any_of'`` (reference: position of the compound in ``self.compound``). + """ + code = int(code) + size = self._ordinal_size() + if code >= size: + if code >= size + len(self.compound): + raise ValueError(f'Condition code {code} is outside 0..{self.n_conditions() - 1}.') + return 'any_of', code - size + 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: + """Code of the condition ``operator`` + term index (inverse of :meth:`condition`); for ``'any_of'`` + ``term`` is the name of the compound term (or its position).""" + if operator == 'any_of': + names = [name for name, _ in self.compound] + position = names.index(term) if term in names else term + if not isinstance(position, (int, np.integer)) or not 0 <= position < len(names): + raise ValueError(f'Variable {self.name!r} has no compound term {term!r}; it has {names}.') + return self._ordinal_size() + int(position) + 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``, the listed terms (low to high) for ``any_of``.""" + operator, term = self.condition(code) + if operator == 'is': + return [term] + if operator == 'at_least': + return list(range(term, len(self.linguistic_variables))) + if operator == 'any_of': + return list(self.compound[term][1]) + 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 ``, + then the names of the compound terms. Without extra conditions this is :meth:`linguistic_variable_names`.""" + names = self.linguistic_variable_names() + if not self.ordinal and not self.compound: + return names + out = list(names) + for code in range(len(names), self._ordinal_size()): + operator, term = self.condition(code) + out.append(_OPERATOR_TEXT[operator] + str(names[term])) + return out + [name for name, _ in self.compound] + + + def condition_memberships(self, terms: np.ndarray) -> np.ndarray: + """Memberships of every condition from those of the terms, ``(n_terms, ...)`` to ``(n_conditions, ...)``.""" + parts = [terms] + if self.ordinal: + parts.append(ordinal_memberships(terms)[terms.shape[0]:]) + if self.compound: + parts.append(compound_memberships(terms, [indexes for _, indexes in self.compound])) + return np.concatenate(parts, axis=0) if len(parts) > 1 else terms + def append(self, fs: FS) -> None: """ @@ -893,8 +1386,18 @@ 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` and + :func:`compound_memberships`). """ + res = self._term_memberships(x) + if self.ordinal or self.compound: + res = self.condition_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 +1487,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 +1513,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..58204e14 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 fs.has_extra_conditions(antecedent): + for code in range(len(antecedent), antecedent.n_conditions()): + first = antecedent.condition_terms(code)[0] # the color of the first term it covers + colors.setdefault(antecedent.condition_names()[code], colors.get(antecedent[first].name, palette[0])) + return colors diff --git a/ex_fuzzy/persistence.py b/ex_fuzzy/persistence.py index b34128f9..51efeccb 100644 --- a/ex_fuzzy/persistence.py +++ b/ex_fuzzy/persistence.py @@ -23,6 +23,13 @@ _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' +# ``Compound;;,...`` after the header: a named compound term (only if there are any). +_COMPOUND_PREFIX = 'Compound;' _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 +44,35 @@ 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 ``, ``at most `` or the name of a compound + term of the variable) 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) + if text in [name for name, _ in variables[index].compound]: + return variables[index].condition_code('any_of', 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 _compound_lines(fuzzy_variable) -> str: + """The ``Compound;name;indexes`` lines of a variable's named compound terms (empty without any).""" + return ''.join(f'{_COMPOUND_PREFIX}{name};' + ','.join(str(ix) for ix in indexes) + '\n' + for name, indexes in getattr(fuzzy_variable, 'compound', ())) + + def _extract_mod_word(text): """ Extract modifier word from a string containing MOD pattern. @@ -148,11 +184,17 @@ 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 + - Named compound terms ``IS ``, which the given variables must already have (see + ``load_fuzzy_variables``) - 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 +227,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 +319,20 @@ def load_fuzzy_variables(fuzzy_variables_printed: str) -> list: fuzzy_variables = [] active_linguistic_variables = False + ordinal_variable = False + compound_entries = [] 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, + compound_terms=compound_entries)) linguistic_var_fuzzy_sets = [] + ordinal_variable = False + compound_entries = [] header = line.split(':') linguistic_variable_name = header[1].strip() fvar_units = header[2].strip() if len(header) > 2 else None @@ -291,6 +340,11 @@ def load_fuzzy_variables(fuzzy_variables_printed: str) -> list: active_linguistic_variables = True elif line == '': pass + elif line == _ORDINAL_LINE: + ordinal_variable = True + elif line.startswith(_COMPOUND_PREFIX) and line.count(';') == 2: + _, compound_name, compound_terms = line.split(';') + compound_entries.append((compound_name, [int(ix) for ix in compound_terms.split(',')])) else: processes_line = line.split(';') categorical_class = fs.categoricalFS if fuzzy_set_type == fs.FUZZY_SETS.t1 else fs.categoricalIVFS @@ -332,7 +386,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, compound_terms=compound_entries) fuzzy_variables.append(object_fvar) return fuzzy_variables @@ -366,6 +421,8 @@ 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, and each + named compound term a line ``Compound;;,...`` (indexes of the covered terms) - Type-2 fuzzy sets include additional parameters for upper membership functions - Supports trapezoidal, triangular, and gaussian membership functions """ @@ -375,6 +432,7 @@ 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' + fuzzy_variable_printed += _compound_lines(fuzzy_variable) fuzzy_variable_printed += 'Categorical ' + cat_type + ';' for fuzzy_set in fuzzy_variable: @@ -385,6 +443,9 @@ 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' + fuzzy_variable_printed += _compound_lines(fuzzy_variable) 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..d9add10a 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 or compound conditions has more columns than terms: the conditions after the terms. + extended = [fs.has_extra_conditions(variable) 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 extended[feature]: + terms = np.moveaxis(table[:, first:index], 1, 0) + table[:, first:first + widths[feature]] = np.moveaxis(antecedents[feature].condition_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,45 @@ 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'``, +#: ``'at_most'`` or ``'any_of'``, ``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), ``code`` the integer +#: stored in ``RuleSimple.antecedents`` and ``name`` the given name of a compound term. For ``'any_of'`` the +#: ``term`` and ``term_index`` are ``None`` (the condition is the named set ``terms``); for the other operators +#: ``name`` is ``None``. +Condition = namedtuple('Condition', ['feature', 'variable', 'operator', 'term', 'term_index', 'terms', 'code', 'name']) + + +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() + covered = [names[ix] for ix in variable.condition_terms(code)] + if operator == 'any_of': + out.append(Condition(feature, variable.name, operator, None, None, covered, code, + variable.compound[term_index][0])) + else: + out.append(Condition(feature, variable.name, operator, names[term_index], term_index, covered, code, None)) + return out + + class RuleSimple(): """ Simplified Rule Representation for Optimized Computation. @@ -533,6 +580,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 +2078,8 @@ def generate_rule_string(rule: RuleSimple, antecedents: list, bootstrap_results: Generates a string with the rule. The text is ``IF IS