Systematically analyzing dice roll outcomes
Your dice_outcomes function is not generic enough. It works for you with your use case but consider the following call:

list(dice_outcomes(3))


the result is surprising:

[[1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1]]


This is because your yield output always return the same list; these are 216 references to the same list (bonus points if you are able to understand why this is always [min] * num). Using the rolls in a for loop as you do work because you transform the list each time and don't store a reference, but some use-case make your function buggy.

But all in all, this is not necessary to come with a fix (such as yield tuple(output)) because you are just reinventing itertools.product. You could instead write:

def dice_outcomes(num, min=1, max=6):
    yield from itertools.product(range(min, max+1), repeat=num)




Now about your class and for loops… the way you add dice rolls to already existing rolls feels really off. Especially given how you wrote a function to generate rolls from several die in the first place.

Besides, you can feed a generator to a Counter and it will happily count the occurences of each values:

self.outcomes = Counter(map(roll_handler, dice_outcomes(num, MIN, MAX)))


This means that:


You don't need the add method;
You would need to add num and possibly min and max as parameters;
You can compute the sample_space in __init__.


Given how simple dice_outcomes is now and how tied the class and sample_space_report are, you could combine everything in this class:

from fractions import Fraction
from collections import Counter
from itertools import product


class AbilityScoreStats:
    def __init__(self, roll_handler, num_die, min_roll=1, max_roll=6):
        max_roll += 1  # Account for excluded upper bound and off-by-one substraction
        self.sample_space = (max_roll - min_roll) ** num_die
        self.outcomes = Counter(
            roll_handler(roll)
            for roll in product(range(min_roll, max_roll), repeat=num_die)
        )

    def print_report(self, title):
        print(f'    {title}:')

        prob_sum = 0
        for outcome, occurrences in self.outcomes.items():
            probability = Fraction(occurrences, sample_space)
            prob_sum += probability
            print(f'{outcome}: {probability} ({float(probability):.2%})')
        print(f'Validation sum = {prob_sum}')


Usage being:

standard = AbilityScoreStats(lambda roll: sum(roll) - min(roll), 4)
classic = AbilityScoreStats(sum, 3)
heroic = ability_score_stats(lambda roll: sum(roll) + 6, 2)

standard.print_report('Standard')
classic.print_report('Classic')
heroic.print_report('Heroic')




Now, there is still two issues:


The prob_sum variable validating the computed values shouldn't be left once you have tested your function;
A class not used as storage having only 2 functions, one of them is __init__ should be replaced by a function:




from fractions import Fraction
from collections import Counter
from itertools import product


def ability_score_stats(title, roll_handler, num_die, min_roll=1, max_roll=6):
    max_roll += 1  # Account for excluded upper bound and off-by-one substraction
    sample_space = (max_roll - min_roll) ** num_die
    outcomes = Counter(
        roll_handler(roll)
        for roll in product(range(min_roll, max_roll), repeat=num_die)
    )

    assert sample_space == sum(outcomes.values())

    print(f'    {title}:')
    for outcome, occurrences in outcomes.items():
        probability = Fraction(occurrences, sample_space)
        print(f'{outcome}: {probability} ({float(probability):.2%})')


if __name__ == '__main__':
    ability_score_stats('Standard', lambda roll: sum(roll) - min(roll), 4)
    ability_score_stats('Classic', sum, 3)
    ability_score_stats('Heroic', lambda roll: sum(roll) + 6, 2)


I somehow left the validation so you can still check that the computations are the same. But you can disable the check by running python -O your_script.py.