Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export {
linearNormalizationCallback,
minMaxNormalizationCallback,
maxNormalizationCallback,
vectorNormalizationCallback,
sumNormalizationCallback,
} from "./normalization";
export { rank } from "./ranking";
168 changes: 168 additions & 0 deletions src/utils/normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,171 @@ export function linearNormalizationCallback(
}),
);
}

export function maxNormalizationCallback(
matrix: DecisionMatrix,
types: CriterionType[],
): DecisionMatrix {
if (!matrix.length) {
return matrix;
}

const criteriaCount = matrix[0].length;

const maxima = Array.from({ length: criteriaCount }, (_, criterionIndex) => {
const criterionValues = matrix.map((alternativeValues) => alternativeValues[criterionIndex]);

return Math.max(...criterionValues);
});

const normalizedMatrix: DecisionMatrix = [];

for (const alternativeValues of matrix) {
const normalizedAlternativeValues: number[] = [];

for (let criterionIndex = 0; criterionIndex < criteriaCount; criterionIndex++) {
const maximum = maxima[criterionIndex] ?? 0;

if (maximum === 0) {
throw new Error(
`Cannot normalize criterion at index ${criterionIndex} because the maximum value is zero.`,
);
}

const value = alternativeValues[criterionIndex];
const normalizedValue = value / maximum;

if (types[criterionIndex] === CriterionType.COST) {
normalizedAlternativeValues.push(1 - normalizedValue);
} else {
normalizedAlternativeValues.push(normalizedValue);
}
}

normalizedMatrix.push(normalizedAlternativeValues);
}

return normalizedMatrix;
}

export function minMaxNormalizationCallback(
matrix: DecisionMatrix,
types: CriterionType[],
): DecisionMatrix {
if (!matrix.length) {
return matrix;
}

const criteriaCount = matrix[0].length;
const ranges = Array.from({ length: criteriaCount }, (_, criterionIndex) => {
const criterionValues = matrix.map(
(alternativeValues) => alternativeValues[criterionIndex] ?? 0,
);

return {
min: Math.min(...criterionValues),
max: Math.max(...criterionValues),
};
});

const normalizedMatrix: DecisionMatrix = [];

for (const alternativeValues of matrix) {
const normalizedAlternativeValues: number[] = [];

for (let criterionIndex = 0; criterionIndex < criteriaCount; criterionIndex++) {
const range = ranges[criterionIndex];

if (range === undefined) {
throw new Error("Min-max normalization criterion range is required.");
}

const denominator = range.max - range.min;

if (denominator === 0) {
throw new Error(
`Cannot normalize criterion at index ${criterionIndex} because all values are equal.`,
);
}

const value = alternativeValues[criterionIndex];

if (types[criterionIndex] === CriterionType.COST) {
normalizedAlternativeValues.push((range.max - value) / denominator);
} else {
normalizedAlternativeValues.push((value - range.min) / denominator);
}
}

normalizedMatrix.push(normalizedAlternativeValues);
}

return normalizedMatrix;
}

