Best way to perform this map reduction in idiomatic elixir

Hi all, I have this function requirement:

@spec group_by_chunk_index([MyStruct]) :: %{
          required(integer()) => [MyStruct]
        }

Which takes in a list of structs and groups them into a map where the keys are an integer representing some transformation over a struct field. For example, my struct has the field weights which is [integer()]. I want to loop over these structs, extract the weights for each one, transform those weight values, and group them into a new map using the transformed values as keys. For example:

first = MyStruct.new(weights: [0])
second = MyStruct.new(weights: [1])
third = MyStruct.new(weights: [5])

wanted = %{
   1000 => [first, second],
   2000 => [third]
}

assert group_by_chunk_index([first, second, third]) == wanted

Where any weight in the range [0-5) is mapped to the number 1000, [5, 10) to the number 2000, etc. My current approach involves an ugly series of Enum.reduce calls with a map as an accumulator. The only functions I use are Enum.reduce and Map.get_and_update. Is there a more functional approach I can take here perhaps using some other special operations from the Enum or List libraries? Thank you in advance.

Hello and welcome,

There is a good candidate… Enum.group_by, but You don’t really want to group by index, but by the resulting number.

Well, this describe all functions, because it’s all about transforming input to output :slight_smile:

Assuming You might want the sum…

Enum.group_by([first, second, third], & weights_to_number(Enum.sum(&1.weights)), & &1)

and You need to write weights_to_number, which take the sum (change to whatever transformation) of weights.

def weights_to_number(sum) when sum in 0..5, do: 1000
etc

I used to add new inside my modules, but now I prefer %MyStruct{weights: [0]}

iex> list = [%{weights: [0]}, %{weights: [1]}, %{weights: [6]}]
iex> w_to_n = fn x when x in 0..5 -> 1000; _ -> 2000 end
iex> Enum.group_by(list, &w_to_n.(Enum.sum(&1.weights)), & &1)
%{1000 => [%{weights: [0]}, %{weights: [1]}], 2000 => [%{weights: [6]}]}
1 Like

Start with your list of structs:

list_of_structs = [first, second, third]

Convert it to a list of {weight, struct} pairs. weights is a list, so I assume can it have multiple values.

list_of_pairs = Enum.flat_map(list_of_structs, fn struct -> Enum.map(struct.weights, &{&1, struct}) end)

Transform the weight values to the output format:

list_of_transformed_pairs = Enum.map(list_of_pairs, fn {w, s} -> {transform_weight(w), s} end)

Now you’ve got a list of tuples like {1000, first}. This is a case where the third argument to Enum.group_by is handy: the output is grouped by the first element of the tuple, but the value grouped is the second:

map_of_lists = Enum.group_by(list_of_transformed_pairs, fn {w, _} -> w end, fn {_, s} -> s end)
2 Likes

This worked really well! Thanks for the tips, learned a ton

I love these sorts of exercises. This one works nicely with a for comprehension. I just threw something together quickly in iex, but you should be able to translate it to named named functions in a module.

# define struct inline
iex(14)> defmodule MyStruct, do: defstruct [weights: []]
{:module, MyStruct,
 <<70, 79, 82, 49, 0, 0, 6, 184, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 185, ...

# here's the three inputs
iex(15)> {first, second, third} = {%MyStruct{weights: [0]}, %MyStruct{weights: [1]}, %MyStruct{weights: [5]}}
{%MyStruct{weights: [0]}, %MyStruct{weights: [1]}, %MyStruct{weights: [5]}}

# This is the "binning" function. Yours will likely be more complex and a named function in a module.
iex(16)> binner = &if(&1 in 0..4, do: 1000, else: 2000)
#Function<7.126501267/1 in :erl_eval.expr/5>

# Here's the meat of the code. We pattern match the `weights` field and name the full struct `s`.
# We reduce into a Map and on each weight call `Map.update` to either add a single item 
# list if it is a new key, or else prepend to the list if the key already exists.
iex(17)> for %{weights: ws} = s <- [first, second, third], w <- ws, reduce: %{}, do: (m -> Map.update(m, binner.(w), [s], &[s | &1]))
%{
  1000 => [%MyStruct{weights: [1]}, %MyStruct{weights: [0]}],
  2000 => [%MyStruct{weights: [5]}]
}
3 Likes