Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/rate-scale-conversion-nonzero-origin.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`MarginalRateTaxScale.to_average` now keeps a first threshold other than 0, so the average scale levies the marginal tax at every threshold and `to_marginal` gives the scale back; it raises `ValueError`, instead of returning a wrong scale or raising `ZeroDivisionError`, when no average rate scale can levy the tax, and it converts one bracket with surplus rates as it converts the paired bracket.
6 changes: 6 additions & 0 deletions policyengine_core/taxscales/linear_average_rate_tax_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ def to_marginal(self) -> MarginalRateTaxScale:

previous_i = 0
previous_threshold = 0
if len(self.thresholds) > 1 and self.thresholds[0] != 0 and self.rates[0] == 0:
# The tax is 0 below the first threshold and, at a rate of 0, at
# it too, so the marginal scale starts there. With another rate
# the tax jumps at a first threshold other than 0, which no
# marginal scale can follow, and the marginal scale starts at 0.
previous_threshold = self.thresholds[0]
terminal_rate = self.rates[0]

for threshold, rate in zip(self.thresholds[1:], self.rates[1:]):
Expand Down
96 changes: 75 additions & 21 deletions policyengine_core/taxscales/marginal_rate_tax_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,35 +261,89 @@ def scale_tax_scales(self, factor: float) -> MarginalRateTaxScale:
return scaled_tax_scale.multiply_thresholds(factor)

def to_average(self) -> taxscales.LinearAverageRateTaxScale:
"""
Convert the scale to a LinearAverageRateTaxScale.

The average scale has the same thresholds, then a last threshold of
infinity that carries the top rate, so ``to_marginal`` gives this
scale back. Its rate at each threshold is the tax there divided by
the threshold, or 0 where both are 0. So the two scales levy the same
tax at every threshold.

One bracket from 0 instead gets its rate at both thresholds. Its
average rate is the same at every base, so the two scales levy the
same tax at every base. With two or more brackets,
``LinearAverageRateTaxScale.calc`` keeps the average rate of the last
finite threshold above it. That matches this scale's tax only when
the top rate is that average rate.

:raises ValueError: if no average rate scale levies the tax at every
threshold or, for one bracket, at every base. This is so for one
bracket with a nonzero rate whose threshold is not 0, and for a
threshold of 0 at which the tax is not 0.
"""
average_tax_scale = taxscales.LinearAverageRateTaxScale(
name=self.name,
option=self.option,
unit=self.unit,
)

if len(self.thresholds) == len(self.rates) == 1 and self.thresholds[0] == 0:
# A zero-origin singleton has a constant average rate. Starting
# at a zero rate and interpolating toward infinity would levy no tax.
average_tax_scale.add_bracket(0, self.rates[0])
else:
if not self.thresholds:
average_tax_scale.add_bracket(0, 0)

if self.thresholds:
i = 0
previous_threshold = self.thresholds[0]
previous_rate = self.rates[0]

for threshold, rate in itertools.islice(
zip(self.thresholds, self.rates),
1,
None,
):
i += previous_rate * (threshold - previous_threshold)
return average_tax_scale

# A first threshold of 0 stays the integer 0, as it has always been.
first_threshold = self.thresholds[0] if self.thresholds[0] != 0 else 0

if len(self.thresholds) == 1:
# As for longer scales, rates past the paired ones are ignored.
rate = self.rates[0]
if first_threshold != 0 and rate != 0:
# The tax is rate * (base - threshold) from the threshold up,
# so the average rate is rate * (1 - threshold / base). A
# linear average rate scale cannot follow it.
raise ValueError(
f"{self.__class__.__name__}.to_average cannot convert one "
f"bracket with threshold {first_threshold} and rate "
f"{rate}: no linear average rate scale levies its tax. "
"Only one bracket with threshold 0 or rate 0 converts."
)
# From 0, the average rate is the rate at every base. At rate 0 it
# is 0 at every base. Starting at a rate of 0 and interpolating
# toward infinity would levy no tax.
average_tax_scale.add_bracket(first_threshold, rate)
average_tax_scale.add_bracket(float("Inf"), rate)
return average_tax_scale

# The tax at the first threshold is 0, so its average rate is 0.
average_tax_scale.add_bracket(first_threshold, 0)

i = 0
previous_threshold = first_threshold
previous_rate = self.rates[0]

for threshold, rate in itertools.islice(
zip(self.thresholds, self.rates),
1,
None,
):
i += previous_rate * (threshold - previous_threshold)
if threshold != 0:
average_tax_scale.add_bracket(threshold, i / threshold)
previous_threshold = threshold
previous_rate = rate
elif i == 0:
# Every average rate taxes a base of 0 at 0.
average_tax_scale.add_bracket(threshold, 0)
else:
raise ValueError(
f"{self.__class__.__name__}.to_average cannot convert a "
f"threshold of 0 at which the tax is {i}: an average rate "
"scale taxes a base of 0 at 0."
)
previous_threshold = threshold
previous_rate = rate

