Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cycle checking results in inconsistent calculations #749

Closed
Morendil opened this issue Oct 23, 2018 · 13 comments
Closed

Cycle checking results in inconsistent calculations #749

Morendil opened this issue Oct 23, 2018 · 13 comments
Assignees
Labels
kind:fix Bugs are defects and failure demand.

Comments

@Morendil
Copy link
Contributor

Morendil commented Oct 23, 2018

Minimal repro:

# -*- coding: utf-8 -*-
import openfisca_france
from openfisca_core.simulations import Simulation

tax_benefit_system = openfisca_france.CountryTaxBenefitSystem()
situation = {
    "individus": {
        "demandeur": {
            "date_naissance": {
                "2018-10": "1952-01-05",
                "2018-09": "1952-01-05",
                "2018-08": "1952-01-05",
                "2018-07": "1952-01-05"
            },
            "salaire_imposable": {
                "2018": 1000,
                "2017": 1000,
                "2016": 1000
            }
        }
    },
    "familles": {
        "_": {
            "parents": [
                "demandeur"
            ],
            "enfants": [],
        }
    },
    "foyers_fiscaux": {
        "_": {
            "declarants": [
                "demandeur"
            ],
            "personnes_a_charge": []
        }
    },
    "menages": {
        "_": {
            "personne_de_reference": [
                "demandeur"
            ],
            "enfants": []
        }
    }
}

allMonths = ['2018-09']

calculs_ok = [
{'variable': 'rfr', 'periods': ['2016']},
{'variable': 'ass', 'periods': allMonths}
]

calculs_ko = [
{'variable': 'ass', 'periods': allMonths},
{'variable': 'rfr', 'periods': ['2016']}
]

simulation_actuelle = Simulation(
tax_benefit_system=tax_benefit_system,
simulation_json=situation)

for computation in calculs_ok:
    for period in computation['periods']:
        data = simulation_actuelle.calculate(computation['variable'], period)
        print (computation['variable'])
        print (data)

simulation_actuelle = Simulation(
tax_benefit_system=tax_benefit_system,
simulation_json=situation)

for computation in calculs_ko:
    for period in computation['periods']:
        data = simulation_actuelle.calculate(computation['variable'], period)
        print (computation['variable'])
        print (data)

The results of computing ass and rfr should not depend on the order of evaluation, but they do:

rfr
[573.99994]
ass
[0.]
ass
[0.]
rfr
[0.]

This is impacting France and indirectly Mes-Aides (preventing an upgrade to the latest version of France in production). The France model is very ramified and cycles abound; on inspection I think the current solution (max_nb_cycles) is misguided.

Connected to openfisca/openfisca-france#1211

@Morendil
Copy link
Contributor Author

Morendil commented Oct 23, 2018

One thing that makes this hard to investigate is that it manifests in many ways, some of which can be seen on a small scale (as in the test above) and some of which have to be embraced at a larger scale. I've created a gist with the trace of a calculation based on a version of Core that removes cycle checks altogether. This trace is from executing this Mes Aides test. It's 10K lines long - that's how deep the stack reaches.

One of the things that's going in there is that we calculate salaire_imposable 6 times for different periods, to calculate autonomie_financiere. If you search for autonomie_financiere in the trace it quickly becomes apparent that we compute it at ever increasing depths of stack, on each occasion for a period reaching further and further back in time.

The hunch I'm investigating is that this isn't about detecting "cycles" at all. It seems clear for this particular observation that the problem can be also framed as why are we even attempting to compute salaire_imposable in the first place, when we don't have a salaire_de_base in the first place ? This is a semantic determination, not a purely syntactic one. I, a human, know that it's pointless to try and compute a taxable salary when there is no salary information in the situation we are computing.

Is there a clue we could follow ? It seems like there is:

class salaire_de_base(Variable):
    value_type = float
    entity = Individu
    label = u"Salaire de base, en général appelé salaire brut, la 1ère ligne sur la fiche de paie"
    set_input = set_input_divide_by_period
    reference = u'http://www.insee.fr/fr/methodes/default.asp?page=definitions/salaire-mensuel-base-smb.htm'
    definition_period = MONTH

