Skip to content

sezgi bridge

bulanik.sezgi connects bulanik to sezgi, a library of metaheuristic optimisers that minimise a function without derivatives. The bridge expresses three bulanik problems as sezgi problems: the nonlinear best-worst method, the fuzzy best-worst method and ANFIS training.

Install

pip install "bulanik[sezgi]"

From a checkout use pip install -e ".[sezgi]" (or uv sync). Importing bulanik.sezgi without the extra raises an ImportError that names it.

sezgi has no constraint API for a single objective, so every constraint is a penalty inside the objective. The default algorithm everywhere is sezgi.DifferentialEvolution(pop_size=40, variant="jde"); pass any other sezgi algorithm instance as algorithm=. A run is deterministic for a fixed seed and budget (the number of objective evaluations), which is what the examples on this page rely on.

Nonlinear best-worst method

BWMProblem is Rezaei's (2015) original, nonlinear BWM. With best criterion \(B\), worst \(W\) and comparisons \(a_{Bj}\), \(a_{jW}\),

\[ \min \xi \quad \text{s.t.} \quad \left|\frac{w_B}{w_j} - a_{Bj}\right| \le \xi\ (j \ne B),\;\; \left|\frac{w_j}{w_W} - a_{jW}\right| \le \xi\ (j \ne W),\;\; \textstyle\sum_j w_j = 1. \]

evaluate(x) is the largest deviation plus penalty * |sum(x) - 1| (default penalty 10). deviation(x) gives the objective without the penalty, which is handy to check a hand-made weight vector. solve(algorithm=None, *, budget=20000, seed=0) runs the search and returns a BWMResult with the weights rescaled to sum to 1, xi recomputed at those weights, extras["evals_used"] and extras["best_f"]. Best and worst are inferred as in the LumiX bridge, or pass best= and worst=.

from bulanik.sezgi import BWMProblem

problem = BWMProblem({"price": 1, "quality": 2, "speed": 8},
                     {"price": 8, "quality": 4, "speed": 1})
print(problem.best, problem.worst)
print(round(problem.deviation([8, 4, 1]), 6), "(perfectly consistent, any scale)")
print(round(problem.deviation([0.6, 0.3, 0.1]), 6))

r = problem.solve(budget=3000, seed=1)
print({k: round(v, 3) for k, v in r.weights.items()}, r.ranking)
print(r.method, r.operators)
print(r.xi < 1e-3, r.consistency_ratio < 1e-3, r.extras["evals_used"])
again = problem.solve(budget=3000, seed=1)
print(again.weights == r.weights)               # same seed and budget: same answer
price speed
0.0 (perfectly consistent, any scale)
2.0
{'price': 0.615, 'quality': 0.308, 'speed': 0.077} ('price', 'quality', 'speed')
bwm_nonlinear {'model': 'nonlinear', 'consistency': 'rezaei2015_ci', 'algorithm': 'DifferentialEvolution'}
True True 3000
True

Fuzzy best-worst method

FuzzyBWMProblem is Guo and Zhao's (2017) version: the comparisons are triangular numbers, for example the words of FUZZY_BWM_TFN (EI, WI, FI, VI, AI for equally to absolutely important), and the weights are triangular numbers \(\tilde w_j = (l_j, m_j, u_j)\). With the fuzzy ratio \(\tilde w_B / \tilde w_j = (l_B/u_j,\; m_B/m_j,\; u_B/l_j)\) each comparison gives three rows, \(|l_B - l_{Bj} u_j| \le k u_j\), \(|m_B - m_{Bj} m_j| \le k m_j\) and \(|u_B - u_{Bj} l_j| \le k l_j\), the same for \(\tilde w_j / \tilde w_W\), and the weights are normalised so that the graded means \(R(\tilde w) = (l + 4m + u)/6\) sum to 1 and ordered \(l \le m \le u\). The search space has \(3n\) components.

solve returns the fuzzy weights in fuzzy_weights, their graded means as weights, and the optimum \(k\) in xi. Comparisons must be TFN with \(l > 0\) and \(m \ge 1\); the best and worst are the criteria whose comparison is TFN(1, 1, 1). The default budget is larger (100 000) because the space is larger.

from bulanik.scales import FUZZY_BWM_TFN as F
from bulanik.sezgi import FuzzyBWMProblem

problem = FuzzyBWMProblem({"a": F["EI"], "b": F["WI"], "c": F["AI"]},
                          {"a": F["AI"], "b": F["FI"], "c": F["EI"]})
