Skip to content
Open
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
38 changes: 23 additions & 15 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
all:
@echo "dev_install - Install development dependencies"
@echo "test - Run tests"
@echo "typecheck - Run static type checking only python>=3.10"
@echo "lint - Run static analysis for common problems"
@echo "autoformat - Format all code to a consistent style"
@echo "coverage - Run tests and check the test coverage"
@echo "benchmark - Run a small suite to check performance"
@echo "ci_install - Install dependencies needed for CI"
@echo "fixed_install - Install as a non-editible package like production consumers"
@echo "clean - Delete generated files"
@echo "dist - Build distribution artifacts"
@echo "release - Build distribution and release to PyPI."
@echo "dev_install - Install development dependencies"
@echo "test - Run tests"
@echo "typecheck - Run static type checking only python>=3.10"
@echo "lint - Run static analysis for common problems"
@echo "autoformat - Format all code to a consistent style"
@echo "coverage - Run tests and check the test coverage"
@echo "benchmark - Run a small suite to check performance"
@echo "test_with_floats - Run tests: including the ones with floating point inputs"
@echo "coverage_with_floats - Check test coverage: including floating point input examples"
@echo "ci_install - Install dependencies needed for CI"
@echo "fixed_install - Install as a non-editible package like production consumers"
@echo "clean - Delete generated files"
@echo "dist - Build distribution artifacts"
@echo "release - Build distribution and release to PyPI."

test:
python -m pytest
python -m pytest -m 'not benchmark' -m 'not floats'

test_with_floats:
python -m pytest -m 'not benchmark'

benchmark:
python -m pytest tests/performance*
python -m pytest -m benchmark tests/performance*

coverage:
python -m pytest --cov=tuplesumfilter -m 'not benchmark' -m 'not floats' tests --cov-fail-under 90

coverage_with_floats:
python -m pytest --cov=tuplesumfilter tests --cov-fail-under 90

