diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 40655b5..f7cb214 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,4 +1,4 @@ ## Contributors - Ian McFarland [@imf](https://github.com/imf) - Dave Schinkel [@dschinkel](https://github.com/dschinkel) - +- Carl Jackson [@carl](https://github.com/carl) diff --git a/examples/team_recommender/tests/test_helpers.py b/examples/team_recommender/tests/test_helpers.py index c41e3e1..900bee3 100644 --- a/examples/team_recommender/tests/test_helpers.py +++ b/examples/team_recommender/tests/test_helpers.py @@ -50,7 +50,7 @@ def test_assert_success_rate_pass(row): [ "New Success rate 0.900 with 90% confidence exceeds expected: 0.7", "Broken Record:", - "Expecting: 0.744 <= 0.700 <= 1.056", + "Expecting: 0.744 <= 0.700 <= 1.000", "Got: expected=0.7 <= analysis.lower_interval=0.74", ], ), @@ -61,7 +61,7 @@ def test_assert_success_rate_pass(row): [ "New Success rate 0.999 with 90% confidence exceeds expected: 0.98", "Broken Record:", - "Expecting: 0.997 <= 0.980 <= 1.001", + "Expecting: 0.997 <= 0.980 <= 1.000", "Got: expected=0.98 <= analysis.lower_interval=0.997", ], ), @@ -92,8 +92,15 @@ def test_beyond_expected_success_rate(assert_success_rate, row): (0.8, 14, 100, None), (0.97, 1, 8, None), (0.97, 0, 1, "after measuring 2x 100 runs and getting 3 failures"), - (0.975, 0, 100, "97.5% success rate is within 100% success rate"), - (0.9737, 0, 100, "97.37% success rate is within 100% success rate"), + ( + 0.97, + 1, + 133, + "At 133 we can say that with 90% confidence 1 failure is within 97% success rate", + ), + (0.98, 0, 100, "97.5% success rate is within 100% success rate"), + (0.97999999999999999, 0, 100, "97.37% success rate is within 100% success rate"), + (0.5, 1, 2, None), ], ) def test_is_within_expected(success_rate, failure_count, sample_size, message): @@ -107,9 +114,15 @@ def test_is_within_expected(success_rate, failure_count, sample_size, message): "failure_count, sample_size, expected_rate, message", [ (3, 5, 0.8, "40% success rate is below expected 80% success rate"), - (1, 2, 0.97, "50% success rate is below expected 97% success rate"), (0, 100, 0.97, "100% success rate is not within 97% success rate"), + (1, 50000, 0.9997, "99.99% success rate is below expected 97% success rate"), (0, 100, 0.9736, "97.36% success rate is not within 100% success rate"), + ( + 1, + 134, + 0.97, + "At 134 we can say that with 90% confidence 1 failure is within 97% success rate", + ), ], ) def test_not_is_within_expected(failure_count, sample_size, expected_rate, message): @@ -145,7 +158,13 @@ def test_next_success_rate(): @pytest.mark.parametrize( "success_rate, largest_sample_size", - [(0.7, 12), (next_success_rate(12), 55), (next_success_rate(55), 248)], + [ + (0.7, 10), + (next_success_rate(10), 44), + (next_success_rate(45), 184), + (next_success_rate(185), 744), + (next_success_rate(745), 2984), + ], ) def test_largest_sample_size_for_given_success_rate(success_rate, largest_sample_size): assert is_within_expected(success_rate, 1, largest_sample_size), "should be within expected" @@ -154,6 +173,43 @@ def test_largest_sample_size_for_given_success_rate(success_rate, largest_sample ) +def test_next_sample_size(): + ## Next sample size should be larger than the current one by at least 4 times + assert next_sample_size(10) == 45, ( + "passing 10 out of 10 should require 45 successful runs to be statistically significant" + ) + assert next_sample_size(45) == 185, ( + "passing 45 out of 45 should require 185 successful runs to be statistically significant" + ) + assert next_sample_size(185) == 745 + assert next_sample_size(745) == 2985 + assert next_sample_size(29) == 121 + assert next_sample_size(29) == next_sample_size_via_loop(29), "calculated via loop should match" + + assert 28 / 29 == pytest.approx(0.96, rel=0.01) + before = analyse_measure_from_test_sample(28, 29) + assert before.proportion == pytest.approx(0.96, rel=0.01) + assert before.confidence_interval_prop == pytest.approx((0.91, 1.00), 0.01) + + analysis = analyse_measure_from_test_sample(120, 121) + assert analysis.proportion == pytest.approx(0.99, rel=0.01) + assert analysis.confidence_interval_prop == pytest.approx((0.98, 1.00), 0.01) + + +def next_sample_size(current): + ## How many successful runs are needed to be statistically significant improvement + # compared to the current sample size with 100% success rate + return 4 * current + 5 + + +def next_sample_size_via_loop(sample_size: int) -> int: + goal_success_rate = next_success_rate(sample_size) + for i in range(sample_size, 5 * sample_size): + if not is_within_expected(goal_success_rate, 1, i): + return i + return 0 + + def test_success_rate(): tiny_set_analysis = analyse_measure_from_test_sample(1, 2) assert tiny_set_analysis.proportion == 0.5 diff --git a/examples/team_recommender/tests/test_proportions_ztest.py b/examples/team_recommender/tests/test_proportions_ztest.py new file mode 100644 index 0000000..808014b --- /dev/null +++ b/examples/team_recommender/tests/test_proportions_ztest.py @@ -0,0 +1,108 @@ +import pytest +from helpers import is_within_expected +from statsmodels.stats.proportion import proportions_ztest +from test_helpers import next_success_rate + + +def test_proportions_ztest_improvement(): + successes = [70, 90] + n_observations = [100, 100] + + stat, p_value = proportions_ztest(successes, n_observations) + assert p_value == pytest.approx(0.00040695, rel=0.001) + assert p_value < 0.05, "statistically significant result" + assert stat == pytest.approx(-3.5355, rel=0.001) + + +def test_proportions_ztest_exact_match(): + stat, p_value = proportions_ztest(7, 10, 0.7) + assert p_value == 1.0, "statistically insignificant result" + assert stat == 0 + + +def test_proportions_ztest_significantly_better(): + stat, p_value = proportions_ztest(9, 10, 0.7) + assert p_value < 0.05, "statistically significant improvement" + assert proportions_ztest(9, 10, 0.7, alternative="larger")[1] < 0.05, ( + "statistically proportion is larger than expected value" + ) + assert proportions_ztest(9, 10, 0.7, alternative="two-sided")[1] < 0.05, ( + "statistically proportion is larger or smaller than expected value" + ) + + +def test_proportions_ztest_not_statistically_significantly(): + for count in range(4, 8): + stat, p_value = proportions_ztest(count, 10, 0.7) + assert p_value > 0.05, "NO statistically significant deviation" + + +def test_proportions_ztest_significantly_worse(): + stat, p_value = proportions_ztest(3, 10, 0.7) + assert p_value < 0.05, "statistically significant result" + assert proportions_ztest(3, 10, 0.7, alternative="smaller")[1] < 0.05, ( + "statistically proportion is smaller than expected value" + ) + assert proportions_ztest(3, 10, 0.7, alternative="two-sided")[1] < 0.05, ( + "statistically proportion is smaller than expected value" + ) + + +def calculate_p_value(success, failure, sample_size) -> float: + return calculate_ztest(success, failure, sample_size)[1] + + +def calculate_ztest(success, failure, sample_size) -> tuple[float, float]: + measurements = [int(success * sample_size), sample_size - failure] + samples = [sample_size, sample_size] + zstat, p_value = proportions_ztest(measurements, samples) + return zstat, p_value + + +def is_statistically_significant(success, failure, sample_size): + return calculate_p_value(success, failure, sample_size) < 0.05 + + +def test_not_is_statistically_significant(): + assert not is_statistically_significant(0.7, 3, 10), "same proportion" + assert not is_statistically_significant(0.9, 10, 100), "same proportion" + assert not is_statistically_significant(0.7, 30, 100), "same proportion" + assert not is_statistically_significant(0.7, 0, 10), "covers 100% success rate" + + +def test_is_statistically_significant(): + assert is_statistically_significant(0.9, 0, 100), "0 out of 100 > 90% success rate" + assert is_statistically_significant(0.7, 0, 11), "0 out of 11 > 70% success rate" + assert is_statistically_significant(0.9, 0, 31), "0 out of 31 > 90% success rate" + assert is_statistically_significant(0.909090, 0, 33), "0 out of 33 > 90.9% success rate" + + +def test_is_statistically_significant_with_next_success_rate(): + sample_size = 10 + assert not is_statistically_significant(next_success_rate(sample_size), 0, sample_size) + assert is_statistically_significant(next_success_rate(sample_size), 0, 34) + assert is_statistically_significant(next_success_rate(35), 0, 109) + + +def test_compare_is_within_expected_and_is_statistically_significant(): + assert is_within_expected(0.7, 3, 10), "not significant result for 3/10=70%" + assert not is_statistically_significant(0.7, 3, 10), "not significant for 3/10=70%" + + assert is_within_expected(0.7, 0, 3), "not significant result for 0 out of 3" + assert not is_statistically_significant(0.7, 0, 3), "not significant result for 0 out of 3" + + +def test_improvement_from_70_percent(): + assert is_within_expected(0.7, 0, 3), "no improvement detected at 3" + assert not is_statistically_significant(0.7, 0, 10), "no improvement detected at 10" + + assert not is_within_expected(0.7, 0, 4), "improvement detected at 4" + assert is_statistically_significant(0.7, 0, 11), "improvement detected at 11" + + +def test_improvement_from_97_percent(): + assert is_within_expected(0.97, 0, 66), "no improvement detected at 66" + assert not is_statistically_significant(0.97, 0, 100), "no improvement detected at 100" + + assert not is_within_expected(0.97, 0, 67), "significantly better at 67" + assert is_statistically_significant(0.97, 0, 101), "significantly better at 101" diff --git a/pyproject.toml b/pyproject.toml index 1d4ab31..e90f958 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ test = [ "pytest-asyncio>=0.21.0,<0.22", "mypy>=1.8.0,<2", "pytest-snapshot>=0.9.0", + "statsmodels>=0.14.4", ] examples = ["openai>=1.63.2,<2", "python-dotenv>=1.0.1,<2"] dev = [ diff --git a/src/cat_ai/statistical_analysis.py b/src/cat_ai/statistical_analysis.py index 9ee21b8..8add5c0 100644 --- a/src/cat_ai/statistical_analysis.py +++ b/src/cat_ai/statistical_analysis.py @@ -90,8 +90,8 @@ def analyse_measure_from_test_sample(measure: int, sample_size: int) -> Statisti me = z * se # Calculate confidence interval bounds as proportions - lower_bound_prop = p_hat - me - upper_bound_prop = p_hat + me + lower_bound_prop = max(0, p_hat - me) + upper_bound_prop = min(1, p_hat + me) # Convert proportion bounds to integer counts lower_bound_count: int = math.ceil(lower_bound_prop * sample_size) diff --git a/tests/snapshots/test_statistical_analysis/test_failure_rate_bar_graph/failure_rate_results.csv b/tests/snapshots/test_statistical_analysis/test_failure_rate_bar_graph/failure_rate_results.csv index bb1eb2f..d3f3489 100644 --- a/tests/snapshots/test_statistical_analysis/test_failure_rate_bar_graph/failure_rate_results.csv +++ b/tests/snapshots/test_statistical_analysis/test_failure_rate_bar_graph/failure_rate_results.csv @@ -1,7 +1,7 @@ failure_count,sample_size,margin_of_error_count,confidence_lower,confidence_upper,proportion,standard_error,margin_of_error,confidence_proportion_lower,confidence_proportion_upper -0,100,0,0,0,0.0,0.0,0.0,0.0,0.0 -1,100,1,0,2,0.01,0.0099498743710662,0.01636608694695973,-0.006366086946959731,0.02636608694695973 -2,100,2,0,4,0.02,0.014,0.023027950777320602,-0.0030279507773206017,0.043027950777320606 +0,100,0,0,0,0.0,0.0,0.0,0,0.0 +1,100,1,0,2,0.01,0.0099498743710662,0.01636608694695973,0,0.02636608694695973 +2,100,2,0,4,0.02,0.014,0.023027950777320602,0,0.043027950777320606 3,100,2,1,5,0.03,0.01705872210923198,0.02805910093252748,0.00194089906747252,0.058059100932527474 4,100,3,1,7,0.04,0.019595917942265423,0.0322324167007787,0.007767583299221302,0.0722324167007787 5,100,3,2,8,0.05,0.021794494717703367,0.03584875368398907,0.014151246316010932,0.08584875368398907 @@ -97,6 +97,6 @@ failure_count,sample_size,margin_of_error_count,confidence_lower,confidence_uppe 95,100,3,92,98,0.95,0.021794494717703377,0.035848753683989085,0.9141512463160109,0.985848753683989 96,100,3,93,99,0.96,0.019595917942265433,0.03223241670077871,0.9277675832992213,0.9922324167007787 97,100,2,95,99,0.97,0.017058722109231986,0.02805910093252749,0.9419408990674725,0.9980591009325275 -98,100,2,96,100,0.98,0.014000000000000005,0.02302795077732061,0.9569720492226794,1.0030279507773205 -99,100,1,98,100,0.99,0.009949874371066205,0.016366086946959738,0.9736339130530403,1.0063660869469597 -100,100,0,100,100,1.0,0.0,0.0,1.0,1.0 +98,100,2,96,100,0.98,0.014000000000000005,0.02302795077732061,0.9569720492226794,1 +99,100,1,98,100,0.99,0.009949874371066205,0.016366086946959738,0.9736339130530403,1 +100,100,0,100,100,1.0,0.0,0.0,1.0,1 diff --git a/tests/snapshots/test_statistical_analysis/test_failure_rate_graph/failure_rate_graph.png b/tests/snapshots/test_statistical_analysis/test_failure_rate_graph/failure_rate_graph.png index 44f1a59..336740b 100644 Binary files a/tests/snapshots/test_statistical_analysis/test_failure_rate_graph/failure_rate_graph.png and b/tests/snapshots/test_statistical_analysis/test_failure_rate_graph/failure_rate_graph.png differ diff --git a/uv.lock b/uv.lock index f30538d..4edc6a3 100644 --- a/uv.lock +++ b/uv.lock @@ -189,6 +189,7 @@ test = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-snapshot" }, + { name = "statsmodels" }, ] [package.metadata] @@ -214,6 +215,7 @@ test = [ { name = "pytest", specifier = ">=8.3.4,<9" }, { name = "pytest-asyncio", specifier = ">=0.21.0,<0.22" }, { name = "pytest-snapshot", specifier = ">=0.9.0" }, + { name = "statsmodels", specifier = ">=0.14.4" }, ] [[package]] @@ -1244,6 +1246,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643 }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573 }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085 }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809 }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316 }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055 }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175 }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650 }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177 }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526 }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013 }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620 }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436 }, +] + [[package]] name = "pandocfilters" version = "1.5.1" @@ -1262,6 +1291,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, ] +[[package]] +name = "patsy" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/81/74f6a65b848ffd16c18f920620ce999fe45fe27f01ab3911260ce4ed85e4/patsy-1.0.1.tar.gz", hash = "sha256:e786a9391eec818c054e359b737bbce692f051aee4c661f4141cc88fb459c0c4", size = 396010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/2b/b50d3d08ea0fc419c183a84210571eba005328efa62b6b98bc28e9ead32a/patsy-1.0.1-py2.py3-none-any.whl", hash = "sha256:751fb38f9e97e62312e921a1954b81e1bb2bcda4f5eeabaf94db251ee791509c", size = 232923 }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -1595,6 +1636,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/72/2f30cf26664fcfa0bd8ec5ee62ec90c03bd485e4a294d92aabc76c5203a5/python_json_logger-3.2.1-py3-none-any.whl", hash = "sha256:cdc17047eb5374bd311e748b42f99d71223f3b0e186f4206cc5d52aefe85b090", size = 14924 }, ] +[[package]] +name = "pytz" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, +] + [[package]] name = "pywin32" version = "308" @@ -1793,6 +1843,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/85/338e603dc68e7d9994d5d84f24adbf69bae760ba5efd3e20f5ff2cec18da/ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69", size = 10436892 }, ] +[[package]] +name = "scipy" +version = "1.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/b9/31ba9cd990e626574baf93fbc1ac61cf9ed54faafd04c479117517661637/scipy-1.15.2.tar.gz", hash = "sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec", size = 59417316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/40/09319f6e0f276ea2754196185f95cd191cb852288440ce035d5c3a931ea2/scipy-1.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01edfac9f0798ad6b46d9c4c9ca0e0ad23dbf0b1eb70e96adb9fa7f525eff0bf", size = 38717587 }, + { url = "https://files.pythonhosted.org/packages/fe/c3/2854f40ecd19585d65afaef601e5e1f8dbf6758b2f95b5ea93d38655a2c6/scipy-1.15.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:08b57a9336b8e79b305a143c3655cc5bdbe6d5ece3378578888d2afbb51c4e37", size = 30100266 }, + { url = "https://files.pythonhosted.org/packages/dd/b1/f9fe6e3c828cb5930b5fe74cb479de5f3d66d682fa8adb77249acaf545b8/scipy-1.15.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:54c462098484e7466362a9f1672d20888f724911a74c22ae35b61f9c5919183d", size = 22373768 }, + { url = "https://files.pythonhosted.org/packages/15/9d/a60db8c795700414c3f681908a2b911e031e024d93214f2d23c6dae174ab/scipy-1.15.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:cf72ff559a53a6a6d77bd8eefd12a17995ffa44ad86c77a5df96f533d4e6c6bb", size = 25154719 }, + { url = "https://files.pythonhosted.org/packages/37/3b/9bda92a85cd93f19f9ed90ade84aa1e51657e29988317fabdd44544f1dd4/scipy-1.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de9d1416b3d9e7df9923ab23cd2fe714244af10b763975bea9e4f2e81cebd27", size = 35163195 }, + { url = "https://files.pythonhosted.org/packages/03/5a/fc34bf1aa14dc7c0e701691fa8685f3faec80e57d816615e3625f28feb43/scipy-1.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb530e4794fc8ea76a4a21ccb67dea33e5e0e60f07fc38a49e821e1eae3b71a0", size = 37255404 }, + { url = "https://files.pythonhosted.org/packages/4a/71/472eac45440cee134c8a180dbe4c01b3ec247e0338b7c759e6cd71f199a7/scipy-1.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5ea7ed46d437fc52350b028b1d44e002646e28f3e8ddc714011aaf87330f2f32", size = 36860011 }, + { url = "https://files.pythonhosted.org/packages/01/b3/21f890f4f42daf20e4d3aaa18182dddb9192771cd47445aaae2e318f6738/scipy-1.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11e7ad32cf184b74380f43d3c0a706f49358b904fa7d5345f16ddf993609184d", size = 39657406 }, + { url = "https://files.pythonhosted.org/packages/0d/76/77cf2ac1f2a9cc00c073d49e1e16244e389dd88e2490c91d84e1e3e4d126/scipy-1.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:a5080a79dfb9b78b768cebf3c9dcbc7b665c5875793569f48bf0e2b1d7f68f6f", size = 40961243 }, + { url = "https://files.pythonhosted.org/packages/4c/4b/a57f8ddcf48e129e6054fa9899a2a86d1fc6b07a0e15c7eebff7ca94533f/scipy-1.15.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:447ce30cee6a9d5d1379087c9e474628dab3db4a67484be1b7dc3196bfb2fac9", size = 38870286 }, + { url = "https://files.pythonhosted.org/packages/0c/43/c304d69a56c91ad5f188c0714f6a97b9c1fed93128c691148621274a3a68/scipy-1.15.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c90ebe8aaa4397eaefa8455a8182b164a6cc1d59ad53f79943f266d99f68687f", size = 30141634 }, + { url = "https://files.pythonhosted.org/packages/44/1a/6c21b45d2548eb73be9b9bff421aaaa7e85e22c1f9b3bc44b23485dfce0a/scipy-1.15.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:def751dd08243934c884a3221156d63e15234a3155cf25978b0a668409d45eb6", size = 22415179 }, + { url = "https://files.pythonhosted.org/packages/74/4b/aefac4bba80ef815b64f55da06f62f92be5d03b467f2ce3668071799429a/scipy-1.15.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:302093e7dfb120e55515936cb55618ee0b895f8bcaf18ff81eca086c17bd80af", size = 25126412 }, + { url = "https://files.pythonhosted.org/packages/b1/53/1cbb148e6e8f1660aacd9f0a9dfa2b05e9ff1cb54b4386fe868477972ac2/scipy-1.15.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd5b77413e1855351cdde594eca99c1f4a588c2d63711388b6a1f1c01f62274", size = 34952867 }, + { url = "https://files.pythonhosted.org/packages/2c/23/e0eb7f31a9c13cf2dca083828b97992dd22f8184c6ce4fec5deec0c81fcf/scipy-1.15.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d0194c37037707b2afa7a2f2a924cf7bac3dc292d51b6a925e5fcb89bc5c776", size = 36890009 }, + { url = "https://files.pythonhosted.org/packages/03/f3/e699e19cabe96bbac5189c04aaa970718f0105cff03d458dc5e2b6bd1e8c/scipy-1.15.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:bae43364d600fdc3ac327db99659dcb79e6e7ecd279a75fe1266669d9a652828", size = 36545159 }, + { url = "https://files.pythonhosted.org/packages/af/f5/ab3838e56fe5cc22383d6fcf2336e48c8fe33e944b9037fbf6cbdf5a11f8/scipy-1.15.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f031846580d9acccd0044efd1a90e6f4df3a6e12b4b6bd694a7bc03a89892b28", size = 39136566 }, + { url = "https://files.pythonhosted.org/packages/0a/c8/b3f566db71461cabd4b2d5b39bcc24a7e1c119535c8361f81426be39bb47/scipy-1.15.2-cp313-cp313t-win_amd64.whl", hash = "sha256:fe8a9eb875d430d81755472c5ba75e84acc980e4a8f6204d402849234d3017db", size = 40477705 }, +] + [[package]] name = "send2trash" version = "1.8.3" @@ -1983,6 +2062,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] +[[package]] +name = "statsmodels" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/3b/963a015dd8ea17e10c7b0e2f14d7c4daec903baf60a017e756b57953a4bf/statsmodels-0.14.4.tar.gz", hash = "sha256:5d69e0f39060dc72c067f9bb6e8033b6dccdb0bae101d76a7ef0bcc94e898b67", size = 20354802 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/f8/2662e6a101315ad336f75168fa9bac71f913ebcb92a6be84031d84a0f21f/statsmodels-0.14.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5a24f5d2c22852d807d2b42daf3a61740820b28d8381daaf59dcb7055bf1a79", size = 10186886 }, + { url = "https://files.pythonhosted.org/packages/fa/c0/ee6e8ed35fc1ca9c7538c592f4974547bf72274bc98db1ae4a6e87481a83/statsmodels-0.14.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df4f7864606fa843d7e7c0e6af288f034a2160dba14e6ccc09020a3cf67cb092", size = 9880066 }, + { url = "https://files.pythonhosted.org/packages/d1/97/3380ca6d8fd66cfb3d12941e472642f26e781a311c355a4e97aab2ed0216/statsmodels-0.14.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91341cbde9e8bea5fb419a76e09114e221567d03f34ca26e6d67ae2c27d8fe3c", size = 10283521 }, + { url = "https://files.pythonhosted.org/packages/fe/2a/55c5b5c5e5124a202ea3fe0bcdbdeceaf91b4ec6164b8434acb9dd97409c/statsmodels-0.14.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1322286a7bfdde2790bf72d29698a1b76c20b8423a55bdcd0d457969d0041f72", size = 10723228 }, + { url = "https://files.pythonhosted.org/packages/4f/76/67747e49dc758daae06f33aad8247b718cd7d224f091d2cd552681215bb2/statsmodels-0.14.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e31b95ac603415887c9f0d344cb523889cf779bc52d68e27e2d23c358958fec7", size = 10859503 }, + { url = "https://files.pythonhosted.org/packages/1d/eb/cb8b01f5edf8f135eb3d0553d159db113a35b2948d0e51eeb735e7ae09ea/statsmodels-0.14.4-cp313-cp313-win_amd64.whl", hash = "sha256:81030108d27aecc7995cac05aa280cf8c6025f6a6119894eef648997936c2dd0", size = 9817574 }, +] + [[package]] name = "tabulate" version = "0.9.0" @@ -2075,6 +2175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + [[package]] name = "uri-template" version = "1.3.0"