export function sumNormalizationCallback(
matrix: DecisionMatrix,
types: CriterionType[],
): DecisionMatrix {
if (!matrix.length) {
return matrix;
}

for (const alternativeValues of matrix) {
for (const alternativeValue of alternativeValues) {
if (alternativeValue < 0) {
throw new Error("Sum normalization requires that none of the values are negative");
}
}
}

const criteriaCount = matrix[0].length;

const divisors = Array.from({ length: criteriaCount }, (_, criterionIndex) => {
if (types[criterionIndex] === CriterionType.COST) {
return matrix.reduce((sum, alternativeValues) => {
const value = alternativeValues[criterionIndex];

if (value === 0) {
throw new Error(
`Cannot normalize criterion at index ${criterionIndex} because it contains zero values`,
);
}

return sum + 1 / value;
}, 0);
}

return matrix.reduce((sum, alternativeValues) => {
return sum + alternativeValues[criterionIndex];
}, 0);
});

const normalizedMatrix: DecisionMatrix = [];

for (const alternativeValues of matrix) {
const normalizedAlternativeValues: number[] = [];

for (let criterionIndex = 0; criterionIndex < criteriaCount; criterionIndex++) {
const divisor = divisors[criterionIndex] ?? 0;

if (divisor === 0) {
throw new Error(
`Cannot normalize criterion at index ${criterionIndex} because the divisor is zero`,
);
}

const value = alternativeValues[criterionIndex];

if (types[criterionIndex] === CriterionType.COST) {
normalizedAlternativeValues.push(1 / value / divisor);
} else {
normalizedAlternativeValues.push(value / divisor);
}
}

normalizedMatrix.push(normalizedAlternativeValues);
}

return normalizedMatrix;
}
8 changes: 8 additions & 0 deletions tests/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ describe("MCDA Exports", () => {
expect(mcda.PrometheeDecisionProblem).toBeDefined();
});

test("should export VikorDecisionProblem", () => {
expect(mcda.VikorDecisionProblem).toBeDefined();
});

test("should export SpotisDecisionProblem", () => {
expect(mcda.SpotisDecisionProblem).toBeDefined();
});

test("should export MabacDecisionProblem", () => {
expect(mcda.MabacDecisionProblem).toBeDefined();
});
Expand Down
153 changes: 144 additions & 9 deletions tests/utils/normalization.test.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,184 @@
import { describe, expect, test } from "@jest/globals";
import { describe, expect, it } from "@jest/globals";
import { CriterionType } from "../../src/types";
import { linearNormalizationCallback, vectorNormalizationCallback } from "../../src/utils";
import {
linearNormalizationCallback,
maxNormalizationCallback,
minMaxNormalizationCallback,
sumNormalizationCallback,
vectorNormalizationCallback,
} from "../../src/utils";

