Multi Attribute Value Theory

This notebook is dedicated to the use of MAVT algorithms, including aggregators like the weighted sum, but also the disaggregators of the UTA algorithmic family.

[1]:
%matplotlib notebook
%config Completer.use_jedi = False

MAVT problem formalization

We first need to define our decision problem within the MAVT framework: * Alternatives * Criteria * Performance table * Criteria scales

The following uses the car choice example from:

  1. Jacquet-Lagrèze and Y. Siskos. Assessing a set of additive utility functions for multicriteria decision making: The UTA method. European Journal of Operational Research, 10(2): 151–164, 1982

[2]:
from mcda import PerformanceTable
from mcda.scales import *
from mcda.relations import *
[3]:
alternatives = [
    "Peugeot 505 GR",
    "Opel Record 2000 LS",
    "Citroen Visa Super E",
    "VW Golf 1300 GLS",
    "Citroen CX 2400 Pallas",
    "Mercedes 230",
    "BMW 520",
    "Volvo 244 DL",
    "Peugeot 104 ZS",
    "Citroen Dyane"
]
[4]:
criteria = [
    "MaximalSpeed",
    "ConsumptionTown",
    "Consumption120kmh",
    "HP",
    "Space",
    "Price"
]
[5]:
scales = {
    criteria[0]: QuantitativeScale(110, 190),
    criteria[1]: QuantitativeScale(7, 15, preference_direction=MIN),
    criteria[2]: QuantitativeScale(6, 13, preference_direction=MIN),
    criteria[3]: QuantitativeScale(3, 13),
    criteria[4]: QuantitativeScale(5, 9),
    criteria[5]: QuantitativeScale(20000, 80000, preference_direction=MIN)
}
[6]:
performance_table = PerformanceTable(
    [
        [173, 11.4, 10.01, 10, 7.88, 49500],
        [176, 12.3, 10.48, 11, 7.96, 46700],
        [142, 8.2, 7.3, 5, 5.65, 32100],
        [148, 10.5, 9.61, 7, 6.15, 39150],
        [178, 14.5, 11.05, 13, 8.06, 64700],
        [180, 13.6, 10.4, 13, 8.47, 75700],
        [182, 12.7, 12.26, 11, 7.81, 68593],
        [145, 14.3, 12.95, 11, 8.38, 55000],
        [161, 8.6, 8.42, 7, 5.11, 35200],
        [117, 7.2, 6.75, 3, 5.81, 24800]
    ],
    alternatives=alternatives,
    criteria=criteria,
    scales=scales
)

Aggregators

For all aggregators, we can define their input and output scales. This is purely optional, they are inferred from data otherwise. Input data is neither checked nor modified to belong in the input scales, they are purely informative.

Weighted sum

[7]:
from mcda.mavt.aggregators import WeightedSum
from mcda import normalize

Which is perhaps the easiest and simplest MCDA method requires setting the user preferences using a set of criterion weights:

[8]:
criteria_weights = {
    criteria[0]: 1,
    criteria[1]: 5,
    criteria[2]: 2,
    criteria[3]: 1,
    criteria[4]: 4,
    criteria[5]: 4
}
weighted_sum = WeightedSum(criteria_weights)

Then all is set to apply the weighted sum.

[9]:
alternatives_grades = weighted_sum(normalize(performance_table))
alternatives_grades.data
[9]:
Peugeot 505 GR             9.505119
Opel Record 2000 LS        9.212500
Citroen Visa Super E      10.321905
VW Golf 1300 GLS           8.529405
Citroen CX 2400 Pallas     6.799643
Mercedes 230               7.249524
BMW 520                    6.919395
Volvo 244 DL               6.735952
Peugeot 104 ZS             9.442738
Citroen Dyane             11.238214
dtype: float64

We have obtained the alternatives’ scores, according to the criteria weights chosen.

We can now sort the alternatives according to these scores.