typecheck:
Expand Down Expand Up @@ -54,4 +62,4 @@ dist: clean
release: dist
twine upload dist/*.*

.PHONY: all autoformat benchmark ci_install clean coverage dev_install dist fixed_install test typecheck
.PHONY: all autoformat benchmark ci_install clean coverage coverage_with_floats dev_install dist fixed_install test test_with_floats typecheck
121 changes: 120 additions & 1 deletion NotesLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,130 @@ eugh that it pretty bad ~0.4 for the triplets version
$ make benchmark
tests/performance_check.py .. [100%]


------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------
Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_input1_pairs 5.4665 (1.0) 6.2297 (1.0) 5.6687 (1.0) 0.1018 (1.0) 5.6575 (1.0) 0.1289 (1.0) 47;3 176.4077 (1.0) 172 1
test_input1_triplets 384.6154 (70.36) 386.5000 (62.04) 385.4776 (68.00) 0.8287 (8.14) 385.4333 (68.13) 1.5047 (11.67) 2;0 2.5942 (0.01) 5 1
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
```

## if you want to go faster

We're going to have to break apart the itertools into the underlying nested loops
before we can mess with the aglo.

but 1st lets split out the float-y bits of the tests, because i think we might want to
make it easier to stop supporting floats and lose those tests.

wow, even just replacing the `math.isclose` in favor of simple `==`
gets us a ~30% speed up on the (`int` only) benchmarks.

```sh
$ make benchmark
tests/performance_check.py .. [100%]

------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------
Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_input1_pairs 2.8727 (1.0) 4.2386 (1.0) 3.1265 (1.0) 0.1638 (1.0) 3.1067 (1.0) 0.1888 (1.0) 78;9 319.8414 (1.0) 326 1
test_input1_triplets 211.6325 (73.67) 213.3950 (50.35) 212.4042 (67.94) 0.6555 (4.00) 212.2717 (68.33) 0.8081 (4.28) 2;0 4.7080 (0.01) 5 1
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
=========================================================== 2 passed in 3.59s ============================================================
```

but this does make some of our floaty tests fail.

## simple nested loops for pairwise

looks like we're seeing an perf improvement over itertools just by breaking out into loops for the pairs
roughly the same speedup we got by dropping float support, but we're back using `math.isclose`

```sh
tests/performance_check.py .. [100%]

------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------
Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_input1_pairs 3.0859 (1.0) 6.3263 (1.0) 3.4614 (1.0) 0.2696 (1.0) 3.4232 (1.0) 0.2591 (1.0) 44;6 288.8998 (1.0) 298 1
test_input1_triplets 392.5019 (127.19) 394.4756 (62.36) 393.5971 (113.71) 0.8023 (2.98) 393.7625 (115.03) 1.2974 (5.01) 2;0 2.5407 (0.01) 5 1
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
=================================================================================== 2 passed in 4.87s ===================================================================================
```

and now we've moved the triplets to nested loops too

```sh
tests/performance_check.py .. [100%]


------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------
Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_input1_pairs 4.6867 (1.0) 9.1275 (1.0) 5.5921 (1.0) 0.8257 (1.0) 5.3198 (1.0) 0.8444 (1.0) 23;12 178.8246 (1.0) 193 1
test_input1_triplets 371.6804 (79.31) 376.8461 (41.29) 374.2332 (66.92) 2.3729 (2.87) 373.5280 (70.21) 4.3788 (5.19) 3;0 2.6721 (0.01) 5 1
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================================================================= 2 passed in 4.81s ========================================================================================================
```

### trading space for speed

its relatively common to speed algos up by trading off some memory
for reduced CPU tome complexity.

I reckon we can do that here using something to keep track of values we've already seen
it'll need to be a fast, $O{1}$, lookup thing: so a `set` or `dict`.

However, we'll need to ditch `float` support because they don't hash.

Okaaaay

That gives us a big speedup in the pair version: note that we are now measuring _micro_ not _milli_ seconds

```sh
tests/performance_check.py .. [100%]


-------------------------------------------------------------------------------------------------- benchmark: 2 tests --------------------------------------------------------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_input1_pairs 22.1710 (1.0) 152.1860 (1.0) 23.5753 (1.0) 5.5831 (1.0) 22.8580 (1.0) 0.4057 (1.0) 114;461 42,417.2354 (1.0) 7671 1
test_input1_triplets 176,173.4430 (>1000.0) 188,509.7390 (>1000.0) 184,815.9523 (>1000.0) 4,741.3658 (849.24) 186,896.7185 (>1000.0) 5,253.1090 (>1000.0) 1;0 5.4108 (0.00) 6 1
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
```

that is > a 200x (nearly 250x) speedup for the pairs.


I'll split the float test out further using `pytest` markers and fix up the makefile.

Now lets do stuff for the triplet version


```sh
$ make benchmark
tests/performance_check.py .. [100%]

-------------------------------------------------------------------------------------------- benchmark: 2 tests --------------------------------------------------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_input1_pairs 22.1660 (1.0) 166.3790 (1.0) 23.6089 (1.0) 5.0170 (1.0) 23.0000 (1.0) 0.5123 (1.0) 87;521 42,356.8183 (1.0) 7677 1
test_input1_triplets 1,994.8000 (89.99) 3,561.1120 (21.40) 2,152.1272 (91.16) 204.1428 (40.69) 2,033.1040 (88.40) 299.1878 (584.07) 41;4 464.6565 (0.01) 341 1
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
```

again note the unit changer for the triplets,
even though we're still (i think $O{n^2}$, down from $O{n^3}$)
we are seeing an approx 175x speedup in the triplets
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ source = ["src", ".tox/*/site-packages"]

[tool.coverage.report]
show_missing = true

[tool.pytest.ini_options]
markers = [
"benchmark: marks tests as for performance benchmarking: they are slow (deselect with '-m \"not benchmark\"')",
"floats: marks tests as for testing floating point support: some algorithms don't work for floats (deselect with '-m \"not float\"')",
]
58 changes: 20 additions & 38 deletions src/tuplesumfilter/sum_to_target.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import itertools
import math

import tuplesumfilter.types as t
from tuplesumfilter.app_logging import get_logger

Expand All @@ -14,12 +11,16 @@ def pairs_that_sum_to(numbers: t.Sequence[t.Num], sum_target: t.Num) -> t.PairsO
sum_kind="pairs",
sum_target=sum_target,
len_input=len(numbers),
algo="itertools",
algo="w-storage-int-only",
)
pairs = list(ntuples_that_sum_to(numbers, sum_target, 2))
logger.debug(f"found {len(pairs)} pair sequences that sum to {sum_target}")
# i promise mypy that we _are_ narrowing the types here
return t.cast(t.PairsOfNums, pairs)
already_seen = set()
summed = []
for comparitor in numbers:
if (sum_target - comparitor) in already_seen:
summed.append((sum_target - comparitor, comparitor))
already_seen.add(comparitor)
logger.debug(f"found {len(summed)} pair sequences that sum to {sum_target}")
return summed


