Skip to content

Conventions

What every part of bulanik promises, so the guide pages do not have to repeat it.

Immutability

Every fuzzy number is a frozen dataclass: you cannot assign to a field, and every operation returns a new number. Numbers compare by value and are hashable, so they work as dictionary keys and set members. The same holds for membership functions, rules and terms, interval type-2 sets and the result objects of bulanik.mcdm (WeightingResult, RankingResult, BWMResult).

from bulanik import TFN

a = TFN(1, 2, 3)
b = a + TFN(1, 1, 1)
print(a, b)                          # a is unchanged
print(a == TFN(1, 2, 3), hash(a) == hash(TFN(1, 2, 3)))
try:
    a.l = 5
except Exception as error:
    print(type(error).__name__ + ":", error)
TFN(l=1.0, m=2.0, u=3.0) TFN(l=2.0, m=3.0, u=4.0)
True True
FrozenInstanceError: cannot assign to field 'l'

Stateful objects are the exception, and they say so: a trained ANFIS changes its parameters (and history) in fit and set_params.

Validation and error types

Inputs are checked when an object is built or a function is called, never later. Two exception types carry nearly all the information:

Type Raised for
TypeError A value of the wrong kind: a bool or str where a number is expected, a number of another type or rung in a binary operation (TFN + IFN, IFN + PFN), a non-TFN where a TFN is required.
ValueError A right kind but a bad value: NaN or infinity, a degree outside \([0, 1]\), a violated constraint (l <= m <= u, \(\mu^q + \nu^q \le 1\)), an unknown method name, an empty or ragged table, mismatched lengths, weights that do not sum to 1.

Subclasses of ValueError make a specific failure catchable: NoRuleFiredError (no rule fires for an output; its .explanation shows why) and NoLowerFiringError (the bmm type reducer without any positive lower firing). Two more types appear at the edges: RuntimeError when a solver finds no optimum (the bridges) or the ANFIS ranges are not known yet, and ImportError when a bridge or bulanik.plot is imported without its extra.

Booleans are rejected as numbers on purpose (True would silently mean 1), and NaN and infinity are always rejected. Messages name the argument and the offending value:

from bulanik import IFN, TFN

def show(make):
    try:
        make()
    except Exception as error:
        print(f"{type(error).__name__}: {error}")

show(lambda: TFN(3, 2, 1))                 # order violated
show(lambda: TFN(1, float("nan"), 3))      # not finite
show(lambda: TFN(True, 2, 3))              # bool is not a number
show(lambda: TFN("1", 2, 3))               # nor is a string
show(lambda: IFN(0.5, 0.4) + TFN(1, 2, 3)) # numbers of different types
show(lambda: IFN(1.2, 0.0))                # degree outside [0, 1]
ValueError: TFN needs l <= m <= u, got (3.0, 2.0, 1.0)
ValueError: m must be finite, got nan
TypeError: l must be a number, got bool True
TypeError: l must be a number, got str '1'
TypeError: unsupported operand type(s) for +: 'IFN' and 'TFN'
ValueError: mu must be in [0, 1], got 1.2

Tolerance and boundary projection

Floating-point rounding can push a value that is mathematically on a constraint slightly past it. bulanik treats this in two steps, both with the module constant TOL = 1e-12:

  1. Inputs and results within TOL of the legal range are accepted. A degree in \([-10^{-12}, 1 + 10^{-12}]\) is clamped to \([0, 1]\). A pair \((\mu, \nu)\) with \(\mu^q + \nu^q \le 1 + 10^{-12}\) is accepted as rounding. Anything beyond raises ValueError.
  2. Orthopair numbers are projected back onto the boundary when the excess is more than 4 ulps. One ulp (unit in the last place of a float) is \(2.2 \times 10^{-16}\) at 1, so the threshold is about \(8.9 \times 10^{-16}\). The pair is rescaled so that \(\mu^q + \nu^q = 1\). An excess of at most 4 ulps is left as given, so a deliberate boundary value such as PFN(0.6, 0.8) stays bit-identical. The projection matters because an amplifying operation (power, scale by \(k > 1\)) would otherwise magnify the excess until a later check rejects a value the constructor had accepted.
from bulanik import IFN

on_boundary = IFN(0.6, 0.4)
within_ulps = IFN(0.6, 0.4 + 2.0**-52)         # 1 ulp past: kept as given
projected = IFN(0.6, 0.4 + 1e-13)              # within TOL, more than 4 ulps: projected
print(repr(within_ulps.nu), repr(projected.mu), repr(projected.nu))
print(projected.mu + projected.nu)             # back on the boundary
try:
    IFN(0.6, 0.4 + 1e-11)                      # beyond TOL: an error
except ValueError as error:
    print("ValueError:", error)
0.40000000000000024 0.59999999999994 0.40000000000006003
1.0
ValueError: need mu^1 + nu^1 <= 1, got mu=0.6, nu=0.40000000001 (sum 1.00000000001)

Operation results get the same treatment: a result that misses its constraint only by rounding is put back on the boundary, and one that misses it by more raises ValueError (for example a picture fuzzy number scaled by \(k < 1\) can leave its constraint set).

Named operator families, scores, accuracies and distances

Everything that has more than one definition is selected by a name, a plain string: ops="einstein", x.score("normalized"), x.distance(y, "euclidean"). Every number class lists the names it supports in class attributes (OPS, SCORES, ACCURACIES, DISTANCES) and its defaults (DEFAULT_SCORE and so on). The table below is generated from those attributes; the default of each column is in bold, and for operator families the first name is the default.

