0

I'm currently learning about transducers with Ramda.js. (So fun, yay! )

I found this question that describes how to first filter an array and then sum up the values in it using a transducer.

I want to do something similar, but different. I have an array of objects that have a timestamp and I want to average out the timestamps. Something like this:

const createCheckin = ({
  timestamp = Date.now(), // default is now
  startStation = 'foo',
  endStation = 'bar'
} = {}) => ({timestamp, startStation, endStation});

const checkins = [
  createCheckin(),
  createCheckin({ startStation: 'baz' }),
  createCheckin({ timestamp: Date.now() + 100 }), // offset of 100
];

const filterCheckins = R.filter(({ startStation }) => startStation === 'foo');
const mapTimestamps = R.map(({ timestamp }) => timestamp);

const transducer = R.compose(
  filterCheckins,
  mapTimestamps,
);

const average = R.converge(R.divide, [R.sum, R.length]);

R.transduce(transducer, average, 0, checkins);
// Should return something like Date.now() + 50, giving the 100 offset at the top.

Of course average as it stands above can't work because the transform function works like a reduce.

I found out that I can do it in a step after the transducer.

const timestamps = R.transduce(transducer,  R.flip(R.append), [], checkins);
average(timestamps);

However, I think there must be a way to do this with the iterator function (second argument of the transducer). How could you achieve this? Or maybe average has to be part of the transducer (using compose)?

J. Hesters
  • 13,117
  • 31
  • 133
  • 249

2 Answers2

2

As a first step, you can create a simple type to allow averages to be combined. This requires keeping a running tally of the sum and number of items being averaged.

const Avg = (sum, count) => ({ sum, count })

// creates a new `Avg` from a given value, initilised with a count of 1
Avg.of = n => Avg(n, 1)

// takes two `Avg` types and combines them together
Avg.append = (avg1, avg2) =>
  Avg(avg1.sum + avg2.sum, avg1.count + avg2.count)

With this, we can turn our attention to creating the transformer that will combine the average values.

First, a simple helper function that allow values to be converted to our Avg type and also wraps a reduce function to default to the first value it receives rather than requiring an initial value to be provided (a nice initial value doesn't exist for averages, so we'll just use the first of the values instead)

const mapReduce1 = (map, reduce) =>
  (acc, n) => acc == null ? map(n) : reduce(acc, map(n))

The transformer then just needs to combine the Avg values and then pull resulting average out of the result. n.b. The result needs to guard for null values in the case where the transformer is run over an empty list.

const avgXf = {
  '@@transducer/step': mapReduce1(Avg.of, Avg.append),
  '@@transducer/result': result =>
    result == null ? null : result.sum / result.count
}

You can then pass this as the accumulator function to transduce, which should produce the resulting average value.

transduce(transducer, avgXf, null, checkins)
Scott Christopher
  • 6,458
  • 23
  • 26
  • 2
    Now why didn't I think of that??? :-) I'm still not following entirely (what is `transducer` here?) and I'm not sure it's that important. I do feel like you could initialize with `Avg (0, 0)` with `result => result.count == 0 ? 0 : result.sum / result.count`, although I could well be missing something. I think that just like the sum of an empty list is defined logically as zero, so too the average of an empty list could be defined to be zero. But clearly this is more of academic than of practical interest. Still, very interesting to see! – Scott Sauyet Sep 18 '21 at 05:22
1

I'm afraid this strikes me as quite confused.

I think of transducers as a way of combining the steps of a composed function on sequences of values so that you can iterate the sequence only once.

average makes no sense here. To take an average you need the whole collection.

So you can transduce the filtering and mapping of the values. But you will absolutely need to then do the averaging separately. Note that filter then map is a common enough pattern that there are plenty of filterMap functions around. Ramda doesn't have one, but this would do fine:

const filterMap = (f, m) => (xs) =>
  xs .flatMap (x => f (x) ? [m (x)] : [])

which would then be used like this:

filterMap (
  propEq ('startStation', 'foo'), 
  prop ('timestamp')
) (checkins)

But for more complex sequences of transformations, transducers can certainly fit the bill.


I would also suggest that when you can, you should use lift instead of converge. It's a more standard FP function, and works on a more abstract data type. Here const average = lift (divide) (sum, length) would work fine.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • 1
    You only need the whole collection in the same way that you need the whole collection to determine the size of a list. That is to say, you can create a type which collects a tuple of the sum of the value to be averaged, along with the count of the values being averaged, allowing for the average to be determined by a fold over the collection in somewhat constant space. Happy to expand out to an additional answer if helpful. – Scott Christopher Sep 16 '21 at 08:28
  • @ScottChristopher: Transducers always give me a headache! I'd love to see your approach. While we can always `reduce`, `scan`, `mapAccum` with something like `(({a, n}, b) => ({a: ((a * n) + b) / (n + 1), n: n + 1}))` or `(({t, a, n}, b) => ({t: t + b, a: (t + b) / (n + 1), n: n + 1}))` with appropriate initial values, and then extract `a` at the end, I don't know how to make that into a reasonable transducer. – Scott Sauyet Sep 16 '21 at 12:49
  • @ScottChristopher I'd also be interested in your suggestion. – J. Hesters Sep 17 '21 at 07:55
  • 1
    I've included an answer here for one such approach. @ScottSauyet - that's pretty much the approach I have taken. The use of transducers specifically is questionable, though may be useful for avoiding multiple passes over large lists. The same could also be achieved without creating a new transformer, using the accumulator function like the one you shared - the only nice extra is the transformer lets you bake in the `sum/count` in `@@transducer/step` rather than having to do so to the resulting value from `transduce`. And I too share your transducer-induced headaches :) – Scott Christopher Sep 18 '21 at 02:58