[10]:
alternatives_grades.sort().data
[10]:
Citroen Dyane             11.238214
Citroen Visa Super E      10.321905
Peugeot 505 GR             9.505119
Peugeot 104 ZS             9.442738
Opel Record 2000 LS        9.212500
VW Golf 1300 GLS           8.529405
Mercedes 230               7.249524
BMW 520                    6.919395
Citroen CX 2400 Pallas     6.799643
Volvo 244 DL               6.735952
dtype: float64

Choquet integral

[11]:
from mcda.set_functions import SetFunction
from mcda.mavt.aggregators import ChoquetIntegral

We need to define the capacity related to the set of criteria of the problem. This version of the package has not yet implemented a way to elicit a capacity. Hence the following test will be achieved with the uniform capacity:

[12]:
capacity = SetFunction.uniform_capacity(criteria)

Now we can compute the aggregated score of each alternative using the Choquet integral on the normalized performance table with the uniform capacity.

[13]:
choquet_integral_capacity = ChoquetIntegral(capacity)
alternatives_grades = choquet_integral_capacity(normalize(performance_table))
alternatives_grades.data
[13]:
Peugeot 505 GR            0.598829
Opel Record 2000 LS       0.602917
Citroen Visa Super E      0.537520
VW Golf 1300 GLS          0.481687
Citroen CX 2400 Pallas    0.535179
Mercedes 230              0.560099
BMW 520                   0.497638
Volvo 244 DL              0.432302
Peugeot 104 ZS            0.544325
Citroen Dyane             0.512976
dtype: float64

N.B: Choquet integral can also be computed using a capacity Möbius representation

[14]:
choquet_integral_mobius = ChoquetIntegral(capacity.mobius)
alternatives_grades = choquet_integral_mobius(normalize(performance_table))
alternatives_grades.data
[14]:
Peugeot 505 GR            0.598829
Opel Record 2000 LS       0.602917
Citroen Visa Super E      0.537520
VW Golf 1300 GLS          0.481687
Citroen CX 2400 Pallas    0.535179
Mercedes 230              0.560099
BMW 520                   0.497638
Volvo 244 DL              0.432302
Peugeot 104 ZS            0.544325
Citroen Dyane             0.512976
dtype: float64

We have obtained the alternatives’ scores, according to the capacity chosen and the Choquet integral as an aggregator.

We can now sort the alternatives according to these scores.

[15]:
alternatives_grades.sort().data
[15]:
Opel Record 2000 LS       0.602917
Peugeot 505 GR            0.598829
Mercedes 230              0.560099
Peugeot 104 ZS            0.544325
Citroen Visa Super E      0.537520
Citroen CX 2400 Pallas    0.535179
Citroen Dyane             0.512976
BMW 520                   0.497638
VW Golf 1300 GLS          0.481687
Volvo 244 DL              0.432302
dtype: float64

OWA

[16]:
from mcda.mavt.aggregators import OWA

We need to define OWA weights. Here is an example with the extreme and weights:

[17]:
owa = OWA.and_aggregator(len(criteria))

We can use OWA to aggregates normalized performances into alternatives’ grades:

[18]:
alternatives_grades = owa(normalize(performance_table))
alternatives_grades.data
[18]:
Peugeot 505 GR            0.427143
Opel Record 2000 LS       0.337500
Citroen Visa Super E      0.162500
VW Golf 1300 GLS          0.287500
Citroen CX 2400 Pallas    0.062500
Mercedes 230              0.071667
BMW 520                   0.105714
Volvo 244 DL              0.007143
Peugeot 104 ZS            0.027500
Citroen Dyane             0.000000
dtype: float64

We have obtained the alternatives’ scores, according to the capacity chosen and the Choquet integral as an aggregator.

We can now sort the alternatives according to these scores.