Well, it doesn't have a default value! That makes sense, because there's really no "default" base salary in salary computation - but there can be the absence of a salary. And in fact we can short-circuit a bunch of calculations if we could detect this. Unfortunately, the code for variables as written has a notion of a "default default value", and so we don't take advantage of this clue.

However, this doesn't save us yet. I've tried forcing Core to skip the computation if the variable being computed was salaire_imposable - and we still hit a cycle - but a different one, which is a clue that cycles in fact abound in the France model. (A good hint that we should solve this problem in a general way and not piecemeal, I think.)

Here is the resulting trace. This looks more like a "true" circular definition: we have the eligibility for AAH computed based on a bunch of income sources which include another aid called ASI/ASPA, for which eligibility in turn hinges on a bunch of income sources including AAH itself.

However, it's important to note that it's not a real cycle in the sense that each time around we shift the period of evaluation towards the past. It would be reasonable to compute a situation in which we want to know, for instance, what fluctuations exist over time as a result of being granted aids in the past which causes someone's income to go over a threshold which causes them not to be eligible for this aid.

What doesn't really make sense in the context of the test is this: we are evaluating eligibility for ASI/ASPA at infinite depths for a situation where its age condition is not fulfilled. Again we have a semantic understanding that we could short-circuit a computation - this time not because a variable is absent but because a variable is present.

This is not a new issue and is linked to vectorization of the computations. In short, we've assumed so far that vector computation necessarily means eager evaluation. We are evaluating ASI/ASPA for a 38-year-old on the chance that the array might contain elderly people. We've tried to improve this with the max_nb_cycles parameter, but this has a huge problem: max_nb_cycles only stops a computation when a cycle is detected, which means that we have been at least once through the loop of unnecessary computations.

This is absurd in the singular case, but it also makes kind of an absurdity, unless I'm very mistaken about the execution behaviour, of our performance story for vector computations. The bulk of the population is under 65, so just how much performance we're throwing away even in a vector context to run one loop of superfluous evaluations for everyone, relative to what we'd be throwing away by performing the computation sequentially, is an open question.

The good news is that we don't have to choose between the two evils of sequential computation or vast amounts of unnecessary computation, at least I don't believe we do. There seem to be technical solutions (lazyarray, cytoolz), or in the worst case we could syntactically elevate eligibility conditions to a special status, perform them always before computing income bases (or other expensive aggregates), and filter the arrays post-eligibility to perform the income basis computation only on entities that pass the first level of eligibility.

But anyway, the TLDR here is: I'd like to reframe this problem from detecting cycles to short-circuiting computation.

@Morendil Morendil changed the title Cycle checking results in inconsistent calculation results Cycle checking results in inconsistent calculations Oct 25, 2018
@Morendil
Copy link
Contributor Author

Morendil commented Oct 25, 2018

Continuing on from the previous episode…

I've investigated short-circuiting as suggested above. One problem that immediately arises is that it's not clear how to change the "shape" of a call to simulation.calculate(), given that

  • dependencies propagate via the stack, but
  • the vector being operated upon is not itself passed via the stack but in a Holder.

What I'd initially envisioned was something like the following scenario, using aah as an example:

  • compute the vector aah_eligible, a relatively shallow computation
  • count how many items in that vector are True and note the indices
  • create a new sub-vector of the vector being operated on, containing these indices
  • pass this (smaller) population vector on to the formula for aah_base_ressources
  • "unpack" this small vector upon return, into a larger vector, filling the indices in the original that correspond to eligible individuals

My rationale for this approach was that if, for instance, AAH eligibility and ASI eligibility have something that syntactically looks like a circular dependency, but that in practice AAH and ASI eligibility are mutually exclusive OR dependent on inputs that only satisfy them in particular periods (which is in fact the case), then this ensures that the vector eventually becomes empty, and the computation terminates.