r = problem.solve(budget=20000, seed=1)
print(r.best, r.worst, r.ranking)
print({k: round(v, 2) for k, v in r.weights.items()}, round(sum(r.weights.values()), 6))
print(r.fuzzy_weights["a"])
print(r.method, r.operators["consistency"], r.operators["defuzzification"])
a c ('a', 'b', 'c')
{'a': 0.51, 'b': 0.35, 'c': 0.14} 1.0
TFN(l=0.46211654177586853, m=0.5032597802781879, u=0.5691938308077182)
fuzzy_bwm guo_zhao2017_ci graded_mean

consistency_ratio is not one quantity

All three BWM producers fill the consistency_ratio field of the same BWMResult, but they put different quantities in it. operators["consistency"] says which.

Producer consistency_ratio operators["consistency"] Scale
bwm_linear (LumiX) \(\xi^L\) itself, equal to xi "xi_L" the comparisons' scale: 0 to a few units, no fixed upper bound
BWMProblem.solve \(\xi / CI(a_{BW})\) with Rezaei's index "rezaei2015_ci" about 0 to 1, analogous to AHP's CR; None when \(CI = 0\) and \(\xi > 0\)
FuzzyBWMProblem.solve \(k / CI\) with Guo and Zhao's index "guo_zhao2017_ci" a ratio; the fuzzy index is never 0, so always a number

The crisp index is \(CI(a) = ((1 + 2a) - \sqrt{8a + 1})/2\) and is 0 for \(a_{BW} = 1\) (best and worst equally important). Then the ratio is defined only if the comparisons are consistent: 0.0 when \(\xi \le 10^{-6}\), and otherwise None with the reason in extras["consistency_note"]. The fuzzy index \(((1 + 2u) + \sqrt{8u + 1})/2\) is at least 3.

The same inconsistent comparisons give different numbers, partly because the models differ: the linear model reports its own optimum \(\xi^L\) (about 0.04), the nonlinear one a larger \(\xi\) (about 0.32, deviations of ratios rather than of linear rows), and the ratio then divides that by \(CI\). The two consistency_ratio values (0.04 and 0.07) only look close by chance:

from bulanik.lumix import bwm_linear
from bulanik.sezgi import BWMProblem

best_to_others = {"a": 1, "b": 2, "c": 8}
others_to_worst = {"a": 8, "b": 3, "c": 1}             # b over c should be 4 to be consistent

linear = bwm_linear(best_to_others, others_to_worst)
nonlinear = BWMProblem(best_to_others, others_to_worst).solve(budget=3000, seed=1)
for r in (linear, nonlinear):
    print(f"{r.operators['consistency']:14} consistency_ratio = {r.consistency_ratio:.2f}  xi = {r.xi:.2f}")

# a_BW = 1: the index is 0, so an inconsistent set has no ratio
p = BWMProblem({"a": 1, "b": 1, "c": 3}, {"a": 1, "b": 1, "c": 2}, best="a", worst="b")
r = p.solve(budget=2000, seed=1)
print(r.consistency_ratio)
print(r.extras["consistency_note"])
xi_L           consistency_ratio = 0.04  xi = 0.04
rezaei2015_ci  consistency_ratio = 0.07  xi = 0.32
None
the comparisons are inconsistent (xi = 1.00213) but Rezaei's consistency index is 0 for a_BW = 1, so no consistency ratio exists

Never compare a linear \(\xi^L\) with a sezgi ratio, and treat None as "no ratio exists", not as an error. See also the box on the LumiX bridge.

Training an ANFIS with sezgi

ANFIS.fit is hybrid (least-squares plus gradient) and uses derivatives for the premise parameters. For a derivative-free global search over all membership and consequent parameters at once, train_anfis(anfis, X, y, *, algorithm=None, budget=20000, seed=0, coef_bound=10.0) trains a deep copy of the model (yours is untouched) and returns an ANFISTrainResult with the trained anfis, its rmse and evals_used. An ANFIS built without ranges gets them from the data on the copy.

Underneath is ANFISProblem: the space is Float(0, 1, D) over the \(D\) parameters, and decode(z) maps each coordinate affinely to its bounds (Gaussian centre in the input range, \(\sigma\) in \([\text{span}/100, \text{span}]\), bell \(b\) in \([0.5, 5]\), consequents in \([-\texttt{coef\_bound}, \texttt{coef\_bound}]\)). evaluate is the training RMSE, or infinity if no rule fires or the output overflows. The problem mutates its working ANFIS; that is why train_anfis hands it a copy.

import math
from bulanik.anfis import ANFIS
from bulanik.sezgi import ANFISProblem, train_anfis