[19]:
alternatives_grades.sort().data
[19]:
Peugeot 505 GR            0.427143
Opel Record 2000 LS       0.337500
VW Golf 1300 GLS          0.287500
Citroen Visa Super E      0.162500
BMW 520                   0.105714
Mercedes 230              0.071667
Citroen CX 2400 Pallas    0.062500
Peugeot 104 ZS            0.027500
Volvo 244 DL              0.007143
Citroen Dyane             0.000000
dtype: float64

ULOWA

[20]:
from pandas import Series
from mcda.functions import FuzzyNumber
from mcda.scales import FuzzyScale
from mcda.mavt.aggregators import ULOWA

We need to define a ULOWA problem, as well as the weights used during the aggregation. For this we define a fuzzy partition using fuzzy numbers, which we associate to labels inside a fuzzy scale:

[21]:
fuzzy_sets = [
    FuzzyNumber([0.0, 0.0, 0.0, 2.0]),
    FuzzyNumber([0.0, 2.0, 2.0, 5.0]),
    FuzzyNumber([2.0, 5.0, 5.0, 6.0]),
    FuzzyNumber([5.0, 6.0, 6.0, 7.0]),
    FuzzyNumber([6.0, 7.0, 8.0, 9.0]),
    FuzzyNumber([8.0, 9.0, 9.0, 10.0]),
    FuzzyNumber([9.0, 10.0, 10.0, 10.0])
]
labels = ["VL", "L", "M", "AH", "H", "VH", "P"]
uscale = FuzzyScale(Series(fuzzy_sets, index=labels))

uweights = [0.0, 0.0, 0.5, 0.5, 0.0]

ualternatives = ["Caleta", "Tarakon", "Dominos", "Ancora", "Frida", "Cucafera"]
ucriteria = ["Food", "Service", "Atmosphere", "Category", "Location"]

uperformance_table = PerformanceTable(
    [
        ["VL", "VL", "P", "H", "VL"],
        ["VL", "VL", "H", "P", "P"],
        ["VL", "VL", "L", "M", "L"],
        ["VH", "L", "H", "H", "AH"],
        ["P", "L", "H", "L", "AH"],
        ["P", "VL", "VL", "M", "AH"]
    ],
    alternatives=ualternatives,
    criteria=ucriteria
)

ulowa = ULOWA(uweights, uscale)

As ULOWA has been designed for fuzzy partition, we can check that our fuzzy scale does define such partition:

[22]:
uscale.is_fuzzy_partition
[22]:
True

Now, we can compute the ULOWA result for each alternative.

[23]:
results1 = ulowa(uperformance_table)
results1.data
[23]:
Caleta      VL
Tarakon      M
Dominos     VL
Ancora      AH
Frida        M
Cucafera     L
dtype: object

Note that this corresponds to a ranking, as those values can be ordered:

[24]:
results1.sort().data
[24]:
Ancora      AH
Tarakon      M
Frida        M
Cucafera     L
Caleta      VL
Dominos     VL
dtype: object

We can also compute some measures to characterize the fuzzy numbers w.r.t to the boundaries of the scale.

[25]:
data = [uscale.fuzziness(fz) for fz in fuzzy_sets]
print(data)
[0.1, 0.25, 0.2, 0.1, 0.1, 0.1, 0.05]
[26]:
data = [uscale.specificity(fz) for fz in fuzzy_sets]
print(data)
[0.9, 0.75, 0.8, 0.9, 0.8, 0.9, 0.95]

Now let’s change the weights and recompute ULOWA.

[27]:
uweights = [0.2, 0.6, 0.2, 0.0, 0.0]
ulowa2 = ULOWA(uweights, uscale)
results2 = ulowa2(uperformance_table)
results2.data
[27]:
Caleta      AH
Tarakon     VH
Dominos      L
Ancora       H
Frida        H
Cucafera    AH
dtype: object

Disaggregators

UTA

[28]:
from mcda.mavt.uta import UTA
from mcda.relations import PreferenceRelation, PreferenceStructure

