Extending Aggregation and Transformation Functions¶
This tutorial serves as a guide for utilizing the extension tools for aggregation and transformer functions in Scikit-Criteria. After going through this tutorial, you will be able to implement your own multi-criteria decision models compatible with the data types and tools provided by the library.
1. Introduction¶
In Scikit-Criteria, leveraging the provided decorators (@extend.mkagg
and @extend.mktransformer
) for extending aggregation and transformation functions provides a powerful means to customize decision-making models allowing the creation of custom functions, enabling domain-specific logic implementation for diverse use cases.
Decorators simplify the process of converting functions into model classes, promoting flexibility in model creation without complex class hierarchies. This facilitates quick prototyping and experimentation by allowing direct modification of functions. Additionally, the decorators handle hyperparameter initialization, encapsulating them within models, promoting clean, organized code and reducing the chances of errors related to parameter handling.
Example Usage:
[1]:
# Import the decorators from the module
from skcriteria.extend import mkagg, mktransformer
# Define custom aggregation and transformation functions
@mkagg
def CustomAggregation(**kwargs):
# Implement aggregation logic
pass
@mktransformer
def CustomTransformation(**kwargs):
# Implement transformation logic
pass
While this code is syntactically valid, attempting to use it may not work as intended since it doesn’t return the required values.
2. A New Aggregation Model¶
To create a custom aggregation model, follow these steps:
Declare a function with the name of your model using the CapWords/UpperCamelCase/PascalCase convention. While this is not mandatory, not adhering to this convention will trigger a warning message from scikit-criteria, notifying that the model name does not follow the Scikit-Criteria standard.
[2]:
@mkagg
def bad_model_name(**kwargs):
pass
/home/juanbc/proyectos/skcriteria/src/skcriteria/extend.py:211: NonStandardNameWarning: Models names should normally use the 'CapWords' convention.try change 'bad_model_name' to 'Bad_Model_Name' or 'BAD_MODEL_NAME'
return _agg_maker if maybe_func is None else _agg_maker(maybe_func)
[3]:
@mkagg
def GodModelName(**kwargs):
pass
The function should take parameters representing the decomposed decision matrix after calling the
DecisionMatrix.to_dict()
method, and a parameterhparams
, which will be explained later and contains the hyper-parameters of the model.
hparams
: Model Hyperparameters.matrix
: Alternatives matrix as numpy array.objectives
: numpy array of objectives for criteria as integers: \(maximize = 1\) and \(minimize = -1\).weights
: Weights of the criteria as numpy array.dtypes
: Data types of the criteria as numpy array.alternatives
: Names of the alternatives as numpy array.criteria
: Names of the criteria as numpy array.
Additionally, if you do not want to use any of those parameters of the matrix, you can declare the function with Variable Keyword Arguments (**kwargs).
If any parameter is forgotten and **kwargs
is not present, a TypeError is raised.
So this next two functions are a valid Aggregation functions
[4]:
@mkagg
def AllParameters(hparams, matrix, objectives, weights, dtypes, alternatives, criteria):
pass
@mkagg
def OnlyTwoWithKwargs(matrix, weights, **kwargs):
pass
Utilizing the received parameters, the function should return two objects:
A
numpy.array
/list
/tuple
or any kind of sequence containing a valid ranking. Where thei
-th position in the returned sequence has the ranking value for thei
-th alternative in the array of alternatives received as a parameter.A
dict
with extra values from the ranking (intermediate results or other useful data for decision-making analysis).
Note: Understanding the Rankings
A valid ranking has the following conditions:
Length: It should have the same length as the number of alternatives received by the function.
Ascending and Consecutive Order: The values must be in ascending order and consecutive. This means that values should start from 1 and increase by increments of 1 without skips For example,
[1, 2, 3, 4]
and[1, 2, 1]
is valid, but[4, 2, 4, 1]
is not valid because the value 3 is missing.Integers Only: Values must be integers. Fractional or other types of values are not allowed.
So if we have the alternatives ["banana", "apple", "orange"]
and the ranking [1, 2, 1]
The meaning of the ranking in relation to the alternatives is as follows:
The first position in the ranking is 1, indicating that the alternative in the first position is the most preferred or the best choice.
The second position in the ranking is 2, suggesting that the alternative in the second position is the second-best choice.
The third position in the ranking is also 1, implying that the alternative in the third position is equally preferred to the alternative in the first position.
Therefore, the ranking [1, 2, 1]
could be interpreted as stating that “Banana” and “Orange” are equally preferred, and “Apple” is the second preferred choice. It’s important to note that the ranking must adhere to the specific conditions mentioned in the definitions, such as the correct length, ascending and consecutive order, and integer values.
With all of this, a complete and valid aggregation function would be:
[5]:
import numpy as np
@mkagg
def AllAlternativesAreFirst(alternatives, **kwargs):
# Assign a rank of 1 to each alternative
rank = [1] * len(alternatives)
# Define extra information (example: some important value)
extra = {"some_important_value": "the_important_value"}
# Return the rank and extra information
return rank, extra
Let’s test the new aggregation with a dataset.
[6]:
import skcriteria as skc
dm = skc.datasets.load_simple_stock_selection() # load the dataset
dm
[6]:
ROE[▲ 2.0] | CAP[▲ 4.0] | RI[▼ 1.0] | |
---|---|---|---|
PE | 7 | 5 | 35 |
JN | 5 | 4 | 26 |
AA | 5 | 6 | 28 |
FX | 3 | 4 | 36 |
MM | 1 | 7 | 30 |
GN | 5 | 8 | 30 |
[7]:
# Instantiate the new aggregation
agg = AllAlternativesAreFirst()
agg
[7]:
<AllAlternativesAreFirst []>
[8]:
# evaluate
rank = agg.evaluate(dm)
rank
[8]:
Alternatives | PE | JN | AA | FX | MM | GN |
---|---|---|---|---|---|---|
Rank | 1 | 1 | 1 | 1 | 1 | 1 |
[9]:
rank.e_.some_important_value
[9]:
'the_important_value'
3. Hyperparameters¶
The Hyper-parameters (in the context of machine learning) are parameters that allow you to specify details on how the function will carry out its aggregation. In this sense, they are more similar to Free-Parameters as they cannot be predicted or constrained by the model.
In Scikit-Criteria, we define the concept of Hyper-parameters similar to the Hyper-parameters in Scikit-Learn: Parameters received by the model’s (Aggregation function class) constructor and always should have some default value.
For example, in the case of Scikit-Criteria’s implementation of TOPSIS, it has a hyper-parameter for the metric it will use, and by default, it is set to "euclidean"
.
[10]:
from skcriteria.agg import similarity
similarity.TOPSIS()
[10]:
<TOPSIS [metric='euclidean']>
[11]:
similarity.TOPSIS(metric="cityblock")
[11]:
<TOPSIS [metric='cityblock']>
The hyper-parameters can be provided as named parameters to the @mkagg
decorator, and their values can be accessed using the hparams
parameter.
Note: Regarding the nature of hparams
If you are familiar with how methods work in Python classes, hparams
is essentially the self
of the model.
Now, for example, if we want to create a model named MaybeWSM
, which is a weighted-sum-model that uses weights only when the use_weight
hyperparameter is set to True
, and the default value is indeed True
.
[12]:
import numpy as np
from skcriteria.utils import rank
@mkagg(use_weights=True)
def MaybeWSM(hparams, matrix, objectives, weights, **kwargs):
"""The Maybe-Weighted Sum Model (WSM) to rank alternatives.
If the use_weights parameter in hparams is set to True, the
function applies weights to the decision matrix. This is done
by taking the inner product of the matrix and the weights vector.
"""
# Check if objectives contain -1 (minimize objectives)
if -1 in objectives:
raise ValueError("'MaybeWSM' cant operate with minimize objectives")
# If use_weights is True, apply weights to the matrix
if hparams.use_weights:
matrix = matrix * weights
# Calculate the scores by row/alternative
score = np.sum(matrix, axis=1)
# rank_values calculates the ranking based on the scores.
# `reverse = True` indicates that higher scores are closer to the 1st place.
# Additionally, we will return the calculated 'score' as extra information.
return rank.rank_values(score, reverse=True), {"score": score}
Let’s use our MaybeWSM model.
First, let’s see what happens if we create a MaybeWSM
with the default (use_weights=True
) and try to evaluate the available decision matrix (dm
).
[13]:
with_useweight = MaybeWSM()
with_useweight
[13]:
<MaybeWSM [use_weights=True]>
If we use dm
as it is right now, we will get an exception: 'MaybeWSM' can't operate with minimize objectives
because, indeed, dm
has some criteria to minimize.
[14]:
dm.minwhere # the critetia to minimize
[14]:
ROE False
CAP False
RI True
Name: minwhere, dtype: bool
For this reason, first, we will use the InvertMinimize
transformer to eliminate criteria to minimize.
[15]:
from skcriteria.preprocessing import invert_objectives
dm = invert_objectives.InvertMinimize().transform(dm)
dm.minwhere
[15]:
ROE False
CAP False
RI False
Name: minwhere, dtype: bool
[16]:
rank_with_uw = with_useweight.evaluate(dm)
rank_with_uw
[16]:
Alternatives | PE | JN | AA | FX | MM | GN |
---|---|---|---|---|---|---|
Rank | 3 | 5 | 2 | 6 | 4 | 1 |
Now, let’s try use_weights=False
.
[17]:
without_useweight = MaybeWSM(use_weights=False)
without_useweight
[17]:
<MaybeWSM [use_weights=False]>
[18]:
rank_without_uw = without_useweight.evaluate(dm)
rank_without_uw
[18]:
Alternatives | PE | JN | AA | FX | MM | GN |
---|---|---|---|---|---|---|
Rank | 2 | 4 | 3 | 6 | 5 | 1 |
It can be seen that depending on the configuration of the hyperparameter use_weights
, the results are different.
In addition to this, the score
is available within extra_
.
[19]:
rank_with_uw.e_.score, rank_without_uw.e_.score
[19]:
(array([34.02857143, 26.03846154, 34.03571429, 22.02777778, 30.03333333,
42.03333333]),
array([12.02857143, 9.03846154, 11.03571429, 7.02777778, 8.03333333,
13.03333333]))
3. A New Transformer¶
The only difference between creating a new aggregator and a transformer lies in the type of data returned by the decorated function. Everything else is exactly the same (received parameters, function names, and functionality of hyperparameters).
The decorated function must return a dictionary that can have the same keys as the parameters received by the function except for hparam
: matrix
, objectives
, weights
, dtypes
, alternatives
, or criteria
; and whose values must be the new values with which to replace the original ones in the transformation matrix.
It is not necessary to return all values; only the ones that you want to change.
For example, if we want to create a transformer StrFormat
that converts the text of the names of each criterion and alternative using the methods of str
, and by default, it converts texts to lowercase.
[20]:
@mktransformer(operation=str.lower)
def StrFormat(alternatives, criteria, hparams, **kwargs):
"""Applies a string formatting operation (lowercasing by default) to alternatives and criteria."""
# Apply the string formatting operation to each alternative
new_alternatives = [hparams.operation(a) for a in alternatives]
# Apply the string formatting operation to each criterion
new_criteria = [hparams.operation(c) for c in criteria]
# Return the transformed alternatives and criteria in a dictionary
return {"alternatives": new_alternatives, "criteria": new_criteria}
trans = StrFormat()
trans
[20]:
<StrFormat [operation=<method 'lower' of 'str' objects>]>
[21]:
trans.transform(dm)
[21]:
roe[▲ 2.0] | cap[▲ 4.0] | ri[▲ 1.0] | |
---|---|---|---|
pe | 7 | 5 | 0.028571 |
jn | 5 | 4 | 0.038462 |
aa | 5 | 6 | 0.035714 |
fx | 3 | 4 | 0.027778 |
mm | 1 | 7 | 0.033333 |
gn | 5 | 8 | 0.033333 |
We can use any function provided by str
.
[22]:
trans = StrFormat(operation=str.capitalize)
trans
[22]:
<StrFormat [operation=<method 'capitalize' of 'str' objects>]>
[23]:
trans.transform(dm)
[23]:
Roe[▲ 2.0] | Cap[▲ 4.0] | Ri[▲ 1.0] | |
---|---|---|---|
Pe | 7 | 5 | 0.028571 |
Jn | 5 | 4 | 0.038462 |
Aa | 5 | 6 | 0.035714 |
Fx | 3 | 4 | 0.027778 |
Mm | 1 | 7 | 0.033333 |
Gn | 5 | 8 | 0.033333 |
In fact, given our implementation, any arbitrary function that converts text can be used. For example, if we want to create our own function that adds exclamation marks to the end of each criterion and alternative.
[24]:
def add_exclamation(text):
return text + " !! "
trans = StrFormat(operation=add_exclamation)
trans
[24]:
<StrFormat [operation=<function add_exclamation at 0x79a56af36f80>]>
[25]:
trans.transform(dm)
[25]:
ROE !! [▲ 2.0] | CAP !! [▲ 4.0] | RI !! [▲ 1.0] | |
---|---|---|---|
PE !! | 7 | 5 | 0.028571 |
JN !! | 5 | 4 | 0.038462 |
AA !! | 5 | 6 | 0.035714 |
FX !! | 3 | 4 | 0.027778 |
MM !! | 1 | 7 | 0.033333 |
GN !! | 5 | 8 | 0.033333 |
3.1 Special considerations regarding dtypes
¶
By design decision, scikitcriteria always attempts to always preserve the original data types, unless it needs to infer them again.
This may not seem important to a user at first glance, so let’s use an example of a transformer affected by this characteristic.
First, let’s reload the original decision matrix, where the values of all criteria are int
.
[26]:
dm = skc.datasets.load_simple_stock_selection()
dm
[26]:
ROE[▲ 2.0] | CAP[▲ 4.0] | RI[▼ 1.0] | |
---|---|---|---|
PE | 7 | 5 | 35 |
JN | 5 | 4 | 26 |
AA | 5 | 6 | 28 |
FX | 3 | 4 | 36 |
MM | 1 | 7 | 30 |
GN | 5 | 8 | 30 |
Now, let’s create a transformer that converts all criteria to the float
type.
[27]:
@mktransformer
def AsFloat(matrix, **kwargs):
"""Converts the elements of a decision-matrix to floating-point numbers."""
# Convert the elements of the matrix to floating-point numbers
new_matrix = matrix.astype(float)
# Return the transformed matrix in a dictionary
return {"matrix": new_matrix}
trans = AsFloat()
trans
[27]:
<AsFloat []>
Now, let’s test its functionality.
[28]:
trans.transform(dm)
[28]:
ROE[▲ 2.0] | CAP[▲ 4.0] | RI[▼ 1.0] | |
---|---|---|---|
PE | 7 | 5 | 35 |
JN | 5 | 4 | 26 |
AA | 5 | 6 | 28 |
FX | 3 | 4 | 36 |
MM | 1 | 7 | 30 |
GN | 5 | 8 | 30 |
As can be seen, the numbers are still integers. This is because the dtypes
parameter of the matrix indicates that those columns are indeed integers.
[29]:
dm.dtypes # check the dtypes
[29]:
ROE int64
CAP int64
RI int64
dtype: object
The simplest solution would be to ensure that the dtypes are inferred again based on the values of the new matrix. This is achieved by assigning the dtype values to None.
[30]:
@mktransformer
def AsFloat(matrix, **kwargs):
"""Converts the elements of a decision-matrix to floating-point numbers."""
# Convert the elements of the matrix to floating-point numbers
new_matrix = matrix.astype(float)
# Return the transformed matrix in a dictionary
# and assign the dtypes as None
return {"matrix": new_matrix, "dtypes": None}
trans = AsFloat()
trans.transform(dm)
[30]:
ROE[▲ 2.0] | CAP[▲ 4.0] | RI[▼ 1.0] | |
---|---|---|---|
PE | 7.0 | 5.0 | 35.0 |
JN | 5.0 | 4.0 | 26.0 |
AA | 5.0 | 6.0 | 28.0 |
FX | 3.0 | 4.0 | 36.0 |
MM | 1.0 | 7.0 | 30.0 |
GN | 5.0 | 8.0 | 30.0 |
While this may seem somewhat inconvenient, it gives the user complete control over the data types of the matrix without assuming default behaviors that may be undesirable.
It’s essential to consider that the original dtypes
are also received by the transformer (in our case, they are inside **kwargs
) and can be used to determine the new types.
Generated by nbsphinx from a Jupyter notebook. 2024-02-09T19:34:09.746877