Skip to main content

lib/algorithms/analytic_hierarchy.ex

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