defmodule Descisionex.AnalyticHierarchy do @moduledoc """ https://en.wikipedia.org/wiki/Analytic_hierarchy_process """ alias Descisionex.{AnalyticHierarchy, Helper} defstruct comparison_matrix: [], normalized_comparison_matrix: [], criteria_weights: [], criteria_num: 0, alternatives: [], alternatives_matrix: %{}, alternatives_weights: [], alternatives_weights_by_criteria: [], alternatives_num: 0, criteria: [], consistency_index: nil, consistency_ratio: nil, lambda_max: nil def set_criteria(%AnalyticHierarchy{} = _data, []) do raise ArgumentError, message: "Criteria must be not empty!" end @doc """ Set criteria for analytic hierarchy. ## Examples iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_criteria([]) ** (ArgumentError) Criteria must be not empty! iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) %Descisionex.AnalyticHierarchy{ alternatives: [], alternatives_matrix: %{}, alternatives_num: 0, alternatives_weights: [], alternatives_weights_by_criteria: [], comparison_matrix: [], consistency_index: nil, consistency_ratio: nil, criteria: ["some", "criteria"], criteria_num: 2, criteria_weights: [], lambda_max: nil, normalized_comparison_matrix: [] } """ def set_criteria(%AnalyticHierarchy{} = data, criteria) do data |> Map.put(:criteria, criteria) |> Map.put(:criteria_num, Enum.count(criteria)) end def set_alternatives(%AnalyticHierarchy{} = _data, []) do raise ArgumentError, message: "Alternatives must be not empty!" end @doc """ Set alternatives for analytic hierarchy. ## Examples iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_alternatives([]) ** (ArgumentError) Alternatives must be not empty! iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_alternatives(["some", "alternatives"]) %Descisionex.AnalyticHierarchy{ alternatives: ["some", "alternatives"], alternatives_matrix: %{}, alternatives_num: 2, alternatives_weights: [], alternatives_weights_by_criteria: [], comparison_matrix: [], consistency_index: nil, consistency_ratio: nil, criteria: [], criteria_num: 0, criteria_weights: [], lambda_max: nil, normalized_comparison_matrix: [] } """ def set_alternatives(%AnalyticHierarchy{} = data, alternatives) do data |> Map.put(:alternatives, alternatives) |> Map.put(:alternatives_num, Enum.count(alternatives)) end @doc """ Set alternatives matrix for analytic hierarchy (criteria must be set!). ## Examples iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_alternatives_matrix([[1, 2], [3, 4]]) ** (ArgumentError) Criteria must be set! iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.set_alternatives_matrix([[1, 2], [3, 4]]) %Descisionex.AnalyticHierarchy{ alternatives: [], alternatives_matrix: %{"criteria" => [3, 4], "some" => [1, 2]}, alternatives_num: 0, alternatives_weights: [], alternatives_weights_by_criteria: [], comparison_matrix: [], consistency_index: nil, consistency_ratio: nil, criteria: ["some", "criteria"], criteria_num: 2, criteria_weights: [], lambda_max: nil, normalized_comparison_matrix: [] } """ def set_alternatives_matrix(%AnalyticHierarchy{} = data, matrix) do if data.criteria_num == 0, do: raise(ArgumentError, message: "Criteria must be set!") if Enum.count(matrix) != data.criteria_num, do: raise(ArgumentError, message: "Matrix row count must equal criteria count (#{data.criteria_num})!" ) tagged = Enum.map(Enum.with_index(data.criteria), fn {criteria, index} -> {criteria, Enum.at(matrix, index)} end) |> Enum.into(%{}) data |> Map.put(:alternatives_matrix, tagged) end @doc """ Set pre-tagged alternatives matrix for analytic hierarchy (criteria must be set!). The map keys must correspond to criteria names. ## Examples iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_tagged_alternatives_matrix(%{"a" => [1, 2]}) ** (ArgumentError) Criteria must be set! iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_criteria(["a", "b"]) |> Descisionex.AnalyticHierarchy.set_tagged_alternatives_matrix(%{"a" => [1, 2], "b" => [3, 4]}) %Descisionex.AnalyticHierarchy{ alternatives: [], alternatives_matrix: %{"a" => [1, 2], "b" => [3, 4]}, alternatives_num: 0, alternatives_weights: [], alternatives_weights_by_criteria: [], comparison_matrix: [], consistency_index: nil, consistency_ratio: nil, criteria: ["a", "b"], criteria_num: 2, criteria_weights: [], lambda_max: nil, normalized_comparison_matrix: [] } """ def set_tagged_alternatives_matrix(%AnalyticHierarchy{} = data, matrix) do if data.criteria_num == 0, do: raise(ArgumentError, message: "Criteria must be set!") if matrix == [] || matrix == %{}, do: raise(ArgumentError, message: "Incorrect matrix!") data |> Map.put(:alternatives_matrix, matrix) end @doc """ Normalizes comparison matrix for analytic hierarchy (criteria must be set, such as comparison matrix!). ## Examples iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.normalize_comparison_matrix() ** (ArgumentError) Comparison matrix must be set! iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.normalize_comparison_matrix() %Descisionex.AnalyticHierarchy{ alternatives: [], alternatives_matrix: %{}, alternatives_num: 0, alternatives_weights: [], alternatives_weights_by_criteria: [], comparison_matrix: [[1, 2], [3, 4]], consistency_index: nil, consistency_ratio: nil, criteria: ["some", "criteria"], criteria_num: 2, criteria_weights: [], lambda_max: nil, normalized_comparison_matrix: [[0.25, 0.333], [0.75, 0.667]] } """ def normalize_comparison_matrix(%AnalyticHierarchy{} = data) do size = data.criteria_num if size == 0, do: raise(ArgumentError, message: "Criteria must be set!") if data.comparison_matrix == [], do: raise(ArgumentError, message: "Comparison matrix must be set!") if Enum.count(data.comparison_matrix) != size, do: raise(ArgumentError, message: "Comparison matrix dimensions must match criteria count (#{size}x#{size})!" ) normalized = Helper.normalize(data.comparison_matrix, size) Map.put(data, :normalized_comparison_matrix, normalized) end @doc """ Calculates weights for normalized comparison matrix for analytic hierarchy (criteria must be set, such as comparison matrix!). ## Examples iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.calculate_criteria_weights() ** (ArgumentError) Comparison matrix must be normalized! iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.normalize_comparison_matrix() |> Descisionex.AnalyticHierarchy.calculate_criteria_weights() %Descisionex.AnalyticHierarchy{ alternatives: [], alternatives_matrix: %{}, alternatives_num: 0, alternatives_weights: [], alternatives_weights_by_criteria: [], comparison_matrix: [[1, 2], [3, 4]], consistency_index: nil, consistency_ratio: nil, criteria: ["some", "criteria"], criteria_num: 2, criteria_weights: [[0.291], [0.709]], lambda_max: nil, normalized_comparison_matrix: [[0.25, 0.333], [0.75, 0.667]] } """ def calculate_criteria_weights(%AnalyticHierarchy{} = data) do size = data.criteria_num if size == 0, do: raise(ArgumentError, message: "Criteria must be set!") if data.normalized_comparison_matrix == [], do: raise(ArgumentError, message: "Comparison matrix must be normalized!") criteria_weights = Helper.calculate_weights(data.normalized_comparison_matrix, size) Map.put(data, :criteria_weights, criteria_weights) end @doc """ Calculates the consistency ratio (CR) of the comparison matrix. CR < 0.1 indicates acceptable consistency. Requires criteria weights to be calculated first. ## Examples iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.calculate_consistency_ratio() ** (ArgumentError) Criteria weights must be calculated before consistency ratio! """ def calculate_consistency_ratio(%AnalyticHierarchy{} = data) do if data.criteria_weights == [], do: raise(ArgumentError, message: "Criteria weights must be calculated before consistency ratio!" ) n = data.criteria_num weights = Enum.map(data.criteria_weights, fn [w] -> w end) matrix = data.comparison_matrix lambda_values = matrix |> Enum.with_index() |> Enum.map(fn {row, i} -> dot = Enum.zip(row, weights) |> Enum.map(fn {a, w} -> a * w end) |> Enum.sum() dot / Enum.at(weights, i) end) lambda_max = Enum.sum(lambda_values) / n ci = (lambda_max - n) / (n - 1) ri = random_index(n) cr = if ri == 0.0, do: 0.0, else: ci / ri data |> Map.put(:lambda_max, Float.round(lambda_max, 3)) |> Map.put(:consistency_index, Float.round(ci, 3)) |> Map.put(:consistency_ratio, Float.round(cr, 3)) end @doc """ Calculates alternatives weights by criteria for analytic hierarchy (criteria must be set, such as comparison matrix!). ## Examples iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.calculate_alternatives_weights_by_criteria() ** (ArgumentError) Alternatives matrix must be set! iex> %Descisionex.AnalyticHierarchy{} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.set_alternatives(["some", "alternatives"]) |> Descisionex.AnalyticHierarchy.set_alternatives_matrix([[[1, 2, 3]], [[4, 5, 6]]]) |> Descisionex.AnalyticHierarchy.calculate_alternatives_weights_by_criteria() %Descisionex.AnalyticHierarchy{ alternatives: ["some", "alternatives"], alternatives_matrix: %{"criteria" => [[4, 5, 6]], "some" => [[1, 2, 3]]}, alternatives_num: 2, alternatives_weights: [], alternatives_weights_by_criteria: [[3.0, 3.0]], comparison_matrix: [], consistency_index: nil, consistency_ratio: nil, criteria: ["some", "criteria"], criteria_num: 2, criteria_weights: [], lambda_max: nil, normalized_comparison_matrix: [] } """ def calculate_alternatives_weights_by_criteria(%AnalyticHierarchy{} = data) do if data.criteria_num == 0, do: raise(ArgumentError, message: "Criteria must be set!") if data.alternatives_matrix == %{}, do: raise(ArgumentError, message: "Alternatives matrix must be set!") alternatives_weights_by_criteria = Enum.map(data.criteria, fn criteria -> matrix = data.alternatives_matrix[criteria] size = Enum.count(matrix) weights = matrix |> Helper.normalize(size) |> Helper.calculate_weights(size) weights end) result = alternatives_weights_by_criteria |> Matrix.transpose() |> Enum.map(&List.flatten/1) Map.put(data, :alternatives_weights_by_criteria, result) end @doc """ Calculates alternatives weights for analytic hierarchy (criteria must be set, such as comparison matrix and weights before must be calculated!). ## Examples iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.normalize_comparison_matrix() |> Descisionex.AnalyticHierarchy.set_alternatives(["some", "alternatives"]) |> Descisionex.AnalyticHierarchy.set_alternatives_matrix([[[1, 2, 3]], [[4, 5, 6]]]) |> Descisionex.AnalyticHierarchy.calculate_alternatives_weights_by_criteria() |> Descisionex.AnalyticHierarchy.calculate_alternatives_weights() ** (ArgumentError) Weights must be calculated before! iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.normalize_comparison_matrix() |> Descisionex.AnalyticHierarchy.calculate_criteria_weights() |> Descisionex.AnalyticHierarchy.set_alternatives(["some", "alternatives"]) |> Descisionex.AnalyticHierarchy.set_alternatives_matrix([[[1, 2, 3]], [[4, 5, 6]]]) |> Descisionex.AnalyticHierarchy.calculate_alternatives_weights_by_criteria() |> Descisionex.AnalyticHierarchy.calculate_alternatives_weights() %Descisionex.AnalyticHierarchy{ alternatives: ["some", "alternatives"], alternatives_matrix: %{"criteria" => [[4, 5, 6]], "some" => [[1, 2, 3]]}, alternatives_num: 2, alternatives_weights: [3.0], alternatives_weights_by_criteria: [[3.0, 3.0]], comparison_matrix: [[1, 2], [3, 4]], consistency_index: nil, consistency_ratio: nil, criteria: ["some", "criteria"], criteria_num: 2, criteria_weights: [[0.291], [0.709]], lambda_max: nil, normalized_comparison_matrix: [[0.25, 0.333], [0.75, 0.667]] } """ def calculate_alternatives_weights(%AnalyticHierarchy{} = data) do weights = data.criteria_weights if weights == [], do: raise(ArgumentError, message: "Weights must be calculated before!") alternatives_weights = Enum.reduce(data.alternatives_weights_by_criteria, [], fn column, acc -> product = Enum.map(Enum.with_index(column), fn {number, index} -> [weight | _] = Enum.at(weights, index) Float.round(number * weight, 3) end) |> Enum.sum() acc ++ [product] end) Map.put(data, :alternatives_weights, alternatives_weights) end @doc """ Runs the full AHP pipeline: normalize comparison matrix, calculate criteria weights, consistency ratio, alternatives weights by criteria, and final alternatives weights. Requires comparison_matrix, criteria, alternatives, and alternatives_matrix to be set beforehand. ## Examples iex> %Descisionex.AnalyticHierarchy{comparison_matrix: [[1, 2], [3, 4]]} |> Descisionex.AnalyticHierarchy.set_criteria(["some", "criteria"]) |> Descisionex.AnalyticHierarchy.set_alternatives(["alt1", "alt2"]) |> Descisionex.AnalyticHierarchy.set_alternatives_matrix([[[1, 2], [2, 1]], [[3, 1], [1, 3]]]) |> Descisionex.AnalyticHierarchy.calculate() |> Map.get(:alternatives_weights) |> Enum.sum() |> Float.round(2) 1.0 """ def calculate(%AnalyticHierarchy{} = data) do data |> normalize_comparison_matrix() |> calculate_criteria_weights() |> calculate_consistency_ratio() |> calculate_alternatives_weights_by_criteria() |> calculate_alternatives_weights() end # Random Consistency Index values for matrices of size 1–10 (Saaty, 1980) @random_index %{1 => 0.00, 2 => 0.00, 3 => 0.58, 4 => 0.90, 5 => 1.12, 6 => 1.24, 7 => 1.32, 8 => 1.41, 9 => 1.45, 10 => 1.49} defp random_index(n), do: Map.get(@random_index, n, 1.49) end