# Far above the last threshold the average rate approaches the top rate.
average_tax_scale.add_bracket(float("Inf"), previous_rate)
# ``to_marginal`` reads the top rate from the infinite threshold.
# ``LinearAverageRateTaxScale.calc`` does not use it.
average_tax_scale.add_bracket(float("Inf"), previous_rate)

return average_tax_scale
23 changes: 23 additions & 0 deletions tests/core/tax_scales/test_linear_average_rate_tax_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,29 @@ def test_to_marginal_uses_last_paired_rate_at_infinity(rates_type):
assert result.rates == [0.2, 0.3]


@pytest.mark.parametrize("first_threshold", [50, -50])
def test_to_marginal_starts_at_a_first_threshold_with_rate_0(first_threshold):
# The scale taxes nothing up to and at its first threshold, so the
# marginal scale starts there. It used to start at 0, which from 50 taxed
# a base of 50 at 50 * 0.2 = 10 instead of 0.
tax_base = numpy.array([first_threshold, 100, 1_000])
tax_scale = taxscales.LinearAverageRateTaxScale()
tax_scale.add_bracket(first_threshold, 0)
tax_scale.add_bracket(100, 0.2)

result = tax_scale.to_marginal()

# Tax at 100 is 100 * 0.2 = 20, spread over the bracket from the first
# threshold.
assert result.thresholds == [first_threshold, 100]
tools.assert_near(
result.rates, [20 / (100 - first_threshold), 0.2], absolute_error_margin=1e-12
)
tools.assert_near(
result.calc(tax_base), tax_scale.calc(tax_base), absolute_error_margin=1e-10
)


def test_to_marginal_of_no_brackets():
result = taxscales.LinearAverageRateTaxScale().to_marginal()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
* at each threshold the tax is the threshold times its rate, and above the
first threshold the tax has no jump there;
* adding a last threshold of infinity, at any rate, does not change the tax;
* when the first threshold is 0, ``to_marginal`` levies the same tax at each
threshold and above the last one, within accumulated floating-point error.
* when the first threshold or its rate is 0, so that the tax does not jump at
the first threshold, ``to_marginal`` levies the same tax at each threshold
and above the last one, within accumulated floating-point error.

