Interactive online version.

With Binder, open this notebook in an executable environment: 

https://mybinder.org/badge_logo.svg

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:

  1. 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
  1. The function should take parameters representing the decomposed decision matrix after calling the DecisionMatrix.to_dict() method, and a parameter hparams, 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
  1. Utilizing the received parameters, the function should return two objects:

    1. A numpy.array/list/tuple or any kind of sequence containing a valid ranking. Where the i-th position in the returned sequence has the ranking value for the i-th alternative in the array of alternatives received as a parameter.

    2. 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:

  1. Length: It should have the same length as the number of alternatives received by the function.

  2. 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.

  3. 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:

  1. The first position in the ranking is 1, indicating that the alternative in the first position is the most preferred or the best choice.

  2. The second position in the ranking is 2, suggesting that the alternative in the second position is the second-best choice.

  3. 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
6 Alternatives x 3 Criteria
[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
Method: AllAlternativesAreFirst
[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
Method: MaybeWSM

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
Method: MaybeWSM

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
6 Alternatives x 3 Criteria

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
6 Alternatives x 3 Criteria

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
6 Alternatives x 3 Criteria

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
6 Alternatives x 3 Criteria

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
6 Alternatives x 3 Criteria

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
6 Alternatives x 3 Criteria

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-10T20:35:33.978285