The problem here is that we do not have a way to (temporarily, in stack-like fashion) change the vector being operated on into another, because this context is not part of the execution stack, so this fails at step 3.

At this point I decided to step back from solving the question "how to short-circuit computations", and instead go back to investigating the original problem report: figuring out why rfr is zeroed out by computing ass first in the above test.

The reason this happens is two-fold:

  • the result of a computation is always put in cache, even if the computation failed because of a cycle being detected
  • the exception raised on detecting a cycle is re-raised through as many cycles as required to ensure that the computation that fails is one that specified a max_nb_cycles

Instrumenting the code in simulations.py to figure out when the rfr variable was being computed and cached, and how this interacted with cycle detection, resulted in this:

...
Formula result for rfr [0.] 2014
Caching rfr [0.] 2014
in cycle with retraite_imposable 2016-01
in cycle with revenu_assimile_pension 2016
in cycle with revenu_assimile_pension_apres_abattements 2016
in cycle with traitements_salaires_pensions_rentes 2016
in cycle with rev_cat_tspr 2016
in cycle with rev_cat 2016
in cycle with rbg 2016
in cycle with rng 2016
in cycle with rni 2016
ultimate cycle error for rfr 2016
Formula result for rfr None 2016
Default for rfr [0.] 2016
Caching rfr [0.] 2016
...

We see that there is a cycle error in retraite_imposable, and this "bubbles up" the stack for a while. The first time we reach, moving up the stack, a computation that sets max_nb_cycles is this line:
rfr = individu.foyer_fiscal('rfr', period = period.n_2, max_nb_cycles = 1)

This is why it's rfr that gets zeroed out and not some other variable.

On branch simplify-cycle-detection, we can see the effect of removing the second rule above (re-raise the error until we hit a line with a max_nb_cycles): all of the tests keep passing (except the unit tests that checked this rule of cycle detection). So we might have a quick-and-dirty fix for Mes Aides' problem until we figure out a more permanent solution.

@guillett Would you please test your problematic calculations from Mes Aides against this particular branch of core ?

@bonjourmauko bonjourmauko added kind:fix Bugs are defects and failure demand. flow:support:doing labels Oct 25, 2018
@bonjourmauko
Copy link
Member

bonjourmauko commented Oct 27, 2018

Also note that by defaulting max_nb_cycles to 0:

# simulations.py:166
max_nb_cycles = parameters.get('max_nb_cycles')

if max_nb_cycles is not None:
    self.max_nb_cycles = max_nb_cycles
else:
    self.max_nb_cycles = max_nb_cycles = 0

We obtain the desired result:

rfr
[573.99994]
ass
[0.]
ass
[0.]
rfr
[573.99994]

This increases stack's maximum recursion depth from 242 to 268 (~10%).

This makes 7 out of 1508 France's tests fail:

$ openfisca-run-test --country-package openfisca_france tests
======================================================================
FAIL: unittest.case.FunctionTestCase (check)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 152, in check
    _run_test(period_str, test, verbose, only_variables, ignore_variables, options)
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 249, in _run_test
    relative_error_margin = relative_error_margin,
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/__init__.py", line 48, in assert_near
    diff, absolute_error_margin)
AssertionError: b'revenu_disponible@2017: '[37042.36] differs from [36355.] with an absolute margin [687.3594] > 1
-------------------- >> begin captured logging << --------------------
openfisca_core.tools.test_runner: ERROR: revenu_disponible.yaml: f1aw_f4ba_2017 - 2017
--------------------- >> end captured logging << ---------------------

======================================================================
FAIL: unittest.case.FunctionTestCase (check)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 152, in check
    _run_test(period_str, test, verbose, only_variables, ignore_variables, options)
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 249, in _run_test
    relative_error_margin = relative_error_margin,
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/__init__.py", line 48, in assert_near
    diff, absolute_error_margin)
AssertionError: b'revenu_disponible@2017: '[71194.37] differs from [70507.] with an absolute margin [687.3672] > 1
-------------------- >> begin captured logging << --------------------
openfisca_core.tools.test_runner: ERROR: revenu_disponible.yaml: acomptes_ir_2017 - 2017
--------------------- >> end captured logging << ---------------------