This method infers the user model of preferences as marginal utility functions, which can then be used to score each alternatives’ criterion performance. We can infer the global score of each alternative by summing those marginal utility values.

Those marginal utility functions are piecewise linear functions.

The algorithm requires the following: * The number of segments to use for each marginal utility function (per criterion) * The pair-wise preference/indifference relations representing the user preferences

[29]:
criteria_segments = {
    criteria[0]: 5,
    criteria[1]: 4,
    criteria[2]: 4,
    criteria[3]: 5,
    criteria[4]: 4,
    criteria[5]: 5
}
[30]:
relations = PreferenceStructure()
for i in range(len(alternatives)-1):
    relations += P(alternatives[i], alternatives[i+1])
# Meaning alternatives are already ordered by preference

We have everything we need to apply UTA:

[31]:
uta = UTA(performance_table, relations, criteria_segments, delta=0.01)
# 'delta' is the preference threshold
value_functions = uta.disaggregate()
Welcome to the CBC MILP Solver
Version: 2.10.3
Build Date: Dec 15 2019

command line - /home/nduminy/.pyenv/versions/3.10.7/envs/mcda-test/lib/python3.10/site-packages/pulp/solverdir/cbc/linux/64/cbc /tmp/62bc3880426e4fe1b6a53803676d36de-pulp.mps timeMode elapsed branch printingOptions all solution /tmp/62bc3880426e4fe1b6a53803676d36de-pulp.sol (default strategy 1)
At line 2 NAME          MODEL
At line 3 ROWS
At line 48 COLUMNS
At line 305 RHS
At line 349 BOUNDS
At line 350 ENDATA
Problem MODEL has 43 rows, 43 columns and 231 elements
Coin0008I MODEL read with 0 errors
Option for timeMode changed from cpu to elapsed
Presolve 28 (-15) rows, 33 (-10) columns and 188 (-43) elements
Perturbing problem by 0.001% of 0.93732919 - largest nonzero change 4.9244589e-05 ( 0.0083799674%) - largest zero change 4.8475482e-05
0  Obj 0 Primal inf 3.2519376 (9)
22  Obj 5.7170759e-05
Optimal - objective value 0
After Postsolve, objective 0, infeasibilities - dual 0 (0), primal 0 (0)
Optimal objective 0 - 22 iterations time 0.002, Presolve 0.00
Option for printingOptions changed from normal to all
Total time (CPU seconds):       0.00   (Wallclock seconds):       0.00

We can plot those marginal value functions for a better understanding of UTA’s results.

[32]:
import mcda.plot as pplot
[33]:
fig = pplot.Figure(ncols=2)
for i in criteria:
    x = value_functions.in_scales[i].range(500)
    y = [value_functions.functions[i](xx) for xx in x]
    ax = fig.create_add_axis()
    ax.title = i
    ax.add_plot(pplot.LinePlot(x, y))
fig.draw()

We can apply those marginal value functions as criteria functions to the performance table, to obtain the table of marginal scores.

[34]:
uta_values = value_functions(performance_table)
uta_values.data
[34]:
MaximalSpeed ConsumptionTown Consumption120kmh HP Space Price
Peugeot 505 GR 0.368427 0.021762 0.013051 -0.000000 0.064203 0.037012
Opel Record 2000 LS 0.370797 0.009521 0.012922 -0.000000 0.064203 0.037012
Citroen Visa Super E 0.332877 0.027202 0.045632 -0.000000 0.041732 0.037012
VW Golf 1300 GLS 0.332877 0.027202 0.013161 -0.000000 0.064203 0.037012
Citroen CX 2400 Pallas 0.370797 -0.000000 0.012766 0.006511 0.064203 0.010178
Mercedes 230 0.370797 -0.000000 0.012944 0.006511 0.064203 -0.000000
BMW 520 0.370797 0.004080 0.005375 -0.000000 0.064203 -0.000000
Volvo 244 DL 0.332877 -0.000000 0.000363 -0.000000 0.064203 0.037012
Peugeot 104 ZS 0.339987 0.027202 0.013191 -0.000000 0.007062 0.037012
Citroen Dyane -0.000000 0.027202 0.085281 -0.000000 0.052004 0.249967

