mirror of
https://github.com/Floriansylvain/FDJ-SLAYER.git
synced 2026-08-19 11:43:21 +02:00
feat: stats analysis & viz w/ unit tests
This commit is contained in:
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user