From 0b83101aef7951a41693e76f797436cdfa650ca6 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 23 Mar 2024 12:59:29 +1100 Subject: [PATCH 1/3] ENH: nper: Perform broadcast rework This commit rewrites the ``nper`` function to mimic broadcasting. As this requires writing manual for loops numba is used to speed up the calculations. Further a fuzz test is added. --- numpy_financial/_financial.py | 85 ++++++++++++++++++++++++++--------- tests/test_financial.py | 15 +++++++ 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 534230f..cd6bf4e 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -261,6 +261,42 @@ def pmt(rate, nper, pv, fv=0, when='end'): return -(fv + pv * temp) / fact +@nb.njit +def _nper_inner_loop(rate, pmt, pv, fv, when): + if rate == 0.0: + if pmt == 0.0: + # If no repayments are made the payments will go on forever + return np.inf + else: + return -(fv + pv) / pmt + else: + # We know that rate != 0.0, so we are sure this won't cause a ZeroDivisionError + z = pmt * (1.0 + rate * when) / rate + try: + numer = np.log((-fv + z) / (pv + z)) + denom = np.log(1.0 + rate) + return numer / denom + except Exception: # As of March 24, numba only supports generic exceptions + # TODO: There are several ``ZeroDivisionError``s here. + # We need to figure out exactly what's causing these + # and return financially sensible values. + return np.nan + + +@nb.njit +def _nper_native(rates, pmts, pvs, fvs, whens, out): + for rate in range(rates.shape[0]): + for pmt in range(pmts.shape[0]): + for pv in range(pvs.shape[0]): + for fv in range(fvs.shape[0]): + for when in range(whens.shape[0]): + out[rate, pmt, pv, fv, when] = _nper_inner_loop( + rates[rate], pmts[pmt], pvs[pv], fvs[fv], whens[when] + ) + + + + def nper(rate, pmt, pv, fv=0, when='end'): """Compute the number of periodic payments. @@ -297,7 +333,7 @@ def nper(rate, pmt, pv, fv=0, when='end'): If you only had $150/month to pay towards the loan, how long would it take to pay-off a loan of $8,000 at 7% annual interest? - >>> print(np.round(npf.nper(0.07/12, -150, 8000), 5)) + >>> round(npf.nper(0.07/12, -150, 8000), 5) 64.07335 So, over 64 months would be required to pay off the loan. @@ -305,35 +341,40 @@ def nper(rate, pmt, pv, fv=0, when='end'): The same analysis could be done with several different interest rates and/or payments and/or total amounts to produce an entire table. - >>> npf.nper(*(np.ogrid[0.07/12: 0.08/12: 0.01/12, - ... -150 : -99 : 50 , - ... 8000 : 9001 : 1000])) - array([[[ 64.07334877, 74.06368256], - [108.07548412, 127.99022654]], + >>> rates = [0.05, 0.06, 0.07] + >>> payments = [100, 200, 300] + >>> amounts = [7_000, 8_000, 9_000] + >>> npf.nper(rates, payments, amounts).round(3) + array([[[-30.827, -32.987, -34.94 ], + [-20.734, -22.517, -24.158], + [-15.847, -17.366, -18.78 ]], + + [[-28.294, -30.168, -31.857], + [-19.417, -21.002, -22.453], + [-15.025, -16.398, -17.67 ]], - [[ 66.12443902, 76.87897353], - [114.70165583, 137.90124779]]]) + [[-26.234, -27.891, -29.381], + [-18.303, -19.731, -21.034], + [-14.311, -15.566, -16.722]]]) + """ when = _convert_when(when) - rate, pmt, pv, fv, when = np.broadcast_arrays(rate, pmt, pv, fv, when) - nper_array = np.empty_like(rate, dtype=np.float64) - zero = rate == 0 - nonzero = ~zero + rate_inner = np.atleast_1d(rate) + pmt_inner = np.atleast_1d(pmt) + pv_inner = np.atleast_1d(pv) + fv_inner = np.atleast_1d(fv) + when_inner = np.atleast_1d(when) - with np.errstate(divide='ignore'): - # Infinite numbers of payments are okay, so ignore the - # potential divide by zero. - nper_array[zero] = -(fv[zero] + pv[zero]) / pmt[zero] + # TODO: Validate ``*_inner`` array shapes - nonzero_rate = rate[nonzero] - z = pmt[nonzero] * (1 + nonzero_rate * when[nonzero]) / nonzero_rate - nper_array[nonzero] = ( - np.log((-fv[nonzero] + z) / (pv[nonzero] + z)) - / np.log(1 + nonzero_rate) + out_shape = _get_output_array_shape( + rate_inner, pmt_inner, pv_inner, fv_inner, when_inner ) + out = np.empty(out_shape) + _nper_native(rate_inner, pmt_inner, pv_inner, fv_inner, when_inner, out) - return nper_array + return _ufunc_like(out) def _value_like(arr, value): diff --git a/tests/test_financial.py b/tests/test_financial.py index 2d64f0b..32984a2 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -50,6 +50,10 @@ def uint_dtype(): shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5), ) +when_strategy = st.sampled_from( + ['end', 'begin', 'e', 'b', 0, 1, 'beginning', 'start', 'finish'] +) + def assert_decimal_close(actual, expected, tol=Decimal("1e-7")): # Check if both actual and expected are iterable (like arrays) @@ -426,6 +430,17 @@ def test_broadcast(self): npf.nper(0.075, -2000, 0, 100000.0, [0, 1]), [21.5449442, 20.76156441], 4 ) + @given( + rates=short_scalar_array, + payments=short_scalar_array, + present_values=short_scalar_array, + future_values=short_scalar_array, + whens=when_strategy, + ) + @settings(deadline=None) # ignore jit compilation of a function + def test_fuzz(self, rates, payments, present_values, future_values, whens): + npf.nper(rates, payments, present_values, future_values, whens) + class TestPpmt: def test_float(self): From 04107813b8e92c737e06d60046a13f76e22ef6f4 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 23 Mar 2024 13:23:17 +1100 Subject: [PATCH 2/3] ENH: nper: Raise an error if arrays are of invalid shape --- numpy_financial/_financial.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index cd6bf4e..08e2ad6 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -295,8 +295,6 @@ def _nper_native(rates, pmts, pvs, fvs, whens, out): ) - - def nper(rate, pmt, pv, fv=0, when='end'): """Compute the number of periodic payments. @@ -366,7 +364,27 @@ def nper(rate, pmt, pv, fv=0, when='end'): fv_inner = np.atleast_1d(fv) when_inner = np.atleast_1d(when) - # TODO: Validate ``*_inner`` array shapes + # TODO: I don't like repeating myself this often, refactor into a function + # that checks all of the arrays at once. + if rate_inner.ndim != 1: + msg = "invalid shape for rates. Rate must be either a scalar or 1d array" + raise ValueError(msg) + + if pmt_inner.ndim != 1: + msg = "invalid shape for pmt. Payments must be either a scalar or 1d array" + raise ValueError(msg) + + if pv_inner.ndim != 1: + msg = "invalid shape for pv. Present value must be either a scalar or 1d array" + raise ValueError(msg) + + if fv_inner.ndim != 1: + msg = "invalid shape for fv. Future value must be either a scalar or 1d array" + raise ValueError(msg) + + if when_inner.ndim != 1: + msg = "invalid shape for when. When must be either a scalar or 1d array" + raise ValueError(msg) out_shape = _get_output_array_shape( rate_inner, pmt_inner, pv_inner, fv_inner, when_inner From af7bf812ef57f81b9687f4341b5f5f6015d3b42a Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 23 Mar 2024 17:19:57 +1100 Subject: [PATCH 3/3] TST: Move Hypothesis strategies into own file --- tests/strategies.py | 33 +++++++++++++++++++++++++++++++++ tests/test_financial.py | 40 +--------------------------------------- 2 files changed, 34 insertions(+), 39 deletions(-) create mode 100644 tests/strategies.py diff --git a/tests/strategies.py b/tests/strategies.py new file mode 100644 index 0000000..cf6fc93 --- /dev/null +++ b/tests/strategies.py @@ -0,0 +1,33 @@ +from hypothesis import strategies as st +from hypothesis.extra import numpy as npst + + +def float_dtype(): + return npst.floating_dtypes(sizes=[32, 64], endianness="<") + + +def int_dtype(): + return npst.integer_dtypes(sizes=[32, 64], endianness="<") + + +def uint_dtype(): + return npst.unsigned_integer_dtypes(sizes=[32, 64], endianness="<") + + +real_scalar_dtypes = st.one_of(float_dtype(), int_dtype(), uint_dtype()) +cashflow_array_strategy = npst.arrays( + dtype=real_scalar_dtypes, + shape=npst.array_shapes(min_dims=1, max_dims=2, min_side=0, max_side=25), +) +cashflow_list_strategy = cashflow_array_strategy.map(lambda x: x.tolist()) +cashflow_array_like_strategy = st.one_of( + cashflow_array_strategy, + cashflow_list_strategy, +) +short_scalar_array = npst.arrays( + dtype=real_scalar_dtypes, + shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5), +) +when_strategy = st.sampled_from( + ['end', 'begin', 'e', 'b', 0, 1, 'beginning', 'start', 'finish'] +) diff --git a/tests/test_financial.py b/tests/test_financial.py index 32984a2..59bbe06 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -1,9 +1,6 @@ import math from decimal import Decimal -import hypothesis.extra.numpy as npst -import hypothesis.strategies as st - # Don't use 'import numpy as np', to avoid accidentally testing # the versions in numpy instead of numpy_financial. import numpy @@ -17,42 +14,7 @@ ) import numpy_financial as npf - - -def float_dtype(): - return npst.floating_dtypes(sizes=[32, 64], endianness="<") - - -def int_dtype(): - return npst.integer_dtypes(sizes=[32, 64], endianness="<") - - -def uint_dtype(): - return npst.unsigned_integer_dtypes(sizes=[32, 64], endianness="<") - - -real_scalar_dtypes = st.one_of(float_dtype(), int_dtype(), uint_dtype()) - - -cashflow_array_strategy = npst.arrays( - dtype=real_scalar_dtypes, - shape=npst.array_shapes(min_dims=1, max_dims=2, min_side=0, max_side=25), -) -cashflow_list_strategy = cashflow_array_strategy.map(lambda x: x.tolist()) - -cashflow_array_like_strategy = st.one_of( - cashflow_array_strategy, - cashflow_list_strategy, -) - -short_scalar_array = npst.arrays( - dtype=real_scalar_dtypes, - shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5), -) - -when_strategy = st.sampled_from( - ['end', 'begin', 'e', 'b', 0, 1, 'beginning', 'start', 'finish'] -) +from tests.strategies import cashflow_array_strategy, short_scalar_array, when_strategy def assert_decimal_close(actual, expected, tol=Decimal("1e-7")):