Can I have a devstruct with computed values?

Hey everyone.
I was wondering about how I should initialize a struct where I have some values and some computed values.

For now I came up with:

defmodule Stats do
  defstruct annotations: 0, true_positive: 0,
    false_positive: 0, false_negative: 0,
    precision: 0, recall: 0

  def set_precision(stats \\ %AnnotationStats{}) do
    Map.put(stats, :precision, (stats.true_positive / (stats.true_positive + stats.false_positive)))
  end

  def set_recall(stats \\ %AnnotationStats{}) do
    Map.put(stats, :recall, (stats.true_positive / (stats.true_positive + stats.false_negative)))
  end
end

What I the initialize with

my_stats = %Stats{annotations: 3, true_positive: 1, false_positive: 2, false_negative: 1}
|> set_precision()
|> set_recall()

But is there a cleaner way ? Or the way to do it?

Thank you all :slight_smile:

Here is my proposition:

defmodule Stats do
  required_keys = [:false_negative, :false_positive, :true_positive]
  @enforce_keys required_keys
  defstruct required_keys ++ [annotations: 0, precision: 0, recall: 0]

  @type t :: %__MODULE__{
          annotations: integer,
          false_negative: integer,
          false_positive: integer,
          precision: float,
          recall: float,
          true_positive: integer
        }

  @spec init(t) :: t
  def init(%__MODULE__{true_positive: true_positive} = struct) do
    computed = %{
      precision: init_computed(true_positive, struct.false_positive),
      recall: init_computed(true_positive, struct.false_negative)
    }

    Map.merge(struct, computed)
  end

  defp init_computed(true_positive, value), do: true_positive / (true_positive + value)
end

Stats.init(%Stats{annotations: 3, false_negative: 1, false_positive: 2, true_positive: 1})

It would fail if you did not pass 3 keys (specified in @enforce_keys) which are required to compute.

Alternatively you could write something like:

defmodule Stats do
  required_keys = [:false_negative, :false_positive, :true_positive]
  @enforce_keys required_keys
  defstruct required_keys ++ [annotations: 0, precision: 0, recall: 0]

  @type t :: %__MODULE__{
          annotations: integer,
          false_negative: integer,
          false_positive: integer,
          precision: float,
          recall: float,
          true_positive: integer
        }

  @spec init(map, integer, integer, integer) :: t
  def init(map, false_negative, false_positive, true_positive) do
    full_map =
      map
      |> Map.put(:false_negative, false_negative)
      |> Map.put(:false_positive, false_positive)
      |> Map.put(:precision, init_computed(true_positive, false_positive))
      |> Map.put(:recall, init_computed(true_positive, false_negative))
      |> Map.put(:true_positive, true_positive)

    struct(__MODULE__, full_map)
  end

  defp init_computed(true_positive, value), do: true_positive / (true_positive + value)
end

This way allows to pass map (don’t require struct), but forces to pass required keys separately.

Finally you can also do it in more pattern-matching like way:

defmodule Stats do
  required_keys = [:false_negative, :false_positive, :true_positive]
  @enforce_keys required_keys
  defstruct required_keys ++ [annotations: 0, precision: 0, recall: 0]

  @type init_map :: %{
          optional(:annotations) => integer,
          optional(:precision) => float,
          optional(:recall) => float,
          required(:false_negative) => integer,
          required(:false_positive) => integer,
          required(:true_positive) => integer
        }

  @type t :: %__MODULE__{
          annotations: integer,
          false_negative: integer,
          false_positive: integer,
          precision: float,
          recall: float,
          true_positive: integer
        }

  @spec init(init_map) :: t
  def init(
        %{
          false_negative: false_negative,
          false_positive: false_positive,
          true_positive: true_positive
        } = map
      ) do
    computed = %{
      precision: init_computed(true_positive, false_positive),
      recall: init_computed(true_positive, false_negative)
    }

    __MODULE__ |> struct(map) |> Map.merge(computed)
  end

  defp init_computed(true_positive, value), do: true_positive / (true_positive + value)
end

Which allows to pass required keys in map.

2 Likes

First of all thank you very much for your very detailed answer.

In your second solution: What is the map in the init() for?

If I understand you correct I would initialize Version 2 via:

Stats.init(%{annotations: 3}, 1, 2, 1)

and Version 3 via:

Stats.init(%{annotations: 3, false_negaitve: 1, false_positive: 2, true_positive: 1})

?

Yup, everything is correct. I saw that annotiations is not used for computing, but you pass it, so it’s more like map with optional data for me. If you want to use 2nd solution feel free to make this map optional by adding default argument like map \\ %{}.