======================================================================
FAIL: unittest.case.FunctionTestCase (check)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 152, in check
    _run_test(period_str, test, verbose, only_variables, ignore_variables, options)
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 249, in _run_test
    relative_error_margin = relative_error_margin,
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/__init__.py", line 48, in assert_near
    diff, absolute_error_margin)
AssertionError: b'revenu_disponible@2018: '[2958671.8] differs from [2957973.8] with an absolute margin [698.] > 1
-------------------- >> begin captured logging << --------------------
openfisca_core.tools.test_runner: ERROR: revenu_disponible.yaml: pfu_av_2018 - 2018
--------------------- >> end captured logging << ---------------------

======================================================================
FAIL: unittest.case.FunctionTestCase (check)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 152, in check
    _run_test(period_str, test, verbose, only_variables, ignore_variables, options)
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 249, in _run_test
    relative_error_margin = relative_error_margin,
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/__init__.py", line 48, in assert_near
    diff, absolute_error_margin)
AssertionError: b'revenu_disponible@2018: '[18597.934] differs from [17900.] with an absolute margin [697.9336] > 1
-------------------- >> begin captured logging << --------------------
openfisca_core.tools.test_runner: ERROR: revenu_disponible.yaml: pfu_epargne_non_solidaire_etats_non_cooperatifs_2018 - 2018
--------------------- >> end captured logging << ---------------------

======================================================================
FAIL: unittest.case.FunctionTestCase (check)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 152, in check
    _run_test(period_str, test, verbose, only_variables, ignore_variables, options)
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 249, in _run_test
    relative_error_margin = relative_error_margin,
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/__init__.py", line 48, in assert_near
    diff, absolute_error_margin)
AssertionError: b'revenu_disponible@2018: '[203457.94] differs from [202760.] with an absolute margin [697.9375] > 1
-------------------- >> begin captured logging << --------------------
openfisca_core.tools.test_runner: ERROR: revenu_disponible.yaml: pfu_hors_av_epargne_non_solidaire_etats_non_cooperatifs_2018 - 2018
--------------------- >> end captured logging << ---------------------

======================================================================
FAIL: unittest.case.FunctionTestCase (check)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 152, in check
    _run_test(period_str, test, verbose, only_variables, ignore_variables, options)
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 249, in _run_test
    relative_error_margin = relative_error_margin,
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/__init__.py", line 52, in assert_near
    diff, abs(relative_error_margin * target_value))
AssertionError: b'rsa@2017-01: '[0.] differs from [335.17] with a relative margin [335.17] > [3.3517]
-------------------- >> begin captured logging << --------------------
openfisca_core.tools.test_runner: ERROR: rsa_2017.yaml: Les derniers benefices agricoles et autres TNS sont pris en compte dans la BR du RSA - 2017-01
--------------------- >> end captured logging << ---------------------

======================================================================
FAIL: unittest.case.FunctionTestCase (check)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 152, in check
    _run_test(period_str, test, verbose, only_variables, ignore_variables, options)
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/test_runner.py", line 249, in _run_test
    relative_error_margin = relative_error_margin,
  File "/Users/hyperion/.pyenv/versions/3.7.0/envs/openfisca-france-3.7.0/lib/python3.7/site-packages/openfisca_core/tools/__init__.py", line 52, in assert_near
    diff, abs(relative_error_margin * target_value))
AssertionError: b'rsa@2017-01: '[0.] differs from [135.17] with a relative margin [135.17] > [1.3517]
-------------------- >> begin captured logging << --------------------
openfisca_core.tools.test_runner: ERROR: rsa_2017.yaml: Les derniers benefices TNS sont pris en compte dans la BR du RSA - 2017-01
--------------------- >> end captured logging << ---------------------

----------------------------------------------------------------------
Ran 1508 tests in 495.242s

FAILED (failures=7)

By defaulting max_nb_cycles to 1:

