From 6fce2546bc9a2759e5685c23b07fac8ccaff651a Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Mon, 27 Nov 2023 20:01:26 +0800 Subject: [PATCH 1/5] ENH: First attempt at Numba rework --- numpy_financial/_financial.py | 27 ++++++++++++++++++--------- pyproject.toml | 1 + tests/test_financial.py | 31 ++++++++++++++++++++++++------- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 8bd280e..291dab9 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -13,6 +13,8 @@ from decimal import Decimal +import numba as nb +from numba import prange import numpy as np __all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate', @@ -825,6 +827,14 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan +@nb.guvectorize("(),(n)->()", nopython=True) +def _npv_internal(r, values, res): + acc = 0.0 + for t in range(values.shape[0]): + acc += values[t] / ((1.0 + r) ** t) + res[0] = acc + + def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. @@ -892,15 +902,14 @@ def npv(rate, values): 3065.22267 """ - values = np.atleast_2d(values) - timestep_array = np.arange(0, values.shape[1]) - npv = (values / (1 + rate) ** timestep_array).sum(axis=1) - try: - # If size of array is one, return scalar - return npv.item() - except ValueError: - # Otherwise, return entire array - return npv + + r = np.atleast_1d(rate) + v = np.atleast_2d(values) + out = np.empty(shape=(r.shape[0], v.shape[0])) + r = r[:, np.newaxis] + v = v[np.newaxis, :, :] + _npv_internal(r, v, out) + return out def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): diff --git a/pyproject.toml b/pyproject.toml index 62b758e..9f3852f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ packages = [{include = "numpy_financial"}] [tool.poetry.dependencies] python = "^3.9" numpy = "^1.23" +numba = "^0.58.1" [tool.poetry.group.test.dependencies] diff --git a/tests/test_financial.py b/tests/test_financial.py index ad01952..f946f55 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -4,6 +4,7 @@ # Don't use 'import numpy as np', to avoid accidentally testing # the versions in numpy instead of numpy_financial. import numpy +import numpy as np import pytest from numpy.testing import ( assert_, @@ -164,7 +165,7 @@ def test_rate_maximum_iterations_exception_array(self): class TestNpv: def test_npv(self): assert_almost_equal( - npf.npv(0.05, [-15000, 1500, 2500, 3500, 4500, 6000]), + npf.npv(0.05, [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0]), 122.89, 2) def test_npv_decimal(self): @@ -172,19 +173,35 @@ def test_npv_decimal(self): npf.npv(Decimal('0.05'), [-15000, 1500, 2500, 3500, 4500, 6000]), Decimal('122.894854950942692161628715')) - def test_npv_broadcast(self): - cashflows = [ + def test_npv_broadcast_cashflows(self): + cashflows = np.array([ [-15000, 1500, 2500, 3500, 4500, 6000], [-15000, 1500, 2500, 3500, 4500, 6000], [-15000, 1500, 2500, 3500, 4500, 6000], [-15000, 1500, 2500, 3500, 4500, 6000], - ] - expected_npvs = [ - 122.8948549, 122.8948549, 122.8948549, 122.8948549 - ] + ]) + expected_npvs = numpy.array([ + [122.8948549, 122.8948549, 122.8948549, 122.8948549] + ]) actual_npvs = npf.npv(0.05, cashflows) assert_allclose(actual_npvs, expected_npvs) + def test_npv_broadcast_equals_for_loop(self): + cashflows = np.array([ + [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], + [-25000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], + [-35000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], + [-45000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], + ]) + rates = np.array([-0.05, 0.00, 0.05, 0.10, 0.15]) + + res = np.empty((len(rates), len(cashflows))) + for i, r in enumerate(rates): + for j, cf in enumerate(cashflows): + res[i, j] = npf.npv(r, cf).item() + + assert_allclose(npf.npv(rates, cashflows), res) + class TestPmt: def test_pmt_simple(self): From 1efd0164a069b2b3f6ca8308d5786673784737ec Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Wed, 29 Nov 2023 15:54:48 +0800 Subject: [PATCH 2/5] ENH: Convert ``guvectorize`` to ``njit`` This seems to be more straight forward to maintain. --- numpy_financial/_financial.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 291dab9..5ba010d 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -827,14 +827,23 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan -@nb.guvectorize("(),(n)->()", nopython=True) -def _npv_internal(r, values, res): - acc = 0.0 - for t in range(values.shape[0]): - acc += values[t] / ((1.0 + r) ** t) - res[0] = acc +@nb.njit(parallel=True) +def _npv(rates, cashflows, result): + r"""Native version of the ``npv`` function. + + Warnings + -------- + For internal use only, note that this function performs no error checking. + """ + for i in range(rates.shape[0]): + for j in range(cashflows.shape[0]): + acc = 0.0 + for t in range(cashflows.shape[1]): + acc += cashflows[j, t] / ((1.0 + rates[i]) ** t) + result[i, j] = acc +@nb.jit def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. @@ -906,9 +915,7 @@ def npv(rate, values): r = np.atleast_1d(rate) v = np.atleast_2d(values) out = np.empty(shape=(r.shape[0], v.shape[0])) - r = r[:, np.newaxis] - v = v[np.newaxis, :, :] - _npv_internal(r, v, out) + _npv(r, v, out) return out From c4b5be22a6fdb11d82810e72f05d10b379658e2e Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Wed, 29 Nov 2023 17:27:04 +0800 Subject: [PATCH 3/5] ENH: Add decimal.Decimal support to NPV --- numpy_financial/_financial.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 5ba010d..84394b1 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -835,15 +835,32 @@ def _npv(rates, cashflows, result): -------- For internal use only, note that this function performs no error checking. """ - for i in range(rates.shape[0]): - for j in range(cashflows.shape[0]): + + # TODO: Is using `prange` actually faster here? + for i in prange(rates.shape[0]): + for j in prange(cashflows.shape[0]): acc = 0.0 for t in range(cashflows.shape[1]): acc += cashflows[j, t] / ((1.0 + rates[i]) ** t) result[i, j] = acc -@nb.jit +@nb.jit(forceobj=True) # Need ``forceobj`` to support decimal.Decimal +def _npv_decimal(rates, cashflows, result): + r"""Version of the ``npv`` function supporting ``decimal.Decimal`` types + + Warnings + -------- + For internal use only, note that this function performs no error checking. + """ + for i in range(rates.shape[0]): + for j in range(cashflows.shape[0]): + acc = Decimal("0.0") + for t in range(cashflows.shape[1]): + acc += cashflows[j, t] / ((Decimal("1.0") + rates[i]) ** t) + result[i, j] = acc + + def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. @@ -914,8 +931,14 @@ def npv(rate, values): r = np.atleast_1d(rate) v = np.atleast_2d(values) - out = np.empty(shape=(r.shape[0], v.shape[0])) - _npv(r, v, out) + + if r.dtype == np.dtype("O") or v.dtype == np.dtype("O"): + out = np.empty(shape=(r.shape[0], v.shape[0]), dtype=Decimal) + _npv_decimal(r, v, out) + else: + out = np.empty(shape=(r.shape[0], v.shape[0])) + _npv(r, v, out) + return out From 0b7d5342fa47ead7bac8e64191909d08e902eb39 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Fri, 1 Dec 2023 07:14:05 +0800 Subject: [PATCH 4/5] TST/NPV: Modify tests to make sure they support Decimal types --- tests/test_financial.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/test_financial.py b/tests/test_financial.py index f946f55..fc5f092 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -186,21 +186,26 @@ def test_npv_broadcast_cashflows(self): actual_npvs = npf.npv(0.05, cashflows) assert_allclose(actual_npvs, expected_npvs) - def test_npv_broadcast_equals_for_loop(self): - cashflows = np.array([ - [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], - [-25000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], - [-35000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], - [-45000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], - ]) - rates = np.array([-0.05, 0.00, 0.05, 0.10, 0.15]) + @pytest.mark.parametrize("dtype", [Decimal, float]) + def test_npv_broadcast_equals_for_loop(self, dtype): + cashflows_str = [ + ["-15000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ["-25000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ["-35000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ["-45000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ] + rates_str = ["-0.05", "0.00", "0.05", "0.10", "0.15"] + + cashflows = np.array([[dtype(x) for x in cf] for cf in cashflows_str]) + rates = np.array([dtype(x) for x in rates_str]) - res = np.empty((len(rates), len(cashflows))) + expected = np.empty((len(rates), len(cashflows)), dtype=dtype) for i, r in enumerate(rates): for j, cf in enumerate(cashflows): - res[i, j] = npf.npv(r, cf).item() + expected[i, j] = npf.npv(r, cf).item() - assert_allclose(npf.npv(rates, cashflows), res) + actual = npf.npv(rates, cashflows) + assert_equal(actual, expected) class TestPmt: From f712e0184890bcb5344b54cc890453c0380d786b Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Fri, 1 Dec 2023 07:22:11 +0800 Subject: [PATCH 5/5] BENCH/NPV: Modify the benchmarks to bench decimal types --- benchmarks/benchmarks.py | 41 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 1bb4c90..d26a9c5 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -1,39 +1,34 @@ +from decimal import Decimal + import numpy as np import numpy_financial as npf -class Npv1DCashflow: - - param_names = ["cashflow_length"] - params = [ - (1, 10, 100, 1000), - ] - - def __init__(self): - self.cashflows = None - - def setup(self, cashflow_length): - rng = np.random.default_rng(0) - self.cashflows = rng.standard_normal(cashflow_length) - - def time_1d_cashflow(self, cashflow_length): - npf.npv(0.08, self.cashflows) - - class Npv2DCashflows: - param_names = ["n_cashflows", "cashflow_lengths"] + param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"] params = [ (1, 10, 100, 1000), (1, 10, 100, 1000), + (1, 10, 100, 1000), ] def __init__(self): + self.rates_decimal = None + self.rates = None + self.cashflows_decimal = None self.cashflows = None - def setup(self, n_cashflows, cashflow_lengths): + def setup(self, n_cashflows, cashflow_lengths, rates_lengths): rng = np.random.default_rng(0) - self.cashflows = rng.standard_normal((n_cashflows, cashflow_lengths)) + cf_shape = (n_cashflows, cashflow_lengths) + self.cashflows = rng.standard_normal(cf_shape) + self.rates = rng.standard_normal(rates_lengths) + self.cashflows_decimal = rng.standard_normal(cf_shape, dtype=Decimal) + self.rates_decimal = rng.standard_normal(rates_lengths, dtype=Decimal) + + def time_2d_cashflow(self, n_cashflows, cashflow_lengths, rates_lengths): + npf.npv(self.rates, self.cashflows) - def time_2d_cashflow(self, n_cashflows, cashflow_lengths): - npf.npv(0.08, self.cashflows) + def time_2d_cashflow_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): + npf.npv(self.rates_decimal, self.cashflows_decimal)