Skip to content

Commit 63780b6

Browse files
committed
BUG: prevent underflow/overflow when finding roots in IRR
NumPy's roots function uses the companion matrix to find polynomial roots. In the process it makes the polynomial monic by dividing by the leading coefficient, which can cause overflow/underflow. This can be avoided by working with a scaled version of the companion matrix instead. Since scaling the matrix simply scales the eigenvalues (i.e. the roots of the polynomial in this case), the original roots can easily be recovered. Closes gh-15.
1 parent a9b73c0 commit 63780b6

File tree

2 files changed

+118
-32
lines changed

2 files changed

+118
-32
lines changed

numpy_financial/_financial.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,35 @@ def rate(nper, pmt, pv, fv, when='end', guess=None, tol=None, maxiter=100):
666666
return rn
667667

668668

669+
def _roots(p):
670+
"""Modified version of NumPy's roots function.
671+
672+
NumPy's roots uses the companion matrix method, which divides by
673+
p[0]. This can causes overflows/underflows. Instead form a
674+
modified companion matrix that is scaled by 2^c * p[0], where the
675+
exponent c is chosen to balance the magnitudes of the
676+
coefficients. Since scaling the matrix just scales the
677+
eigenvalues, we can remove the scaling at the end.
678+
679+
Scaling by a power of 2 is chosen to avoid rounding errors.
680+
681+
"""
682+
_, e = np.frexp(p)
683+
# Balance the most extreme exponents e_max and e_min by solving
684+
# the equation
685+
#
686+
# |c + e_max| = |c + e_min|.
687+
#
688+
# Round the exponent to an integer to avoid rounding errors.
689+
c = int(-0.5 * (np.max(e) + np.min(e)))
690+
p = np.ldexp(p, c)
691+
692+
A = np.diag(np.full(p.size - 2, p[0]), k=-1)
693+
A[0,:] = -p[1:]
694+
eigenvalues = np.linalg.eigvals(A)
695+
return eigenvalues / p[0]
696+
697+
669698
def irr(values):
670699
"""
671700
Return the Internal Rate of Return (IRR).
@@ -729,12 +758,17 @@ def irr(values):
729758
0.0886
730759
731760
"""
732-
# `np.roots` call is why this function does not support Decimal type.
733-
#
734-
# Ultimately Decimal support needs to be added to np.roots, which has
735-
# greater implications on the entire linear algebra module and how it does
736-
# eigenvalue computations.
737-
res = np.roots(values[::-1])
761+
values = np.atleast_1d(values)
762+
if values.ndim != 1:
763+
raise ValueError("Cashflows must be a rank-1 array")
764+
765+
# Strip leading and trailing zeros. Since we only care about
766+
# positive roots we can neglect roots at zero.
767+
non_zero = np.nonzero(np.ravel(values))[0]
768+
values = values[int(non_zero[0]):int(non_zero[-1])+1]
769+
770+
res = _roots(values[::-1])
771+
738772
mask = (res.imag == 0) & (res.real > 0)
739773
if not mask.any():
740774
return np.nan

numpy_financial/tests/test_financial.py

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,6 @@
1313

1414

1515
class TestFinancial(object):
16-
def test_npv_irr_congruence(self):
17-
# IRR is defined as the rate required for the present value of a
18-
# a series of cashflows to be zero i.e. NPV(IRR(x), x) = 0
19-
cashflows = numpy.array([-40000, 5000, 8000, 12000, 30000])
20-
assert_allclose(npf.npv(npf.irr(cashflows), cashflows), 0,
21-
atol=1e-10, rtol=0)
22-
2316
def test_rate(self):
2417
assert_almost_equal(npf.rate(10, 0, -3500, 10000), 0.1107, 4)
2518

@@ -28,25 +21,6 @@ def test_rate_decimal(self):
2821
Decimal('10000'))
2922
assert_equal(Decimal('0.1106908537142689284704528100'), rate)
3023

