3
\$\begingroup\$

I want to be able to distribute a total reward among network, where the distribution diminishes according to the depth of the network but the total sum of distributed rewards equals the initial total reward.

I have attempted to model this with:

\$S = a * (1 - r^n) / (1 - r)\$

The below Python snippet presents this series.

def distribute_total_reward(total_reward: float, decay_rate: float, max_levels: int = 10) -> list:
    # Solve for the first term (a)
    a = total_reward * (1 - decay_rate) / (1 - decay_rate ** max_levels)
    rewards = [a * (decay_rate ** i) for i in range(max_levels)]
    return rewards


if __name__ == "__main__":
    # Total reward to be distributed
    tw = 3500

    # 50% Decay rate for the distribution
    dr = 0.5

    # Number of levels in the network
    ml = 8

    reward_distribution = distribute_total_reward(tw, dr, ml)
    for level, reward in enumerate(reward_distribution, start=1):
        print(f"Level {level} referrer receives: £${reward:.2f}")

    print(f"Total Distributed: ${sum(reward_distribution):.2f}")

Note: I have arbitrarily set tw, dr, ml.

One of the things I have struggled with is building out the solution to support circular referrals. I think this sort of breaks a referral system designed with hierarchy because anyone can referrer anyone else. I want to accommodate the flexibility of reverse referrals (i.e., downstream referring upstream and vice versa).

I have tried implementing a fixed reward for reverse referrals, separate from the main referral rewards. However, I feel like this is too simplistic as it assumes a fixed number of reverse referrals. Realistically, we need to dynamically identify these reverse referral scenarios and allocate accordingly. Here is the code:

def distribute(
    total_reward: int,
    decay_rate: float,
    max_levels: int = 10,
    reverse_referral_reward: int = 5
) -> tuple:
    """
    Distributes the total reward among the referral network and handles reverse referrals.
    """
    num_reverse_referrals = 2
    adjusted_total_reward = total_reward - (num_reverse_referrals * reverse_referral_reward)

    a = adjusted_total_reward * (1 - decay_rate) / (1 - decay_rate ** max_levels)
    rewards = [a * (decay_rate ** i) for i in range(max_levels)]
    reverse_rewards = [reverse_referral_reward for _ in range(num_reverse_referrals)]
    return rewards, reverse_rewards


if __name__ == "__main__":

    total_reward = 3500           # Total reward to be distributed
    decay_rate = 0.5              # Decay rate for the distribution
    max_levels = 8                # Number of levels in the referral network
    reverse_referral_reward = 5   # Reward for reverse referrals

    primary_rewards, reverse_rewards = distribute(
        total_reward,
        decay_rate,
        max_levels,
        reverse_referral_reward
    )
    for level, reward in enumerate(primary_rewards, start=1):
        print(f"Level {level} primary referrer receives: ${reward:.2f}")

    for i, reward in enumerate(reverse_rewards, start=1):
        print(f"Reverse referral {i} receives: ${reward:.2f}")

    print(f"Total Distributed Reward: ${sum(primary_rewards) + sum(reverse_rewards):.2f}")

\$\endgroup\$
3
  • \$\begingroup\$ Can you add descriptions for all of the variables in your expression at the top? \$\endgroup\$
    – Reinderien
    Commented Mar 15 at 16:13
  • \$\begingroup\$ I have applied this to the new edit. \$\endgroup\$
    – Bob
    Commented Mar 16 at 1:48
  • \$\begingroup\$ I have rolled back Rev 5 → 1. Please see What should I do when someone answers my question?. \$\endgroup\$ Commented Mar 16 at 4:16

2 Answers 2

2
\$\begingroup\$

clear identifiers

distribute_total_reward() looks great. All the identifiers are very helpful. An URL citation of that formula which introduces the variable "a" wouldn't hurt.

post-condition

The essential aspect of this routine is the computed rewards shall sum to the specified input parameter. We should minimally spell this out with an English sentence in a docstring. The supplied test code nicely computes a sum(), but it is not self-evaluating so a human must eyeball the result and verify it's sensible.

Ideally the function would end with an assert of equality, but due to FP ULP rounding errors we expect a small epsilon error, so the test would be for relative_error() less than e.g. 1 ppb.

An easy assert would be to construct a unit test to check the post-condition. For some parameters, such as 1024 total reward and .5 decay rate, the FP result will yield exact equality.

def main()