We can apply the aggregator returned by UTA to obtain alternatives scores. You can see that in our case, UTA keeps the same order of alternatives.

[35]:
alternatives_grades = uta_values.sum(1)
alternatives_grades.data
[35]:
Peugeot 505 GR            0.504455
Opel Record 2000 LS       0.494455
Citroen Visa Super E      0.484455
VW Golf 1300 GLS          0.474455
Citroen CX 2400 Pallas    0.464455
Mercedes 230              0.454455
BMW 520                   0.444455
Volvo 244 DL              0.434455
Peugeot 104 ZS            0.424455
Citroen Dyane             0.414455
dtype: float64

We can now sort the alternatives according to these scores.

[36]:
alternatives_grades.sort().data
[36]:
Peugeot 505 GR            0.504455
Opel Record 2000 LS       0.494455
Citroen Visa Super E      0.484455
VW Golf 1300 GLS          0.474455
Citroen CX 2400 Pallas    0.464455
Mercedes 230              0.454455
BMW 520                   0.444455
Volvo 244 DL              0.434455
Peugeot 104 ZS            0.424455
Citroen Dyane             0.414455
dtype: float64

You can look at the base LP problem using the attribute problem, and also directly look at the objective value:

[37]:
uta.objective
[37]:
0.0

N.B: if using post-optimality, the property objective will refer to the post-optimal solution objective value. You can still view the base problem objective va

UTA*

[38]:
from mcda.mavt.uta import UTAstar
[39]:
uta_star = UTAstar(performance_table, relations, criteria_segments, delta=0.01)
# 'delta' is the preference threshold
value_functions = uta_star.disaggregate()
Welcome to the CBC MILP Solver
Version: 2.10.3
Build Date: Dec 15 2019

command line - /home/nduminy/.pyenv/versions/3.10.7/envs/mcda-test/lib/python3.10/site-packages/pulp/solverdir/cbc/linux/64/cbc /tmp/379652fec47e4ec6b6168b29b6e60e23-pulp.mps timeMode elapsed branch printingOptions all solution /tmp/379652fec47e4ec6b6168b29b6e60e23-pulp.sol (default strategy 1)
At line 2 NAME          MODEL
At line 3 ROWS
At line 15 COLUMNS
At line 344 RHS
At line 355 BOUNDS
At line 356 ENDATA
Problem MODEL has 10 rows, 53 columns and 174 elements
Coin0008I MODEL read with 0 errors
Option for timeMode changed from cpu to elapsed
Presolve 10 (0) rows, 44 (-9) columns and 164 (-10) elements
Perturbing problem by 0.001% of 4.4984539 - largest nonzero change 6.8916459e-05 ( 0.0015320032%) - largest zero change 6.8191646e-05
0  Obj 0 Primal inf 0.089991 (9)
13  Obj 3.8813844e-06
Optimal - objective value 0
After Postsolve, objective 0, infeasibilities - dual 0 (0), primal 0 (0)
Optimal objective 0 - 13 iterations time 0.002, Presolve 0.00
Option for printingOptions changed from normal to all
Total time (CPU seconds):       0.00   (Wallclock seconds):       0.00

[40]:
alternatives_grades = value_functions.aggregate(value_functions(performance_table))
alternatives_grades.data
[40]:
Peugeot 505 GR            0.951774
Opel Record 2000 LS       0.941774
Citroen Visa Super E      0.931774
VW Golf 1300 GLS          0.921774
Citroen CX 2400 Pallas    0.911774
Mercedes 230              0.901774
BMW 520                   0.891774
Volvo 244 DL              0.881774
Peugeot 104 ZS            0.871774
Citroen Dyane             0.861774
dtype: float64