Examples are in ``test_linear_average_rate_tax_scale.py``.
"""
Expand Down Expand Up @@ -141,15 +142,34 @@ def test_infinite_last_threshold_changes_nothing(brackets, top_rate, extra_bases
)


@st.composite
def _brackets_without_a_jump(draw):
# The tax jumps from 0 at the first threshold unless it or its rate is 0.
thresholds, rates = draw(
_brackets(
first_threshold=st.one_of(
st.just(0), st.integers(min_value=-1_000, max_value=1_000)
)
)
)
if thresholds[0] != 0:
rates[0] = 0.0
return thresholds, rates


@settings(max_examples=300, deadline=None)
@given(_brackets(first_threshold=st.just(0), min_size=2))
@given(_brackets_without_a_jump())
@example(brackets=([0, 999_999, 1_000_000], [0, 1, 0]))
@example(brackets=([0], [0.37]))
@example(brackets=([0], [-1.0]))
@example(brackets=([50, 100], [0.0, 0.2]))
@example(brackets=([-50, 100], [0.0, 0.2]))
def test_calc_agrees_with_to_marginal_at_thresholds_and_above(brackets):
# One bracket is left out: ``to_marginal`` cannot convert it yet.
thresholds, rates = brackets
tax_scale = _scale(thresholds, rates)
last = thresholds[-1]
tax_base = numpy.array(
thresholds + [thresholds[-1] + 1, 2 * thresholds[-1] + 1, 1e9], dtype=float
thresholds + [last + 1, last + abs(last) + 1, 1e9], dtype=float
)

marginal_scale = tax_scale.to_marginal()
Expand All @@ -158,7 +178,7 @@ def test_calc_agrees_with_to_marginal_at_thresholds_and_above(brackets):
# Marginal calc shifts thresholds by 1 + eps. Each active bracket's
# endpoint magnitudes bound this shift and width subtraction; weighting
# by |marginal rate| also covers cancellation between large bracket taxes.
endpoints = lower + numpy.minimum(tax_base[:, None], upper)
endpoints = numpy.abs(lower) + numpy.abs(numpy.minimum(tax_base[:, None], upper))
endpoints = numpy.where(tax_base[:, None] >= lower, endpoints, 0)
# The stored threshold*rate taxes telescope in exact arithmetic. Allow
# rounding in secant conversion, widths, products and the bracket sum.
Expand Down
114 changes: 113 additions & 1 deletion tests/core/tax_scales/test_marginal_rate_tax_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ def test_to_average():

# Note: assert_near doesn't work for inf.
assert result.thresholds == [0, 1, 2, numpy.inf]
assert result.rates, [0, 0, 0.05, 0.2]
assert result.rates == [0, 0, 0.05, 0.2]
tools.assert_near(
result.calc(tax_base),
[0, 0.0375, 0.1, 0.125],
Expand Down Expand Up @@ -258,6 +258,118 @@ def test_to_average_uses_last_paired_rate(rates_type):
assert result.rates == [0, 0.1, 0.2]


@pytest.mark.parametrize("rates_type", [list, numpy.array])
def test_to_average_of_a_single_bracket_uses_its_paired_rate(rates_type):
# The surplus rate 0.8 used to send one bracket down the path for longer
# scales, which gave an average scale that levied no tax.
tax_scale = taxscales.MarginalRateTaxScale()
tax_scale.thresholds = [0]
tax_scale.rates = rates_type([0.3, 0.8])

result = tax_scale.to_average()

assert result.thresholds == [0, numpy.inf]
assert result.rates == [0.3, 0.3]
tools.assert_near(
result.calc(numpy.array([-1, 0, 1, 1_000])),
[0, 0, 0.3, 300],
absolute_error_margin=1e-10,
)


@pytest.mark.parametrize("threshold", [50, -50])
def test_to_average_rejects_a_single_bracket_away_from_0(threshold):
# One bracket from 50 taxes 0.37 * (base - 50), so its average rate is
# 0.37 * (1 - 50 / base). No linear average rate scale levies that tax.
# This used to give an average scale that taxed every base at 0, and a
# round trip to a bracket from 0. Before that it raised UnboundLocalError.
# From -50 the tax at base 0 is 0.37 * 50 = 18.5, but an average rate
# scale taxes a base of 0 at 0.
tax_scale = taxscales.MarginalRateTaxScale()
tax_scale.add_bracket(threshold, 0.37)

with pytest.raises(ValueError, match="cannot convert one bracket"):
tax_scale.to_average()


@pytest.mark.parametrize("threshold", [50, -50])
def test_to_average_of_a_single_zero_rate_bracket_away_from_0(threshold):
tax_scale = taxscales.MarginalRateTaxScale()
tax_scale.add_bracket(threshold, 0)

result = tax_scale.to_average()

assert result.thresholds == [threshold, numpy.inf]
assert result.rates == [0, 0]
tools.assert_near(
result.calc(numpy.array([-1_000, threshold, 0, 1_000])),
[0, 0, 0, 0],
absolute_error_margin=1e-10,
)
round_trip = result.to_marginal()
assert round_trip.thresholds == [threshold]
assert round_trip.rates == [0]


@pytest.mark.parametrize(
"thresholds, average_rates",
[
# Tax at 100 is 0.1 * (100 - 50) = 5, so its average rate is 0.05.
([50, 100], [0, 0.05, 0.2]),
# Tax at 100 is 0.1 * (100 + 50) = 15, so its average rate is 0.15.
([-50, 100], [0, 0.15, 0.2]),
],
)
def test_to_average_keeps_a_first_threshold_away_from_0(thresholds, average_rates):
# The first threshold used to become 0. From 50, that taxed a base of 50
# at 50 * (50 * 0.05 / 100) = 1.25 instead of 0, and the round trip gave
# brackets from 0 and 100 with rates 0.05 and 0.2.
tax_scale = taxscales.MarginalRateTaxScale()
tax_scale.add_bracket(thresholds[0], 0.1)
tax_scale.add_bracket(thresholds[1], 0.2)

result = tax_scale.to_average()

assert result.thresholds == [*thresholds, numpy.inf]
tools.assert_near(result.rates, average_rates, absolute_error_margin=1e-12)
tools.assert_near(
result.calc(numpy.array(thresholds)),
tax_scale.calc(numpy.array(thresholds)),
absolute_error_margin=1e-10,
)
round_trip = result.to_marginal()
assert round_trip.thresholds == thresholds
tools.assert_near(round_trip.rates, [0.1, 0.2], absolute_error_margin=1e-12)


def test_to_average_with_no_tax_at_a_threshold_of_0():
tax_scale = taxscales.MarginalRateTaxScale()
tax_scale.add_bracket(-50, 0)
tax_scale.add_bracket(0, 0.2)
tax_scale.add_bracket(100, 0.3)

result = tax_scale.to_average()

# Tax at 100 is 0.2 * 100 = 20, so its average rate is 0.2.
assert result.thresholds == [-50, 0, 100, numpy.inf]
tools.assert_near(result.rates, [0, 0, 0.2, 0.3], absolute_error_margin=1e-12)
round_trip = result.to_marginal()
assert round_trip.thresholds == [-50, 0, 100]
tools.assert_near(round_trip.rates, [0, 0.2, 0.3], absolute_error_margin=1e-12)


def test_to_average_rejects_tax_at_a_threshold_of_0():
# Tax at 0 is 0.1 * 50 = 5, but an average rate scale taxes 0 at 0. This
# used to raise ZeroDivisionError.
tax_scale = taxscales.MarginalRateTaxScale()
tax_scale.add_bracket(-50, 0.1)
tax_scale.add_bracket(0, 0.2)
tax_scale.add_bracket(100, 0.3)

with pytest.raises(ValueError, match="threshold of 0 at which the tax is 5"):
tax_scale.to_average()


def test_rate_from_bracket_indice():
tax_base = numpy.array([0, 1_000, 1_500, 50_000])
tax_scale = taxscales.MarginalRateTaxScale()
Expand Down
Loading
Loading