Type Operator families (ops) Scores Accuracies Distances
TFN algebraic centroid, graded_mean, mean none vertex
TrFN algebraic centroid, graded_mean, mean none vertex
IFN algebraic, einstein difference, normalized sum hamming, euclidean
PFN algebraic, einstein difference, normalized sum hamming, euclidean
FFN algebraic, einstein difference, normalized sum hamming, euclidean
QROFN algebraic, einstein difference, normalized sum hamming, euclidean
SphericalFN algebraic pi_adjusted, normalized sum hamming, euclidean
PictureFN algebraic difference, normalized sum hamming, euclidean
NeutrosophicFN algebraic average difference, certainty hamming, euclidean

IFN, PFN, FFN and QROFN share one implementation, so they have the same names (applied to \(\mu^q\) and \(\nu^q\)). A number type without accuracy functions (TFN, TrFN) raises ValueError from accuracy(); the formulas of each name are on the user guide pages.

The naming rule

  1. A name is always a string and always optional where a default exists: None means "the default".
  2. An unknown name raises ValueError that lists every valid name: unknown ops 'foo' for IFN; valid: algebraic, einstein.
  3. MCDM results report the names they used in result.operators, so a ranking can be reproduced from its output.
from bulanik import IFN, TFN

def show(make):
    try:
        make()
    except Exception as error:
        print(f"{type(error).__name__}: {error}")

show(lambda: IFN(0.6, 0.3).add(IFN(0.5, 0.4), ops="foo"))
show(lambda: TFN(1, 2, 3).score("foo"))
show(lambda: TFN(1, 2, 3).accuracy())
print(IFN(0.6, 0.3).score(), IFN(0.6, 0.3).score(None), IFN(0.6, 0.3).score("normalized"))
ValueError: unknown ops 'foo' for IFN; valid: algebraic, einstein
ValueError: unknown score 'foo' for TFN; valid: centroid, graded_mean, mean
ValueError: TFN has no accuracy function
0.3 0.3 0.65

Names in the other modules

The same rule covers the method options of the sets, inference, type-2, ANFIS and MCDM modules. This table is generated from the code too (the valid names come from the modules, the defaults from the function signatures):

Where Option Valid names Default
t_norm kind min, prod min
t_conorm kind max, probor max
MamdaniFIS, SugenoFIS, TsukamotoFIS and_method min, prod min
MamdaniFIS, SugenoFIS, TsukamotoFIS or_method max, probor max
MamdaniFIS implication min, prod min
MamdaniFIS aggregation max, sum, probor max
MamdaniFIS, defuzzify_samples defuzzification centroid, bisector, mom, som, lom centroid
SugenoFIS output_method weighted_average, weighted_sum weighted_average
IT2MamdaniFIS, IT2SugenoFIS type_reduction km, ekm, nie_tan, bmm km
type_reduce method km, ekm, nie_tan, bmm km
IT2MamdaniFIS implication min, prod min
IT2MamdaniFIS aggregation max, sum, probor max
ANFIS mf gaussian, bell gaussian
ANFIS.fit method hybrid, gradient hybrid
aggregate_experts method wa, wg wa
rank_fuzzy method topsis, vikor, waspas, edas, codas, copras, marcos, cocoso, wisp, electre topsis
rank_fuzzy aggregation wa, wg wa

Criterion types are the names "benefit" and "cost"; FuzzyGoal.sense takes "ge", "le" or "eq"; rule keywords (IF, IS, AND, OR, NOT, THEN, WITH) are case-insensitive while variable and term names are case-sensitive.

Warnings

bulanik raises errors for wrong input and keeps warnings for results that are valid but probably not what you want. All of them are RuntimeWarning or UserWarning, so the standard warnings filters apply.

Warning Emitted when
ExtrapolationWarning (a RuntimeWarning) ANFIS.predict or rmse is asked about an input outside the trained ranges. One warning per call, naming the first offending sample; the prediction is still returned. The SugenoFIS exported by to_sugeno rejects such inputs instead.
RuntimeWarning ANFIS.fit skipped gradient steps. A step whose result would be non-finite is halved up to 60 times and skipped after that (or at once for a non-finite gradient); the history then repeats the previous RMSE. One warning per fit, with the count. Lower lr.
UserWarning An ANFIS grid partition produces more than 27 rules (more than 1024 raises ValueError unless allow_large=True).
import warnings

from bulanik.anfis import ANFIS

X = [[i / 10] for i in range(21)]
y = [x[0] ** 2 for x in X]

model = ANFIS(1, 3, ranges=[(0, 2)])
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    model.predict([[5.0]])                       # outside the trained range [0, 2]
print(caught[0].category.__name__, "-", caught[0].message)

wild = ANFIS(1, 3, mf="bell", ranges=[(0, 2)])
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    wild.fit(X, y, epochs=3, method="gradient", lr=1e100)   # absurd step size
print(caught[0].category.__name__, "-", caught[0].message)
ExtrapolationWarning - ANFIS.predict: sample 0 has input 0 = 5 outside the trained range [0, 2]; the prediction extrapolates
RuntimeWarning - ANFIS.fit skipped 3 of 3 gradient steps: none lowered the error within 60 halvings or the gradient was not finite; lower lr