# simulations.py:166
max_nb_cycles = parameters.get('max_nb_cycles')

if max_nb_cycles is not None:
    self.max_nb_cycles = max_nb_cycles
else:
    self.max_nb_cycles = max_nb_cycles = 1

We also obtain the desired result:

rfr
[573.99994]
ass
[0.]
ass
[0.]
rfr
[573.99994]

But this increases stack's maximum recursion depth from 242 to 961 (~397%)!!!

@Morendil
Copy link
Contributor Author

Making progress on short-circuiting.

Consider the following method we might add to entities.py:

def condition(self, condition_name, variable_name, period):
condition_array = self(condition_name, period)
if condition_array.any():
return condition_array * self(variable_name, period)
else:
return self.get_holder(variable_name).default_array()

This allows a new syntax-like construct for formulas:

class aah_base(Variable):
    calculate_output = calculate_output_add
    value_type = float
    entity = Individu
    definition_period = MONTH

    def formula(individu, period, parameters):
        return individu.condition('aah_eligible', 'aah_base_montant', period)

Obviously this requires a bit of work on existing formulas. Is it worth it though ?

I tried a change of this sort in just two places, the eligibility for AAH and that for PAJE. (No particular reason, they're the ones that popped up first on looking for likely instances of the general pattern "take the array-wise logical AND of an eligibility condition and an amount".)

The results are impressive - I get a 35% gain on execution time for the Mes Aides tests (from 315s to 206s) and a 20% total gain on the entire test suite. Not so bad for so few lines.

An obvious objection is that the above implementation does not realize these performance gains in the general vector case (n > 1), only for singleton simulations or those where these eligibility conditions are homogenous.

The gains in the singleton case alone could be worth it just in terms of helping local test execution or CI load. But I think we can realize the gains in the general vector case as well by partitioning computation. Say we start with a vector of 1000, and encounter an eligibility condition which 25% only pass. Then we can run the cheaper computation on 75% of the vector and restrict the more expensive one to that 25%. Of that 25%, the ones that require deeper computation are also probably going to encounter some more eligibility conditions, all of which will reduce the vector further.

@bonjourmauko
Copy link
Member

What I'd initially envisioned was something like the following scenario, using aah as an example:

compute the vector aah_eligible, a relatively shallow computation
count how many items in that vector are True and note the indices
create a new sub-vector of the vector being operated on, containing these indices
pass this (smaller) population vector on to the formula for aah_base_ressources
"unpack" this small vector upon return, into a larger vector, filling the indices in the original that correspond to eligible individuals

You could achieve this by boolean indexing and fancy indexing, however memory performance should be tested as both create copies of the arrays, instead of just views. An example:

import numpy                                                                                                                                                                                        

montant_aah = numpy.zeros(int(1e5))                                                                                                                                                                                                                                                                                                                                                      
# array([0., 0., 0., ..., 0., 0., 0.])

aah_eligible = numpy.random.choice(numpy.array([True, False]), int(1e5))                                                                                                                                                                                                                                                                                                                
# array([ True,  True,  True, ...,  True, False,  True])

ahh_eligible_indexes = numpy.where(aah_eligible)[0]                                                                                                                                                 
# array([    0,     1,     2, ..., 99994, 99997, 99999])

# let's say this is the formula for now
aah_base_resource_formula = 100                                                                                                                                                                     

montant_aah[ahh_eligible_indexes] += aah_base_resource_formula                                                                                                                                      
# array([100., 100., 100., ..., 100., 100., 100.])

montant_aah                                                                                                                                                                                        
# array([100., 100., 100., ..., 100.,   0., 100.])

@bonjourmauko
Copy link
Member

bonjourmauko commented Oct 28, 2018

Hi @Morendil, thanks for the investigation on the possible scenarios.

The gains in the singleton case alone could be worth it just in terms of helping local test execution or CI load. But I think we can realize the gains in the general vector case as well by partitioning computation.

This is all theoretical, but let's say for:

  • An array random defined as
random = numpy.random.rand(n) 
  • An array condition defined as an heterogenous population
condition = numpy.random.choice(numpy.array([True, False]), n)
  • A non-partitioned formula calculation formula A defined as
random - random
  • And a partitioned formula calculation formula B defined as
eligible = random[numpy.where(condition)[0]]
random[numpy.where(condition)[0]] = eligible + eligible 

These are the results for different values of n:

n formula A formula B condition.any()
10 428 ns 975 ns 1.21 µs
100 444 ns 1.1 µs 1.2 µs
1000 745 ns 2.91 µs 1.31 µs
10000 3.42 µs 30 µs 1.39 µs
100000 135 µs 481 µs 2.21 µs
1000000 733 µs 5.89 ms 10.6 µs
10000000 27.7 ms 75 ms 111 µs

We can see that:

  • the bigger the population, the greater the potential benefit of short-circuiting calculation

  • array.any() could be a bit expensive for large populations

  • for this already naive formula, partitioning can be also quite expensive (note that there may be more performant partitioning techniques avoiding array copy, like sorting + slicing, etc)

  • note also that we're assuming 50% of population is eligible, cost of indexing for 1%, 10% etc. may be significantly lower

  • partitioning cost will be exponential with recursion depth, and could yield great benefits if partitions are small, and recursions are short-circuited

Of course, all of this has to be tested against actual populations (we could generate with https://github.com/openfisca/openfisca-survey-manager/blob/master/openfisca_survey_manager/tests/test_scenario.py for testing).

@Morendil
Copy link
Contributor Author

Linking to this old discussion on short-circuiting: #363.

@Morendil
Copy link
Contributor Author

Morendil commented Nov 26, 2018

To recap some progress in the few weeks since this was opened, some avenues we explored for cracking this were the following:

  • change the default allowed cycle depth
  • reify conditions, switches and so on, to short-circuit computations
  • rely on the presence or absence of inputs to formulas, to decide whether the computation is likely to terminate; I have investigated this but it's inconclusive; I encountered problems with "default default values" and some model consistency issues.

What we are lacking, it seems to me, is some clarity on how OpenFisca is supposed to work; a specification of any kind, formal or informal, would probably help a lot.

One of the examples that @guillett came up with was:

class pension(Variable):
  definition_period = MONTH

  formula(entity, period):
    return entity.calculate('pension', period.last_month)

While this is pathological, it's also a simplified example of the kind of thing we have been writing without meaning to. It is not properly speaking a cycle since pension for the previous month isn't the same as pension for this month (we've been informally calling them "spirals").

In fact, this code will terminate if there is an input for pension at some previous month relative to the requested period. If that is not the case, we could still statically reason our way out of an infinite loop in the following way:

  • we compute the set of variables that pension depends on, recursively
  • we subtract the set of calculated variables - those with a formula and no input values
  • this is the empty set, thus pension is not in fact calculable, we fail (or return a default)

Computing the dependency graph can help us statically assess the situation, and would have other uses, such as helping visualize the model, helping navigate variables in the API and its exploration UI.

Our lack of precision in defining "what we talk about when we talk about OpenFisca formulas" may have led us to write formulas that will stop making sense when we reexamine them in the light of a more precise framework that incorporates notions of "free variables" and "calculable formulas". The ease of using 0 as a default value for just about everything, the fact that variables with formulas can also be used as inputs at the drop of a hat, and other "conveniences" in Core might mean that we have some (or a lot of) work to make the models conform to the calculable specification a posteriori. But it might be well worth it in terms of accuracy and quality of our computations.

To take an example from France, one of the cycles we explicitly disallow is:
aide_logement_neutralisation_rsa -> rsa -> aide_logement -> aide_logement_neutralisation_rsa

However, the other dependency of aide_logement_neutralisation_rsa, which is revenu_assimile_salaire_apres_abattements, is never evaluated if we allow indefinite cycles. This is silly because that variable is essentially salaire_imposable, the taxable salary, which is rather easy to assess as non-calculable when there is no input of a net or base salary.

Once the calculation of aide_logement_neutralisation_rsa is triggered it takes close to 5000 intermediate calculations before closing the cycle - which we could simply skip if we looked at the free variables before starting.

@benjello
Copy link
Member

benjello commented Nov 26, 2018

I am not sure I understand everything but I can give you some input of how we got there for france.

  • We had a simple model which worked yearly and was then made more complex to take into account all the possible time behavior.

  • The model was also made more complex by the introduction of many new variables to take care of new input variables.

  • To alleviate the need to specify every variable we use default for variable without formula and allowed to neutralize some variable so they are no more calculated and return their default value

  • Still the were improper initial states (a set of initial variables) that were resulting in infinite spirals toward the past. They could have been explicitly resolved by expanding the initial set.

  • This solution was rejected because it made the specification of tests too cumbersome and the max_nb_cycles parameter.

  • I never thought that it was a good solution but it was a quick fix.

  • My preferred solution would to stop the spirals toward the past is the following:

    • allow the user be able to specify a backstop date before which every a variable could be reset to its default value or a specified value or as it last non-default value. This could be set variable by variable.
    • this should not set in the country package but provided by the user when initializing its simulation.
    • the main idea is provide enough information or allow specific explicit imputation

@Morendil
Copy link
Contributor Author

provided by the user when initializing its simulation.

Well, if we're looking at what the user provides, we can also look at all the inputs; after all if we can make a decision on whether a formula is calculable or not based on that data, we shouldn't have the user do any work. If we can't make that decision, then it's hard to tell the user how to make it. What advice would we give the user ?

  • try the oldest variable in your input (but we could do that for them)
  • something else ? but any date more recent than the oldest input could cut off some useful data…
  • use trial and error (how can we guarantee that any date will work though?)

allow the user be able to specify a backstop date before which every a variable could be reset to its default value or a specified value

The first option ("try the oldest variable") is what I experimented my initial investigations after discussing the problem with @guillett and it didn't seem to solve the problem very effectively. In some cases the dependencies are so deep that a few cycles are enough to overflow the stack. So if you have a wide range of dates, you're stuck with the problem.

Admittedly, I did this for all variables and not on a variable-by-variable basis. On the other hand if you have to do this for all variables, it could get very tedious to do it by hand…

I agree with the logic that we should keep the formulas in the country package agnostic of how they're going to be computed, and rely on the inputs. But I think we should be able to "follow the values back in time" automatically, not force the user to do it; and I think we should give strong guarantees that computations always terminate, without ever pushing on the user the responsibility for termination (which is what max_nb_cycles also does). We should be able to raise an error statically if a computation is going to overflow the stack.

@fpagnoux
Copy link
Member

Thanks @Morendil and @maukoquiroga for all this great investigation, this is really interesting and could offer a lot of potential improvements for OpenFisca!

the fact that variables with formulas can also be used as inputs at the drop of a hat

This is caused by the fact an OpenFisca model can be used for different usecases. For instance, a "net salary" is usually an input if you're calculating social benefits, but an output if you're calculation social contributions.

@fpagnoux
Copy link
Member

Also, FYI, there is already something in Core to print the trace of a calculation:

simulation = Simulation(tax_benefit_system, simulation_dict, trace = True)
simulation.calculate('disposable_income', '2017-01')
simulation.tracer.print_computation_log()

will print

  disposable_income<2017-01> >> [3920. 2675.]
    salary<2017-01> >> [4000. 2500.]
    basic_income<2017-01> >> [600. 600.]
      age<2017-01> >> [37 33]
        birth<2017-01> >> ['1980-01-01' '1984-01-01']
    income_tax<2017-01> >> [600. 375.]
      salary<2017-01> >> [4000. 2500.]
    social_security_contribution<2017-01> >> [80. 50.]
      salary<2017-01> >> [4000. 2500.]

@Morendil
Copy link
Contributor Author

Fixed in #817

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
kind:fix Bugs are defects and failure demand.
Projects
None yet
Development

No branches or pull requests

5 participants