LumiX bridge¶
bulanik.lumix connects bulanik to
LumiX, a Python modelling layer for linear and
integer programming. It adds three things: the linear best-worst method, Zimmermann's
fuzzy goal programming on top of your own LumiX model, and a converter that hands MCDM
scores to LumiX as plain data.
Install¶
pip install "bulanik[lumix]"
The lumix extra installs lumix-opt and OR-Tools, the default solver. From a checkout of
the repository use pip install -e ".[lumix]" (or uv sync, which includes them).
Importing bulanik.lumix without the extra raises an ImportError that names it. Every
solving function takes solver="ortools", the name of any LumiX solver.
Linear best-worst method¶
bwm_linear implements Rezaei's (2016) linear BWM. With the
best criterion \(B\), the worst \(W\) and the comparison vectors \(a_{Bj}\) (best_to_others) and
\(a_{jW}\) (others_to_worst), it solves the linear program
Each absolute value is modelled by two rows. The optimum \(\xi^L\) is 0 when the comparisons
are perfectly consistent and grows as they conflict. The best and worst criteria are
inferred as the unique criterion with comparison 1 in each vector, or given with best= and
worst= (needed when several criteria tie at 1). The comparison of best over worst must
agree in both vectors, \(a_{BW}\) = best_to_others[worst] = others_to_worst[best].
The result is a BWMResult; the LumiX model and solution
are kept in extras["model"] and extras["solution"], and a
ranking method accepts it directly as weights.
from bulanik.lumix import bwm_linear
consistent = bwm_linear({"price": 1, "quality": 2, "speed": 8},
{"price": 8, "quality": 4, "speed": 1})
print({k: round(v, 3) for k, v in consistent.weights.items()})
print(consistent.best, consistent.worst, round(consistent.xi, 4))
print(consistent.operators)
# a_Bb x a_bW = 2 x 3 = 6 but a_BW = 8: a small conflict
r = bwm_linear({"a": 1, "b": 2, "c": 8}, {"a": 8, "b": 3, "c": 1})
print(r.ranking, {k: round(v, 3) for k, v in r.weights.items()})
print(round(r.xi, 4), r.consistency_ratio == r.xi, sorted(r.extras))
{'price': 0.615, 'quality': 0.308, 'speed': 0.077}
price speed 0.0
{'model': 'linear', 'consistency': 'xi_L'}
('a', 'b', 'c') {'a': 0.625, 'b': 0.292, 'c': 0.083}
0.0417 True ['model', 'solution']
consistency_ratio is not the same number in every bridge
bwm_linear stores \(\xi^L\) itself in consistency_ratio (it equals xi, and
operators["consistency"] is "xi_L"). It is on the scale of the comparisons: 0 for
perfect consistency, then a few tenths to a few units, with no fixed upper bound. The
sezgi bridge stores a ratio under the same field name:
BWMProblem gives \(\xi / CI(a_{BW})\) and
FuzzyBWMProblem gives \(k / CI\), where \(CI\) is a
consistency index of the best-over-worst comparison (see
Weighting). Compare a linear \(\xi^L\) only with other
linear results, and read operators["consistency"] before comparing anything. The
sezgi bridge page has the
other side of this box.
Zimmermann's fuzzy goal programming¶
Sometimes a linear program has goals that are soft: "profit of about 20 or more, use of about 4 or less". Zimmermann's (1978) max-min approach gives each goal a membership function and maximises the smallest membership \(\lambda\) over all goals.
A FuzzyGoal describes a goal on a linear expression \(z\) with an
aspiration \(g\) and a tolerance \(t > 0\):
sense |
Reads | Membership of \(z\) |
|---|---|---|
"ge" |
at least about \(g\) | 0 at \(g - t\), rising linearly to 1 at \(g\) |
"le" |
at most about \(g\) | 1 at \(g\), falling linearly to 0 at \(g + t\) |
"eq" |
about \(g\) | $1 - |
zimmermann(model, goals) adds the rows to your model in
place and returns it. Each goal gets a free variable goal_<name>, a definition row
goal_<name>_def (\(z\) minus that variable is 0) and the membership row (_ge and/or
_le):
plus a variable \(0 \le \lambda \le 1\) (fuzzy_lambda, renamable) and the objective
\(\max \lambda\), which replaces any earlier objective. Your own variables and constraints
stay, so you can keep extending the model before solving.
solve_zimmermann does that call and solves, returning a
ZimmermannResult with lambda_, the goal_values, the
memberships at those values and the LumiX solution for your decision variables.
from lumix import LXConstraint, LXModel, LXVariable
from lumix.core import LXLinearExpression
from bulanik.lumix import FuzzyGoal, solve_zimmermann, zimmermann
def build():
x = LXVariable[None, float]("x").continuous().bounds(lower=0, upper=10)
y = LXVariable[None, float]("y").continuous().bounds(lower=0, upper=10)
use = LXLinearExpression().add_term(x, coeff=1).add_term(y, coeff=1)
model = (LXModel("plan").add_variable(x).add_variable(y)
.add_constraint(LXConstraint("cap").expression(use).le().rhs(8))) # at most 8 units
profit = LXLinearExpression().add_term(x, coeff=3).add_term(y, coeff=2)
goals = [FuzzyGoal("profit", profit, aspiration=20, tolerance=8, sense="ge"),
FuzzyGoal("use", use, aspiration=4, tolerance=4, sense="le")]
return model, goals
goal = build()[1][0]
print([goal.membership(z) for z in (12, 16, 20, 25)]) # 0 at g - t, 0.5, 1 at g
model, goals = build()
zimmermann(model, goals) # inspect what was added
print([v.name for v in model.variables])
print([c.name for c in model.constraints])
model, goals = build()
res = solve_zimmermann(model, goals)
print("lambda:", round(res.lambda_, 3))
print({k: round(v, 2) for k, v in res.goal_values.items()})
print({k: round(v, 2) for k, v in res.memberships.items()})
print("plan:", {n: round(res.solution.variables[n], 2) for n in ("x", "y")})
[0.0, 0.5, 1.0, 1.0]
['x', 'y', 'fuzzy_lambda', 'goal_profit', 'goal_use']
['cap', 'goal_profit_def', 'goal_profit_ge', 'goal_use_def', 'goal_use_le']
lambda: 0.6
{'profit': 16.8, 'use': 5.6}
{'profit': 0.6, 'use': 0.6}
plan: {'x': 5.6, 'y': 0.0}
Here \(\lambda = 0.6\): no plan does better than 60% satisfaction of the worse goal, and the optimum balances the two goals at exactly that level. Because the model is changed in place, build a fresh one if you want to solve a second time.
MCDM scores as LumiX data¶
scores_as_data converts a weighting, ranking or BWM result
(or a plain {name: score} mapping) into a list of
ScoreRecord with key, value (the raw weight or score) and
rank (1 is best). The records are what LXVariable(...).from_data(records) expects, so an
MCDM score can drive an optimisation model: score the suppliers, then choose the best
combination under a budget.
from lumix import LXConstraint, LXModel, LXOptimizer, LXVariable
from lumix.core import LXLinearExpression
from bulanik.lumix import scores_as_data
from bulanik.mcdm import swara, topsis
criteria = ["price", "quality", "delivery"]
suppliers = ["Atlas", "Borealis", "Cedar", "Delta"]
data = [[120, 8, 7], [100, 7, 5], [110, 9, 6], [130, 6, 9]]
ranking = topsis(data, swara([0.2, 0.5], criteria), ["cost", "benefit", "benefit"],
alternatives=suppliers)
records = scores_as_data(ranking)
for rec in records:
print(rec.key, round(rec.value, 4), rec.rank)
setup_cost = {"Atlas": 50, "Borealis": 25, "Cedar": 45, "Delta": 30}
pick = LXVariable[object, int]("pick").binary().indexed_by(lambda r: r.key).from_data(records)
model = (
LXModel("supplier_pair")
.add_variable(pick)
.add_constraint(LXConstraint("budget")
.expression(LXLinearExpression().add_term(pick, lambda r: setup_cost[r.key]))
.le().rhs(75))
.add_constraint(LXConstraint("at_most_two")
.expression(LXLinearExpression().add_term(pick, 1.0)).le().rhs(2))
.maximize(LXLinearExpression().add_term(pick, lambda r: r.value))
)
solution = LXOptimizer().use_solver("ortools").solve(model)
print(solution.is_optimal(), sorted(k for k, v in solution.variables["pick"].items() if v > 0.5))
Atlas 0.5231 2
Borealis 0.4199 4
Cedar 0.5978 1
Delta 0.4338 3
True ['Cedar', 'Delta']
The two best suppliers (Cedar and Atlas) cost 95 together, over the budget of 75, so the optimum is Cedar with Delta: the ranking alone would not have found it.
Records keep the order of the result's scores and take rank from result.ranking. A
plain mapping is ranked by descending value (ties keep input order). The value is the
method's raw score: for VIKOR, where lower is better, a model that maximises value
would prefer the worst alternative, so use rank or negate it.
Pitfalls¶
Two different consistency_ratios. See the box above: \(\xi^L\) here, a ratio in the
sezgi bridge.
zimmermann mutates your model. It adds variables and rows and replaces the
objective. Variable names fuzzy_lambda and goal_<name> must be free, goal names unique,
and a goal expression must be an LXLinearExpression.
A goal that cannot reach membership 0 is infeasible. If the hard constraints keep a
goal's expression beyond \(g - t\) (or \(g + t\)), solve_zimmermann raises RuntimeError with
the solver status. Widen the tolerance or relax the constraint.
Solver failures are RuntimeErrors, never silent: bwm_linear and
solve_zimmermann raise if the solver finds no optimum.
BWM inputs. Comparisons are finite numbers at least 1; a missing best or worst (or an
ambiguous one) is a ValueError that tells you to pass best= or worst=.
from lumix import LXConstraint, LXModel, LXVariable
from lumix.core import LXLinearExpression
from bulanik.lumix import FuzzyGoal, bwm_linear, scores_as_data, solve_zimmermann
def show(make):
try:
print(make())
except Exception as error:
print(f"{type(error).__name__}: {error}")
show(lambda: bwm_linear({"a": 1, "b": 2, "c": 8}, {"a": 8, "b": 4, "c": 1}, best="b"))
show(lambda: bwm_linear({"a": 1, "b": 1, "c": 8}, {"a": 8, "b": 8, "c": 1})) # best is ambiguous
show(lambda: bwm_linear({"a": 1, "b": 2, "c": 8}, {"a": 8, "b": 4, "c": 2})) # nobody has 1
show(lambda: bwm_linear({"a": 1, "b": 0.5, "c": 8}, {"a": 8, "b": 4, "c": 1})) # below 1
show(lambda: bwm_linear({"a": 1, "b": 2, "c": 8}, {"a": 8, "b": 4, "c": 1}, solver="nosuchsolver"))
show(lambda: scores_as_data([1, 2, 3]))
show(lambda: scores_as_data({"a": float("nan")}))
x = LXVariable[None, float]("x").continuous().bounds(lower=0, upper=10)
z = LXLinearExpression().add_term(x, coeff=1)
show(lambda: FuzzyGoal("g", z, aspiration=5, tolerance=0, sense="ge")) # tolerance > 0
show(lambda: FuzzyGoal("g", z, aspiration=5, tolerance=1, sense="about"))
show(lambda: FuzzyGoal("g", "x", aspiration=5, tolerance=1, sense="ge"))
model = LXModel("m").add_variable(x).add_constraint(
LXConstraint("low").expression(z).le().rhs(2))
far = FuzzyGoal("far", z, aspiration=20, tolerance=4, sense="ge") # needs x >= 16
show(lambda: solve_zimmermann(model, [far]))
ValueError: best_to_others[b] must be 1.0 for the best criterion, got 2
ValueError: several criteria have best_to_others value 1.0 (a, b); pass best= to choose the best criterion
ValueError: no criterion has others_to_worst value 1.0; cannot infer the worst
ValueError: best_to_others[b] must be >= 1, got 0.5
ValueError: Unknown solver: nosuchsolver
TypeError: scores_as_data expects a WeightingResult, RankingResult, BWMResult or a mapping of scores, got list
ValueError: score of a must be finite, got nan
ValueError: FuzzyGoal 'g': tolerance must be > 0, got 0.0
ValueError: unknown sense 'about' for FuzzyGoal; valid: ge, le, eq
TypeError: FuzzyGoal 'g': expression must be an LXLinearExpression, got str
RuntimeError: zimmermann: solver did not find an optimum (status infeasible)
See also¶
- Weighting for the BWM input and its consistency indices,
Ranking to use a
BWMResultas weights, and the sezgi bridge for the nonlinear and fuzzy BWM. - API:
bulanik.lumix. - Examples: 06 Zimmermann, 07 BWM with LumiX, 09 weights to LumiX.
- Rezaei (2016), "Best-worst multi-criteria decision-making method: some properties and a linear model", Omega 64; Zimmermann (1978), "Fuzzy programming and linear programming with several objective functions", Fuzzy Sets and Systems 1(1).