Nice __main__ guard. There is starting to be enough code here that it may be worth burying it within def main():. By the time you introduce reverse_rewards we're definitely ready for that. I confess I'm not sure about the choice of tw rather than a tr name.

nit: We switch between £$ and $ currency prefixes.

distribute()

It's unclear why the currency figures of total_reward and reverse_referral_reward would be of type int. Please understand that a float annotation subsumes int, even though integers can have unlimited magnitude and there's no inheritance relationship between them.

We have a docstring, and it is informative. But it's a little wishy washy. If I'm tasked with writing a unit test that verifies correct behavior, verifies the implementation conforms to a spec, then consulting just the docstring, alas, won't suffice. The verbs "distributes" and "handles" are OK but they aren't accompanied by any specifics.

It seems like we want a pair of post-conditions here, constraining what happens with each of the two reward categories.

DRY

    adjusted_total_reward = total_reward - (num_reverse_referrals * reverse_referral_reward)
    ...
    reverse_rewards = [reverse_referral_reward for _ in range(num_reverse_referrals)]

These are kind of saying the same thing. Consider first computing

    reverse_rewards = [reverse_referral_reward] * num_reverse_referrals

and then you can conveniently assign total_reward - sum(reverse_rewards).

design of Public API

    return rewards, reverse_rewards

This kind of looks like we're returning parallel vectors a, b as seen in many Fortran APIs, where a[i] describes one aspect of entity i and b[i] another aspect of that same entity. But of course they have different lengths. Consider including 0 values in the reverse_rewards. Consider returning a single vector of namedtuples that contain values for both reward categories.

Which brings us to the input parameters. You will need a way to describe who referred, in which direction, and how effectively. This could be a general graph, but I am skeptical that you have a business use case for that yet. Better to start out with a restricted graph such as a tree. Maybe pass in a vector of (forward, reverse) referral magnitudes, and of course when all the reverse values are zero we should produce same result as that first algorithm produces.

algorithm

The closed-form solution you provide is very nice. Here is another way, a little messier, to think about that initial algorithm. Assign total_reward to the initial entry, with the rest zero. Use a for i in range(max_levels): loop to make several passes over the vector, distributing a small fraction of remaining reward each time. The last iteration makes the one and only assignment to the final level. We always {add, subtract} same small value, preserving a constant total.

Now consider a graph that includes reverse referrals. Again we make several passes over the graph starting from its root, distributing a fraction as we go. But when following an upward edge we now can distribute upward, as well.

The current approach of “divide level I reward evenly among that level’s participants” will need to move toward considering each participant individually.

It's possible that you wish to view this as "a single (downward) origin node plus N (upward) reverse nodes", and so you want to run N + 1 instances of the loop.

\$\endgroup\$
0
2
\$\begingroup\$

Applying @J_H suggestions, I am adding this answer as reference for any future readers.

def distribute(total_reward: float, decay_rate: float, max_levels: int) -> list:
    """
    Distributes a total reward across a referral network based on a geometric decay rate.

    This function calculates the initial reward amount for the first level and then applies
    a decay rate for subsequent levels, ensuring that the sum of all rewards is as close as
    possible to the total_reward. The geometric series formula used is: S = a(1-r^n)/(1-r),
    where S is the total sum of rewards, a is the first term, r is the common ratio (decay rate),
    and n is the number of terms (levels).

    Post-condition: The sum of the computed rewards is approximately equal to the specified
    total_reward, allowing for floating point rounding errors.

    :param total_reward: The total reward amount to be distributed (float).
    :param decay_rate: The decay rate for the distribution of rewards (float).
    :param max_levels: The number of levels in the referral network (int).
    :return: A list of reward amounts for each level (list of floats).
    """
    # Calculate the first term of the geometric series
    first_term = (total_reward * (1 - decay_rate)) / (1 - decay_rate ** max_levels)

    # Generate the list of rewards for each level
    rewards = [first_term * (decay_rate ** i) for i in range(max_levels)]

    # The sum of rewards should be close to the total_reward
    assert abs(sum(rewards) - total_reward) / total_reward < 1e-9, (
        "The sum of distributed rewards does not match the total reward."
    )

    return rewards


def main(total_reward: float, decay_rate: float, max_levels: int = 10):

    reward_distribution = distribute(total_reward, decay_rate, max_levels)
    for level, reward in enumerate(reward_distribution, start=1):
        print(f"Level {level} primary referrer receives: ${reward:.2f}")


if __name__ == "__main__":
    main(total_reward=3500, decay_rate=0.5)
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.