31-
def test_irr(self):
32-
v = [-150000, 15000, 25000, 35000, 45000, 60000]
33-
assert_almost_equal(npf.irr(v), 0.0524, 2)
34-
v = [-100, 0, 0, 74]
35-
assert_almost_equal(npf.irr(v), -0.0955, 2)
36-
v = [-100, 39, 59, 55, 20]
37-
assert_almost_equal(npf.irr(v), 0.28095, 2)
38-
v = [-100, 100, 0, -7]
39-
assert_almost_equal(npf.irr(v), -0.0833, 2)
40-
v = [-100, 100, 0, 7]
41-
assert_almost_equal(npf.irr(v), 0.06206, 2)
42-
v = [-5, 10.5, 1, -8, 1]
43-
assert_almost_equal(npf.irr(v), 0.0886, 2)
44-
45-
# Test that if there is no solution then npf.irr returns nan
46-
# Fixes gh-6744
47-
v = [-1, -2, -3]
48-
assert_equal(npf.irr(v), numpy.nan)
49-
5024
def test_pv(self):
5125
assert_almost_equal(npf.pv(0.07, 20, 12000, 0), -127128.17, 2)
5226

@@ -514,3 +488,81 @@ def test_some_rates_zero(self):
514488
[-500, -610.51], # Computed using Google Sheet's FV
515489
rtol=1e-10,
516490
)
491+
492+
493+
class TestIrr:
494+
def test_npv_irr_congruence(self):
495+
# IRR is defined as the rate required for the present value of
496+
# a a series of cashflows to be zero, so we should have
497+
#
498+
# NPV(IRR(x), x) = 0.
499+
cashflows = numpy.array([-40000, 5000, 8000, 12000, 30000])
500+
assert_allclose(
501+
npf.npv(npf.irr(cashflows), cashflows),
502+
0,
503+
atol=1e-10,
504+
rtol=0,
505+
)
506+
507+
@pytest.mark.parametrize('v, desired', [
508+
([-150000, 15000, 25000, 35000, 45000, 60000], 0.0524),
509+
([-100, 0, 0, 74], -0.0955),
510+
([-100, 39, 59, 55, 20], 0.28095),
511+
([-100, 100, 0, -7], -0.0833),
512+
([-100, 100, 0, 7], 0.06206),
513+
([-5, 10.5, 1, -8, 1], 0.0886),
514+
])
515+
def test_basic_values(self, v, desired):
516+
assert_almost_equal(npf.irr(v), desired, decimal=2)
517+
518+
def test_trailing_zeros(self):
519+
assert_almost_equal(
520+
npf.irr([-5, 10.5, 1, -8, 1, 0, 0, 0]),
521+
0.0886,
522+
decimal=2,
523+
)
524+
525+
def test_numpy_gh_6744(self):
526+
# Test that if there is no solution then npf.irr returns nan.
527+
v = [-1, -2, -3]
528+
assert numpy.isnan(npf.irr(v))
529+
530+
def test_gh_15(self):
531+
v = [
532+
-3000.0,
533+
2.3926932267015667e-07,
534+
4.1672087103345505e-16,
535+
5.3965110036378706e-25,
536+
5.1962551071806174e-34,
537+
3.7202955645436402e-43,
538+
1.9804961711632469e-52,
539+
7.8393517651814181e-62,
540+
2.3072565113911438e-71,
541+
5.0491839233308912e-81,
542+
8.2159177668499263e-91,
543+
9.9403244366963527e-101,
544+
8.942410813633967e-111,
545+
5.9816122646481191e-121,
546+
2.9750309031844241e-131,
547+
1.1002067043497954e-141,
548+
3.0252876563518021e-152,
549+
6.1854121948207909e-163,
550+
9.4032980015353301e-174,
551+
1.0629218520017728e-184,
552+
8.9337141847171845e-196,
553+
5.5830607698467935e-207,
554+
2.5943122036622652e-218,
555+
8.9635842466507006e-230,
556+
2.3027710094332358e-241,
557+
4.3987510596745562e-253,
558+
6.2476630372575209e-265,
559+
6.598046841695288e-277,
560+
5.1811095266842017e-289,
561+
3.0250999925830644e-301,
562+
1.3133070599585015e-313,
563+
]
564+
result = npf.irr(v)
565+
assert numpy.isfinite(result)
566+
# Very rough approximation taken from the issue.
567+
desired = -0.9999999990596069
568+
assert_allclose(result, desired, rtol=1e-9)

0 commit comments

Comments
 (0)