X = [[2 * math.pi * i / 49] for i in range(50)]
y = [math.sin(x[0]) for x in X]

start = ANFIS(1, 3, ranges=[(0, 2 * math.pi)])           # 3 rules, 12 parameters
before = start.get_params()
result = train_anfis(start, X, y, budget=20000, seed=1)
print("sezgi RMSE < 0.1:", result.rmse < 0.1, "|", result.evals_used, "evaluations")
print("original untouched:", start.get_params() == before)

hybrid = ANFIS(1, 3, ranges=[(0, 2 * math.pi)])
print("hybrid RMSE < 0.1:", hybrid.fit(X, y, epochs=10)[-1] < 0.1)

problem = ANFISProblem(ANFIS(1, 2, ranges=[(0, 1)]), [[0.0], [1.0]], [0.0, 1.0], coef_bound=2)
print(problem.dim, problem.decode([0.0] * problem.dim))
print(problem.decode([1.0] * problem.dim))
sezgi RMSE < 0.1: True | 20000 evaluations
original untouched: True
hybrid RMSE < 0.1: True
8 [0.0, 0.01, 0.0, 0.01, -2.0, -2.0, -2.0, -2.0]
[1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0]

For a smooth one-dimensional target the hybrid rule is both faster and more accurate; a metaheuristic earns its keep when the loss surface is rough, when you want a different objective, or when you want to start the gradient method from a global solution. Example 16 ANFIS with sezgi compares the two.

Pitfalls

Budget and seed are part of the answer. A small budget gives a rough optimum, and a different seed a slightly different one. Fix both when you report numbers, and raise the budget until the result stops moving.

The sezgi objective is penalised, not constrained. Weights from solve are rescaled to sum to 1 afterwards; xi is recomputed at the rescaled weights.

Do not compare consistency_ratio across producers (see the table above), and handle None for crisp BWM with \(a_{BW} = 1\).

FuzzyBWMProblem wants TFN with \(l > 0\) and \(m \ge 1\). Crisp numbers and other number kinds are errors, and so is a missing TFN(1, 1, 1) (pass best= and worst= only to resolve a tie, they must still have the "one" comparison).

ANFISProblem needs ranges. Build the ANFIS with ranges= (or use train_anfis, which infers them from the data; a constant input column is an error then).

from bulanik import TFN
from bulanik.anfis import ANFIS
from bulanik.scales import FUZZY_BWM_TFN as F
from bulanik.sezgi import ANFISProblem, BWMProblem, FuzzyBWMProblem, train_anfis

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

ok = ({"a": 1, "b": 2, "c": 8}, {"a": 8, "b": 4, "c": 1})
show(lambda: BWMProblem(*ok).solve(budget=0))
show(lambda: BWMProblem(*ok, penalty=0))
show(lambda: BWMProblem({"a": 1, "b": 2}, {"a": 8, "b": 1}))                       # a_BW mismatch
show(lambda: BWMProblem(ok[0], {"a": 8, "b": 4}))                                  # keys differ
show(lambda: BWMProblem(*ok).deviation([1, 2]))                                    # wrong length
show(lambda: FuzzyBWMProblem({"a": 1, "b": 2}, {"a": 2, "b": 1}))                  # not TFN
show(lambda: FuzzyBWMProblem({"a": F["EI"], "b": TFN(0, 1, 2)}, {"a": F["AI"], "b": F["EI"]}))
show(lambda: ANFISProblem(ANFIS(1, 2), [[0.0], [1.0]], [0.0, 1.0]))                # no ranges
show(lambda: train_anfis(ANFIS(1, 2), [[1.0], [1.0]], [0.0, 1.0]))                 # constant column
show(lambda: train_anfis("not an anfis", [[0.0]], [0.0]))
ValueError: budget must be an integer >= 1, got 0
ValueError: penalty must be > 0, got 0.0
ValueError: a_BW mismatch: best_to_others[b] = 2 but others_to_worst[a] = 8; they must be equal
ValueError: others_to_worst keys ['a', 'b'] must equal the criteria ['a', 'b', 'c']
ValueError: expected 3 values, got 2
TypeError: best_to_others[a] must be a TFN, got int
ValueError: best_to_others[b] must have l > 0, got TFN(l=0.0, m=1.0, u=2.0)
RuntimeError: ANFISProblem needs an ANFIS with input ranges: build it with ranges=...
ValueError: input 0 is constant in X (1); pass ranges=... explicitly
TypeError: anfis must be an ANFIS, got str

See also