Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions LLM/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
135 changes: 135 additions & 0 deletions benchmarks/benchmark_ordinal_conditions.py
Original file line number Diff line number Diff line change
@@ -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()
31 changes: 31 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,37 @@ and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0
[Unreleased]
============

Added
-----
- **Ordinal conditions**: ``ordinal_conditions=True`` on ``BaseFuzzyRulesClassifier`` (winning-rule and
mixture inference, also with ``mixture_search``) and ``BaseFuzzyRulesRegressor`` (and on ``FitRuleBase`` and
``FitRuleBaseRegression``) lets a rule state ``x IS at least t`` or ``x IS at most t`` over the terms of a
variable, ordered as listed from low to high, besides a single term. A variable with ``n`` terms offers the
``n`` single terms plus ``at least t_j`` and ``at most t_j`` for ``j = 1 ... n - 2`` (``3n - 4`` conditions;
the vacuous ``at least t_0`` and the duplicates of a single term are not offered). The membership is the
bounded sum ``min(1, sum of the memberships of the terms covered)``, a shouldered trapezoid on a strong
partition; it works for Type-1 and interval Type-2 sets, categorical variables keep single terms, and general
Type-2 sets and ``candidate_rules`` are refused. The search widens the term gene to the condition codes (no
gene is added), so mutation, crossover, sampling, the array, population, streamed and PyTorch objectives (on
fixed partitions) and duplicate-rule detection handle it, and a set of terms has one code. Rules print
``IS at least Fair`` / ``IS at most Fair``; ``rules.rule_conditions`` (``RuleSimple.conditions``) reads the
operator (``is``, ``at_least``, ``at_most``) and term without parsing text; text rules and variables
(``persistence``), pickles, ``print_mixture``, the regression rules, ``rules_to_latex`` and the
``pattern_stability`` rule keys handle it. Off by default: the encoding, rules, printing, files and every
seeded fit are those of 3.4.0 (a test replays nine recorded fits). The search space grows, so give it a larger
budget when comparing (``docs/source/ordinal_conditions.rst``,
``benchmarks/benchmark_ordinal_conditions.py``)
- **Per-variable ordinal conditions and named compound terms**: ``ordinal_conditions`` also accepts a
collection of features (names or indexes) that get ``at least`` / ``at most`` while the others keep single
terms, for example to leave a cyclic clock variable out. The new ``compound_terms={feature: [(name,
[terms])...]}`` adds one condition per entry, ``x IS <name>``, 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
====================

Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ Documentation Contents
extending
persistence
rule_mixture
ordinal_conditions
classifiers
pattern_stats

Expand Down
Loading
Loading