From bbb91c1b41d538135aebee8c6119552b8338d04d Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Fri, 28 Mar 2025 16:06:27 +0100 Subject: [PATCH] feat: stats analysis & viz w/ unit tests --- fdj_slayer/stats.py | 221 ++++++++++++++++++++++++++++++++++++++++++++ main.py | 44 ++++++--- test/stats_test.py | 199 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 449 insertions(+), 15 deletions(-) create mode 100644 fdj_slayer/stats.py create mode 100644 test/stats_test.py diff --git a/fdj_slayer/stats.py b/fdj_slayer/stats.py new file mode 100644 index 0000000..9daeead --- /dev/null +++ b/fdj_slayer/stats.py @@ -0,0 +1,221 @@ +""" +This module provides statistical analysis and visualization for lottery draws. +""" + +import math +import matplotlib.pyplot as plt +from scipy import stats + + +class StatsAnalysis: + """Handles statistical analysis of lottery draws""" + + def __init__(self, max_number, max_star, number_of_numbers, number_of_stars): + self.max_number = max_number + self.max_star = max_star + self.number_of_numbers = number_of_numbers + self.number_of_stars = number_of_stars + + def analyze_randomness(self, draws): + """ + Analyzes the randomness of generated draws using statistical tests. + Args: + draws: List of draw dictionaries + Returns: + Dictionary with analysis results + Raises: + ValueError: If the draws list is empty + """ + if not draws: + raise ValueError("Cannot analyze randomness with empty draws list") + + all_numbers, all_stars = self._extract_numbers_and_stars(draws) + results = {"sample_size": len(draws)} + + number_results = self._analyze_dataset( + all_numbers, "number", self.max_number) + results.update(number_results) + + star_results = self._analyze_dataset(all_stars, "star", self.max_star) + results.update(star_results) + + return results + + def _analyze_dataset(self, values, value_type, max_value): + """ + Analyzes a single dataset (either numbers or stars). + Args: + values: List of values to analyze + value_type: String identifier ("number" or "star") + max_value: Maximum possible value in the dataset + Returns: + Dictionary with analysis results for this dataset + """ + results = {} + counts = self._calculate_frequencies(values, max_value) + expected_freq = len(values) / max_value + chi2_result = self._perform_chi_square_test( + counts, expected_freq, max_value) + min_max = self._find_min_max_frequencies(counts) + stats_data = self._calculate_statistics(counts, expected_freq, min_max) + + results[f"{value_type}_frequencies"] = counts + results[f"expected_{value_type}_freq"] = expected_freq + results[f"{value_type}_chi2"] = chi2_result["chi2"] + results[f"p_value_{value_type}s"] = chi2_result["p_value"] + results[f"min_{value_type}"] = min_max["min"] + results[f"max_{value_type}"] = min_max["max"] + results[f"{value_type}_std_dev"] = stats_data["std_dev"] + results[f"{value_type}_variation_pct"] = stats_data["variation_pct"] + results[f"{value_type}_assessment"] = self._assess_randomness( + chi2_result["p_value"]) + + return results + + def _extract_numbers_and_stars(self, draws): + """Extract all numbers and stars from the draws""" + all_numbers = [] + all_stars = [] + for draw in draws: + all_numbers.extend(draw['numbers']) + all_stars.extend(draw['stars']) + return all_numbers, all_stars + + def _calculate_frequencies(self, values, max_value): + """Count the frequency of each value""" + counts = {} + for n in range(1, max_value + 1): + counts[n] = values.count(n) + return counts + + def _perform_chi_square_test(self, counts, expected_freq, max_value): + """Perform chi-square test on the distribution""" + observed = list(counts.values()) + expected = [expected_freq] * max_value + chi2, p_value = stats.chisquare(observed, expected) + return {"chi2": chi2, "p_value": p_value} + + def _find_min_max_frequencies(self, counts): + """Find minimum and maximum frequencies""" + min_count = min(counts.values()) + max_count = max(counts.values()) + min_values = [n for n, count in counts.items() if count == min_count] + max_values = [n for n, count in counts.items() if count == max_count] + return { + "min": (min_values, min_count), + "max": (max_values, max_count) + } + + def _calculate_statistics(self, counts, expected_freq, min_max): + """Calculate standard deviation and variation percentage""" + std_dev = math.sqrt(sum((count - expected_freq) ** 2 + for count in counts.values()) / len(counts)) + min_count = min_max["min"][1] + max_count = min_max["max"][1] + + if expected_freq == 0: + variation_pct = 0 + else: + variation_pct = (max_count - min_count) / expected_freq * 100 + + return { + "std_dev": std_dev, + "variation_pct": variation_pct + } + + def _assess_randomness(self, p_value): + """Assess randomness based on p-value""" + return "likely random" if p_value > 0.05 else "possibly biased" + + +class StatsVisualization: + """Handles visualization of lottery draw statistics""" + + def __init__(self, max_number, max_star): + self.max_number = max_number + self.max_star = max_star + + def display_randomness_analysis(self, results): + """Displays the results of randomness analysis in a user-friendly format""" + print(f"\n===== RANDOMNESS ANALYSIS =====\n" + f"{self._format_analysis_section(results, 'number')}\n" + f"{self._format_analysis_section(results, 'star')}\n\n" + f"For truly reliable randomness assessment, a larger sample size may be needed.") + if results['sample_size'] < 100: + print( + "Sample size is relatively small, results should be interpreted with caution.") + self._prompt_for_visualization(results) + + def _format_analysis_section(self, results, value_type): + """ + Formats a section of the randomness analysis output + Args: + results: Dictionary containing analysis results + value_type: Either "number" or "star" to specify which analysis to format + """ + title_suffix = "NUMBERS ANALYSIS" if value_type == "number" else "STARS ANALYSIS" + title = f"MAIN {title_suffix}" if value_type == "number" else f"STAR {title_suffix}" + min_key = f"min_{value_type}" + max_key = f"max_{value_type}" + return f"\n{title}:" \ + f"\nExpected frequency per value: {results[f'expected_{value_type}_freq']:.2f}" \ + f"\nVariation between min and max: {results[f'{value_type}_variation_pct']:.2f}%" \ + f"\nLeast frequent value(s): {results[min_key][0]} (x{results[min_key][1]})" \ + f"\nMost frequent value(s): {results[max_key][0]} (x{results[max_key][1]})" \ + f"\nStandard deviation: {results[f'{value_type}_std_dev']:.2f}" \ + f"\nChi-square value: {results[f'{value_type}_chi2']:.2f}" \ + f"\nP-value: {results[f'p_value_{value_type}s']:.4f}" \ + f"\nAssessment: {results[f'{value_type}_assessment'].upper()}" + + def _prompt_for_visualization(self, results): + """Asks the user if they want to see a visual representation of the distribution""" + show_viz = input( + "\nDo you want to see the distribution visualization? (y/n): ").lower().strip() + if show_viz in ['o', 'oui', 'y', 'yes', '']: + self._visualize_distribution(results) + + def _visualize_distribution(self, results): + """Creates a visualization of the number and star distributions""" + _, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10)) + number_config = { + "color": "blue", + "title": "Main Numbers Distribution", + "tick_interval": 5 + } + star_config = { + "color": "green", + "title": "Star Numbers Distribution", + "tick_interval": 1 + } + self._plot_frequency_distribution( + ax1, results, "number", number_config) + self._plot_frequency_distribution(ax2, results, "star", star_config) + plt.tight_layout() + plt.show() + + def _plot_frequency_distribution(self, ax, results, value_type, config=None): + """ + Plots frequency distribution on the given axes + Args: + ax: Matplotlib axes to plot on + results: Analysis results dictionary + value_type: "number" or "star" + config: Optional dictionary with plot configuration + """ + config = config or {} + color = config.get("color", "blue") + title = config.get("title", f"{value_type.capitalize()} Distribution") + max_value = self.max_number if value_type == "number" else self.max_star + tick_interval = config.get("tick_interval", max(1, max_value // 10)) + values = list(results[f'{value_type}_frequencies'].keys()) + frequencies = list(results[f'{value_type}_frequencies'].values()) + expected_freq = results[f'expected_{value_type}_freq'] + + ax.bar(values, frequencies, color=color, alpha=0.7) + ax.axhline(y=expected_freq, color='r', + linestyle='-', label='Expected frequency') + ax.set_title(title) + ax.set_xlabel(value_type.capitalize()) + ax.set_ylabel('Frequency') + ax.set_xticks(range(1, max_value + 1, tick_interval)) + ax.legend() diff --git a/main.py b/main.py index 92bb05c..04637f4 100644 --- a/main.py +++ b/main.py @@ -1,31 +1,45 @@ """ -This script generates random draws for EuroMillions using various entropy sources. +Main entry point for the FDJ Slayer application. """ import random -import numpy as _ - from fdj_slayer.draw import Draw +from fdj_slayer.stats import StatsAnalysis, StatsVisualization from fdj_slayer.weather import Weather -from fdj_slayer.constants import NUMBER_OF_DRAWS +from fdj_slayer.constants import MAX_NUMBER, MAX_STAR, NUMBER_OF_NUMBERS, NUMBER_OF_STARS def main(): - """Main function that orchestrates the draw generation process""" + """Main entry point for the application""" weather = Weather() - draw_generator = Draw(weather) + stats_analyzer = StatsAnalysis( + MAX_NUMBER, MAX_STAR, NUMBER_OF_NUMBERS, NUMBER_OF_STARS) + stats_visualizer = StatsVisualization(MAX_NUMBER, MAX_STAR) + draw = Draw(weather) - print(f"Generating {NUMBER_OF_DRAWS} different draws...") - draws = draw_generator.generate_draws(NUMBER_OF_DRAWS) + print("\nWelcome to FDJ SLAYER - Your EuroMillions Number Generator!") - base_pool = draw_generator.get_static_entropy_pool() - random.seed(draw_generator.generate_seed(base_pool)) - chosen_draw = random.choice(draws) - displayed_draws = {draws.index(chosen_draw)} + try: + num_draws = int(input("\nHow many draws would you like to generate? ")) + except ValueError: + num_draws = 5 + print(f"Invalid input, defaulting to {num_draws} draws.") - draw_generator.display_draw(chosen_draw, title="FINAL RESULT") - print("Draw selected from among the", NUMBER_OF_DRAWS, "generated") - draw_generator.display_additional_draws(draws, displayed_draws) + print("\nGenerating random draws with enhanced entropy...") + draws = draw.generate_draws(num_draws) + + if draws: + displayed = set() + random_index = random.randint(0, len(draws) - 1) + draw.display_draw( + draws[random_index], random_index, "RANDOMLY SELECTED DRAW") + displayed.add(random_index) + + draw.display_additional_draws(draws, displayed) + + print("\nAnalyzing randomness of the generated draws...") + analysis = stats_analyzer.analyze_randomness(draws) + stats_visualizer.display_randomness_analysis(analysis) if __name__ == "__main__": diff --git a/test/stats_test.py b/test/stats_test.py new file mode 100644 index 0000000..1181012 --- /dev/null +++ b/test/stats_test.py @@ -0,0 +1,199 @@ +""" +Unit tests for the StatsAnalysis class in fdj_slayer.stats module +""" +import unittest +from unittest.mock import patch +import math +from fdj_slayer.stats import StatsAnalysis + + +class TestStatsAnalysis(unittest.TestCase): + """Tests for the StatsAnalysis class""" + + def setUp(self): + """Set up test fixtures before each test method""" + self.stats_analyzer = StatsAnalysis( + max_number=50, max_star=12, number_of_numbers=5, number_of_stars=2) + + self.sample_draws = [ + {'numbers': [1, 10, 20, 30, 40], 'stars': [1, 10]}, + {'numbers': [5, 15, 25, 35, 45], 'stars': [5, 12]}, + {'numbers': [2, 12, 22, 32, 42], 'stars': [2, 11]}, + {'numbers': [3, 13, 23, 33, 43], 'stars': [3, 8]}, + {'numbers': [4, 14, 24, 34, 44], 'stars': [4, 9]} + ] + + def test_init(self): + """Test initialization with correct parameters""" + self.assertEqual(self.stats_analyzer.max_number, 50) + self.assertEqual(self.stats_analyzer.max_star, 12) + self.assertEqual(self.stats_analyzer.number_of_numbers, 5) + self.assertEqual(self.stats_analyzer.number_of_stars, 2) + + def test_extract_numbers_and_stars(self): + """Test extraction of numbers and stars from draws""" + all_numbers, all_stars = self.stats_analyzer._extract_numbers_and_stars( + self.sample_draws) + + expected_numbers = [1, 10, 20, 30, 40, 5, 15, 25, 35, 45, 2, 12, 22, 32, 42, + 3, 13, 23, 33, 43, 4, 14, 24, 34, 44] + expected_stars = [1, 10, 5, 12, 2, 11, 3, 8, 4, 9] + + self.assertEqual(sorted(all_numbers), sorted(expected_numbers)) + self.assertEqual(sorted(all_stars), sorted(expected_stars)) + + def test_calculate_frequencies(self): + """Test frequency calculation""" + values = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4] + max_value = 5 + + frequencies = self.stats_analyzer._calculate_frequencies( + values, max_value) + + self.assertEqual(frequencies, {1: 1, 2: 2, 3: 3, 4: 4, 5: 0}) + self.assertEqual(sum(frequencies.values()), len(values)) + + def test_find_min_max_frequencies(self): + """Test finding minimum and maximum frequencies""" + counts = {1: 5, 2: 3, 3: 3, 4: 10, 5: 0} + + min_max = self.stats_analyzer._find_min_max_frequencies(counts) + + self.assertEqual(min_max["min"], ([5], 0)) + self.assertEqual(min_max["max"], ([4], 10)) + + counts = {1: 5, 2: 5, 3: 0, 4: 0, 5: 10, 6: 10} + min_max = self.stats_analyzer._find_min_max_frequencies(counts) + + self.assertEqual(sorted(min_max["min"][0]), [3, 4]) + self.assertEqual(min_max["min"][1], 0) + self.assertEqual(sorted(min_max["max"][0]), [5, 6]) + self.assertEqual(min_max["max"][1], 10) + + def test_calculate_statistics(self): + """Test statistics calculation""" + counts = {1: 5, 2: 10, 3: 15} + expected_freq = 10 + min_max = {"min": ([1], 5), "max": ([3], 15)} + + stats_data = self.stats_analyzer._calculate_statistics( + counts, expected_freq, min_max) + + expected_std_dev = math.sqrt( + sum((count - expected_freq)**2 for count in [5, 10, 15]) / 3) + expected_variation_pct = (15 - 5) / expected_freq * 100 + + self.assertAlmostEqual(stats_data["std_dev"], expected_std_dev) + self.assertAlmostEqual( + stats_data["variation_pct"], expected_variation_pct) + + def test_assess_randomness(self): + """Test randomness assessment based on p-value""" + self.assertEqual( + self.stats_analyzer._assess_randomness(0.06), "likely random") + self.assertEqual(self.stats_analyzer._assess_randomness( + 0.05), "possibly biased") + self.assertEqual(self.stats_analyzer._assess_randomness( + 0.04), "possibly biased") + self.assertEqual( + self.stats_analyzer._assess_randomness(0.8), "likely random") + self.assertEqual(self.stats_analyzer._assess_randomness( + 0.01), "possibly biased") + + @patch('scipy.stats.chisquare') + def test_perform_chi_square_test(self, mock_chisquare): + """Test chi-square test with mocked scipy function""" + mock_chisquare.return_value = (1.234, 0.567) + + counts = {1: 5, 2: 7, 3: 9} + expected_freq = 7 + max_value = 3 + + result = self.stats_analyzer._perform_chi_square_test( + counts, expected_freq, max_value) + + mock_chisquare.assert_called_once_with([5, 7, 9], [7, 7, 7]) + self.assertEqual(result, {"chi2": 1.234, "p_value": 0.567}) + + def test_analyze_dataset(self): + """Test dataset analysis with mocked internal methods""" + values = [1, 1, 2, 2, 2, 3, 3, 4] + value_type = "number" + max_value = 5 + + with patch.object(self.stats_analyzer, '_calculate_frequencies') as mock_calc_freq, \ + patch.object(self.stats_analyzer, '_perform_chi_square_test') as mock_chi2, \ + patch.object(self.stats_analyzer, '_find_min_max_frequencies') as mock_min_max, \ + patch.object(self.stats_analyzer, '_calculate_statistics') as mock_stats, \ + patch.object(self.stats_analyzer, '_assess_randomness') as mock_assess: + + mock_calc_freq.return_value = {1: 2, 2: 3, 3: 2, 4: 1, 5: 0} + mock_chi2.return_value = {"chi2": 2.5, "p_value": 0.6} + mock_min_max.return_value = {"min": ([5], 0), "max": ([2], 3)} + mock_stats.return_value = {"std_dev": 1.2, "variation_pct": 30.0} + mock_assess.return_value = "likely random" + + results = self.stats_analyzer._analyze_dataset( + values, value_type, max_value) + + self.assertEqual(results["number_frequencies"], { + 1: 2, 2: 3, 3: 2, 4: 1, 5: 0}) + self.assertEqual( + results["expected_number_freq"], len(values) / max_value) + self.assertEqual(results["number_chi2"], 2.5) + self.assertEqual(results["p_value_numbers"], 0.6) + self.assertEqual(results["min_number"], ([5], 0)) + self.assertEqual(results["max_number"], ([2], 3)) + self.assertEqual(results["number_std_dev"], 1.2) + self.assertEqual(results["number_variation_pct"], 30.0) + self.assertEqual(results["number_assessment"], "likely random") + + @patch.object(StatsAnalysis, '_analyze_dataset') + @patch.object(StatsAnalysis, '_extract_numbers_and_stars') + def test_analyze_randomness(self, mock_extract, mock_analyze): + """Test analyze_randomness with mocked methods to verify method interactions""" + mock_extract.return_value = ([1, 2, 3], [4, 5]) + mock_analyze.side_effect = [ + {"number_key": "number_value"}, + {"star_key": "star_value"} + ] + + results = self.stats_analyzer.analyze_randomness(self.sample_draws) + + mock_extract.assert_called_once_with(self.sample_draws) + self.assertEqual(mock_analyze.call_count, 2) + mock_analyze.assert_any_call([1, 2, 3], "number", 50) + mock_analyze.assert_any_call([4, 5], "star", 12) + + self.assertEqual(results, { + "sample_size": len(self.sample_draws), + "number_key": "number_value", + "star_key": "star_value" + }) + + @patch('scipy.stats.chisquare') + def test_analyze_randomness_integration(self, mock_chisquare): + """Integration test for analyze_randomness with real data""" + + mock_chisquare.return_value = (2.0, 0.8) + + results = self.stats_analyzer.analyze_randomness(self.sample_draws) + + self.assertEqual(results["sample_size"], len(self.sample_draws)) + self.assertIn("number_frequencies", results) + self.assertIn("star_frequencies", results) + self.assertEqual( + len(results["number_frequencies"]), self.stats_analyzer.max_number) + self.assertEqual( + len(results["star_frequencies"]), self.stats_analyzer.max_star) + self.assertEqual(results["number_assessment"], "likely random") + self.assertEqual(results["star_assessment"], "likely random") + + def test_analyze_randomness_empty_draws(self): + """Test behavior with empty draws list""" + with self.assertRaises(ValueError): + self.stats_analyzer.analyze_randomness([]) + + +if __name__ == '__main__': + unittest.main()