describe("vectorNormalizationCallback", () => {
test("normalizes benefit criteria by vector divisor", () => {
it("normalizes benefit criteria by vector divisor", () => {
const normalizedMatrix = vectorNormalizationCallback([[3], [4]], [CriterionType.BENEFIT]);

expect(normalizedMatrix).toEqual([[0.6], [0.8]]);
});

test("normalizes cost criteria by inverted vector divisor", () => {
it("normalizes cost criteria by inverted vector divisor", () => {
const normalizedMatrix = vectorNormalizationCallback([[4], [3]], [CriterionType.COST]);

expect(normalizedMatrix).toEqual([[0.19999999999999996], [0.4]]);
});

test("throws when matrix has no alternatives", () => {
it("throws when matrix has no alternatives", () => {
expect(() => vectorNormalizationCallback([], [])).toThrow(
"Decision problem matrix requires at least one alternative.",
);
});

test("throws when criterion divisor is zero", () => {
it("throws when criterion divisor is zero", () => {
expect(() => vectorNormalizationCallback([[0], [0]], [CriterionType.BENEFIT])).toThrow(
"Cannot normalize criterion at index 0 because the divisor is zero.",
);
});
});

describe("linearNormalizationCallback", () => {
test("normalizes benefit criteria from min to max", () => {
it("normalizes benefit criteria from min to max", () => {
const normalizedMatrix = linearNormalizationCallback([[10], [8], [6]], [CriterionType.BENEFIT]);

expect(normalizedMatrix).toEqual([[1], [0.5], [0]]);
});

test("normalizes cost criteria from max to min", () => {
it("normalizes cost criteria from max to min", () => {
const normalizedMatrix = linearNormalizationCallback([[5], [7], [4]], [CriterionType.COST]);

expect(normalizedMatrix).toEqual([[2 / 3], [0], [1]]);
});

test("returns zero for criteria with identical values", () => {
it("returns zero for criteria with identical values", () => {
const normalizedMatrix = linearNormalizationCallback([[5], [5]], [CriterionType.BENEFIT]);

expect(normalizedMatrix).toEqual([[0], [0]]);
});
});

describe("maxNormalizationCallback", () => {
it("normalizes benefit criteria by dividing by max", () => {
const normalizedMatrix = maxNormalizationCallback([[1], [5], [10]], [CriterionType.BENEFIT]);

expect(normalizedMatrix).toEqual([[0.1], [0.5], [1]]);
});

it("normalizes benefit criteria as 1 - x/max", () => {
const normalizedMatrix = maxNormalizationCallback([[1], [5], [10]], [CriterionType.COST]);

expect(normalizedMatrix).toEqual([[0.9], [0.5], [0]]);
});

it("returns 1 for criteria with identical values", () => {
const normalizedMatrix = maxNormalizationCallback([[5], [5]], [CriterionType.BENEFIT]);

expect(normalizedMatrix).toEqual([[1], [1]]);
});

it("normalizes data per criterion not per alternative", () => {
const normalizedMatrix = maxNormalizationCallback(
[
[1, 1],
[5, 5],
[10, 10],
],
[CriterionType.BENEFIT, CriterionType.COST],
);

expect(normalizedMatrix).toEqual([
[0.1, 0.9],
[0.5, 0.5],
[1, 0],
]);
});

it("throws if any max is 0", () => {
expect(() => maxNormalizationCallback([[0], [-5]], [CriterionType.BENEFIT])).toThrow(
"Cannot normalize criterion at index 0 because the maximum value is zero.",
);
});
});

describe("minMaxNormalizationCallback", () => {
it("normalizes data using min-max alg. per criterion not per alternative", () => {
const normalizedMatrix = minMaxNormalizationCallback(
[
[1, 1],
[5, 5],
[9, 9],
],
[CriterionType.BENEFIT, CriterionType.COST],
);

expect(normalizedMatrix).toEqual([
[0, 1],
[0.5, 0.5],
[1, 0],
]);
});

it("throws for criteria with identical values", () => {
expect(() => minMaxNormalizationCallback([[5], [5]], [CriterionType.BENEFIT])).toThrow(
"Cannot normalize criterion at index 0 because all values are equal.",
);
});
});

describe("sumNormalizationCallback", () => {
it("normalizes benefit criteria by dividing by sum", () => {
const normalizedMatrix = sumNormalizationCallback(
[[0.25], [0.25], [0.5]],
[CriterionType.BENEFIT],
);

expect(normalizedMatrix).toEqual([[0.25], [0.25], [0.5]]);
});
it("normalizes cost criteria by dividing 1/x by sum(1/x)", () => {
const normalizedMatrix = sumNormalizationCallback(
[[0.25], [0.25], [0.5]],
[CriterionType.COST],
);

expect(normalizedMatrix).toEqual([[0.4], [0.4], [0.2]]);
});

it("normalizes data per criterion not per alternative", () => {
const normalizedMatrix = sumNormalizationCallback(
[
[0.25, 0.25],
[0.25, 0.25],
[0.5, 0.5],
],
[CriterionType.BENEFIT, CriterionType.COST],
);

expect(normalizedMatrix).toEqual([
[0.25, 0.4],
[0.25, 0.4],
[0.5, 0.2],
]);
});

it("throws if a cost criterion has any 0 value", () => {
expect(() =>
sumNormalizationCallback(
[
[5, 5],
[0, 0],
],
[CriterionType.BENEFIT, CriterionType.COST],
),
).toThrow("Cannot normalize criterion at index 1 because it contains zero values");
});

it("throws if any criterion has negative value", () => {
expect(() =>
sumNormalizationCallback(
[
[1, 1],
[2, 2],
[3, -3],
],
[CriterionType.BENEFIT, CriterionType.BENEFIT],
),
).toThrow("Sum normalization requires that none of the values are negative");
});
});
Loading