def triplets_that_sum_to(
Expand All @@ -29,34 +30,15 @@ def triplets_that_sum_to(
sum_kind="triplets",
sum_target=sum_target,
len_input=len(numbers),
algo="itertools",
)
triplets = list(ntuples_that_sum_to(numbers, sum_target, 3))
logger.debug(f"found {len(triplets)} triplet sequences that sum to {sum_target}")
# i promise mypy, again, that we _are_ narrowing the types here
return t.cast(t.TripletsOfNums, triplets)


def ntuples_that_sum_to(
numbers: t.Sequence[t.Num], sum_target: t.Num, dimensions: t.Int
) -> t.Generator[t.NTupelOfNums, None, None]:
"""
Filters a the input `numbers` by whether their n-tuple combinations
sum to match the `sum_target`, the n in n-tuple is controlled by `dimensions`.
e.g. pairs of numbers for dimenions==2

Returns
-------
A generator from which we can pull the matching combinations.

>>> input = [1, 2, 3, 4]
>>> list(ntuples_that_sum_to(input, 7, 3))
>>> [(1, 2, 4)]
"""
n_tuples = itertools.combinations(numbers, dimensions)
filtered = (
n_tuple
for n_tuple in n_tuples
if math.isclose(sum(n_tuple), sum_target, rel_tol=FLOAT_COMPARISON_REL_TOL)
algo="nested",
)
return filtered
summed = []
for ileft, left in enumerate(numbers):
already_seen = set()
still_need = sum_target - left
for right in numbers[ileft + 1 :]:
if still_need - right in already_seen:
summed.append((left, (sum_target - left - right), right))
already_seen.add(right)
logger.debug(f"found {len(summed)} triplet sequences that sum to {sum_target}")
return summed
1 change: 1 addition & 0 deletions src/tuplesumfilter/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def read_file(fname: t.Path)

cast = typ.cast

Boolean = bool
Generator = typ.Generator
Int = int
List = typ.List
Expand Down
3 changes: 3 additions & 0 deletions tests/performance_check.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from pathlib import Path
import pytest

from tuplesumfilter import numbers_in_file, pairs_that_sum_to, triplets_that_sum_to

pytestmark = pytest.mark.benchmark

INPUT1_FILE = Path("./tests/__test_data__") / "input1.txt"


Expand Down
22 changes: 22 additions & 0 deletions tests/test_floats_in_pairs_and_triplets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from math import e, pi, sqrt

import pytest
from tuplesumfilter import pairs_that_sum_to, triplets_that_sum_to

pytestmark = pytest.mark.floats


def test_triplets_works_approx_with_floats():
root2 = sqrt(2)
nums = [pi, root2, 17.45, 1e-10, e]
target = pi + e + root2
expected = [(pytest.approx(pi), pytest.approx(root2), pytest.approx(e))]
assert triplets_that_sum_to(nums, target) == expected


def test_pairs_works_approx_with_floats():
root2 = sqrt(2)
nums = [pi, root2, 17.45, 1e-10, e]
target = pi + root2
expected = [(pytest.approx(pi), pytest.approx(root2))]
assert pairs_that_sum_to(nums, target) == expected
13 changes: 5 additions & 8 deletions tests/test_pairs_that_sum.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from math import e, pi, sqrt
import pytest
from tuplesumfilter import pairs_that_sum_to

Expand Down Expand Up @@ -28,13 +27,11 @@ def test_pairs_with_finite_input_but_no_match(worked_example_nums):
assert pairs_that_sum_to(worked_example_nums, 0) == []


def test_pairs_works_approx_with_floats():
nums = [pi, sqrt(2), 17.45, 1e-10, e]
target = pi + e
expected = [(pytest.approx(pi), pytest.approx(e))]
assert pairs_that_sum_to(nums, target) == expected


def test_pairs_when_multiple_matches():
example_input = [1, 979, 6, 299, 2, 1456, 5]
assert pairs_that_sum_to(example_input, 7) == [(1, 6), (2, 5)]


def test_regression_pairs_excludes_repeats():
got = pairs_that_sum_to([1, 2], 4)
assert got == []
13 changes: 5 additions & 8 deletions tests/test_triplets_that_sum.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from math import e, pi, sqrt
import pytest
from tuplesumfilter import triplets_that_sum_to

Expand Down Expand Up @@ -32,13 +31,11 @@ def test_triplets_with_finite_input_but_no_match(worked_example_nums):
assert triplets_that_sum_to(worked_example_nums, 0) == []


def test_triplets_works_approx_with_floats():
nums = [pi, sqrt(2), 17.45, 1e-10, e]
target = pi + e + sqrt(2)
expected = [(pytest.approx(pi), pytest.approx(sqrt(2)), pytest.approx(e))]
assert triplets_that_sum_to(nums, target) == expected


def test_triplets_when_multiple_matches():
input = [10, 979, 5, 299, 2, 1456, 6, 8, 3]
assert triplets_that_sum_to(input, 17) == [(10, 5, 2), (6, 8, 3)]


def test_regression_triplets_excludes_repeats():
got = triplets_that_sum_to([1, 2, 7, 8, 9], 4)
assert got == []