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
Frida        M
Tarakon      M
Cucafera     L
Dominos     VL
Caleta      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

TOPSIS

TOPSIS is an aggregation technique which compares weighted normalized alternative values to fictive alternatives called positive (respectively negative) solution corresponding to the best (resp. worst) criterion values taken from the input performance table.

[28]:
from mcda.mavt.aggregators import TOPSIS

The only argument needed to setup TOPSIS is the criteria weights.

[29]:
criteria_weights = {
    criteria[0]: 1,
    criteria[1]: 5,
    criteria[2]: 2,
    criteria[3]: 1,
    criteria[4]: 4,
    criteria[5]: 4
}
topsis = TOPSIS(criteria_weights)

Note: the weights are internally normalized before application to the table of normalized values.

[30]:
topsis.normalized_weights
[30]:
{'MaximalSpeed': 0.058823529411764705,
 'ConsumptionTown': 0.29411764705882354,
 'Consumption120kmh': 0.11764705882352941,
 'HP': 0.058823529411764705,
 'Space': 0.23529411764705882,
 'Price': 0.23529411764705882}

Then you can apply TOPSIS aggregation to the performance table, and check its resulting scores (higher is better):

[31]:
result = topsis(performance_table).sort()
print(result.data)
Citroen Dyane             0.738436
Citroen Visa Super E      0.700240
Peugeot 104 ZS            0.651177
VW Golf 1300 GLS          0.592510
Peugeot 505 GR            0.524999
Opel Record 2000 LS       0.515510
Volvo 244 DL              0.387210
Citroen CX 2400 Pallas    0.326306
BMW 520                   0.317553
Mercedes 230              0.316707
dtype: float64

There are various methods used inside the TOPSIS algorithm. here are some examples:

TOPSIS normalization method:

[32]:
TOPSIS.normalize(performance_table).data
[32]:
MaximalSpeed ConsumptionTown Consumption120kmh HP Space Price
Peugeot 505 GR 0.338750 0.310784 0.313363 0.327385 0.344570 0.302964
Opel Record 2000 LS 0.344624 0.335319 0.328077 0.360124 0.348068 0.285826
Citroen Visa Super E 0.278049 0.223546 0.228527 0.163693 0.247059 0.196467
VW Golf 1300 GLS 0.289797 0.286248 0.300841 0.229170 0.268922 0.239617
Citroen CX 2400 Pallas 0.348540 0.395295 0.345920 0.425601 0.352441 0.395995
Mercedes 230 0.352456 0.370759 0.325572 0.425601 0.370369 0.463320
BMW 520 0.356373 0.346224 0.383799 0.360124 0.341509 0.419822
Volvo 244 DL 0.283923 0.389843 0.405400 0.360124 0.366434 0.336626
Peugeot 104 ZS 0.315253 0.234451 0.263588 0.229170 0.223446 0.215441
Citroen Dyane 0.229097 0.196284 0.211309 0.098216 0.254055 0.151788

positive and negative ideal solutions used by TOPSIS:

[33]:
TOPSIS.positive_ideal_solution(performance_table).data
[33]:
MaximalSpeed           182.00
ConsumptionTown          7.20
Consumption120kmh        6.75
HP                      13.00
Space                    8.47
Price                24800.00
dtype: float64
[34]:
TOPSIS.negative_ideal_solution(performance_table).data
[34]:
MaximalSpeed           117.00
ConsumptionTown         14.50
Consumption120kmh       12.95
HP                       3.00
Space                    5.11
Price                75700.00
dtype: float64

Disaggregators

UTA

[35]:
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

[36]:
criteria_segments = {
    criteria[0]: 5,
    criteria[1]: 4,
    criteria[2]: 4,
    criteria[3]: 5,
    criteria[4]: 4,
    criteria[5]: 5
}
[37]:
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:

[38]:
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/Development/pymcda/.venv/lib/python3.10/site-packages/pulp/apis/../solverdir/cbc/linux/64/cbc /tmp/e5b2d5248e714613acbbfbd174e74817-pulp.mps timeMode elapsed branch printingOptions all solution /tmp/e5b2d5248e714613acbbfbd174e74817-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.

[39]:
import mcda.plot as pplot
[40]:
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.

[41]:
uta_values = value_functions(performance_table)
uta_values.data
[41]:
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.

[42]:
alternatives_grades = uta_values.sum(1)
alternatives_grades.data
[42]:
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.

[43]:
alternatives_grades.sort().data
[43]:
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:

[44]:
uta.objective
[44]:
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*

[45]:
from mcda.mavt.uta import UTAstar
[46]:
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/Development/pymcda/.venv/lib/python3.10/site-packages/pulp/apis/../solverdir/cbc/linux/64/cbc /tmp/75a8f2f571354b3c88ae6ec06672fe91-pulp.mps timeMode elapsed branch printingOptions all solution /tmp/75a8f2f571354b3c88ae6ec06672fe91-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.8786125e-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