1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
|
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# © Copyright 2021-2022, Scott Gasch
"""A text-based simple histogram helper class."""
import math
from dataclasses import dataclass
from typing import Dict, Generic, Iterable, List, Optional, Tuple, TypeVar
T = TypeVar("T", int, float)
Bound = int
Count = int
@dataclass
class BucketDetails:
"""A collection of details about the internal histogram buckets."""
num_populated_buckets: int = 0
"""Count of populated buckets"""
max_population: Optional[int] = None
"""The max population in a bucket currently"""
last_bucket_start: Optional[int] = None
"""The last bucket starting point"""
lowest_start: Optional[int] = None
"""The lowest populated bucket's starting point"""
highest_end: Optional[int] = None
"""The highest populated bucket's ending point"""
max_label_width: Optional[int] = None
"""The maximum label width (for display purposes)"""
class SimpleHistogram(Generic[T]):
"""A simple histogram."""
# Useful in defining wide open bottom/top bucket bounds:
POSITIVE_INFINITY = math.inf
NEGATIVE_INFINITY = -math.inf
def __init__(self, buckets: List[Tuple[Bound, Bound]]):
"""C'tor.
Args:
buckets: a list of [start..end] tuples that define the
buckets we are counting population in. See also
:meth:`n_evenly_spaced_buckets` to generate these
buckets more easily.
"""
from math_utils import NumericPopulation
self.buckets: Dict[Tuple[Bound, Bound], Count] = {}
for start_end in buckets:
if self._get_bucket(start_end[0]) is not None:
raise Exception("Buckets overlap?!")
self.buckets[start_end] = 0
self.sigma: float = 0.0
self.stats: NumericPopulation = NumericPopulation()
self.maximum: Optional[T] = None
self.minimum: Optional[T] = None
self.count: Count = 0
@staticmethod
def n_evenly_spaced_buckets(
min_bound: T,
max_bound: T,
n: int,
) -> List[Tuple[int, int]]:
"""A helper method for generating the buckets argument to
our c'tor provided that you want N evenly spaced buckets.
Args:
min_bound: the minimum possible value
max_bound: the maximum possible value
n: how many buckets to create
Returns:
A list of bounds that define N evenly spaced buckets
"""
ret: List[Tuple[int, int]] = []
stride = int((max_bound - min_bound) / n)
if stride <= 0:
raise Exception("Min must be < Max")
imax = math.ceil(max_bound)
imin = math.floor(min_bound)
for bucket_start in range(imin, imax, stride):
ret.append((bucket_start, bucket_start + stride))
return ret
def _get_bucket(self, item: T) -> Optional[Tuple[int, int]]:
"""Given an item, what bucket is it in?"""
for start_end in self.buckets:
if start_end[0] <= item < start_end[1]:
return start_end
return None
def add_item(self, item: T) -> bool:
"""Adds a single item to the histogram (reculting in us incrementing
the population in the correct bucket.
Args:
item: the item to be added
Returns:
True if the item was successfully added or False if the item
is not within the bounds established during class construction.
"""
bucket = self._get_bucket(item)
if bucket is None:
return False
self.count += 1
self.buckets[bucket] += 1
self.sigma += item
self.stats.add_number(item)
if self.maximum is None or item > self.maximum:
self.maximum = item
if self.minimum is None or item < self.minimum:
self.minimum = item
return True
def add_items(self, lst: Iterable[T]) -> bool:
"""Adds a collection of items to the histogram and increments
the correct bucket's population for each item.
Args:
lst: An iterable of items to be added
Returns:
True if all items were added successfully or False if any
item was not able to be added because it was not within the
bounds established at object construction.
"""
all_true = True
for item in lst:
all_true = all_true and self.add_item(item)
return all_true
def _get_bucket_details(self, label_formatter: str) -> BucketDetails:
"""Get the details about one bucket."""
details = BucketDetails()
for (start, end), pop in sorted(self.buckets.items(), key=lambda x: x[0]):
if pop > 0:
details.num_populated_buckets += 1
details.last_bucket_start = start
if details.max_population is None or pop > details.max_population:
details.max_population = pop
if details.lowest_start is None or start < details.lowest_start:
details.lowest_start = start
if details.highest_end is None or end > details.highest_end:
details.highest_end = end
label = f'[{label_formatter}..{label_formatter}): ' % (start, end)
label_width = len(label)
if details.max_label_width is None or label_width > details.max_label_width:
details.max_label_width = label_width
return details
def __repr__(self, *, width: int = 80, label_formatter: str = '%d') -> str:
"""Returns a pretty (text) representation of the histogram and
some vital stats about the population in it (min, max, mean,
median, mode, stdev, etc...)
"""
from text_utils import bar_graph
details = self._get_bucket_details(label_formatter)
txt = ""
if details.num_populated_buckets == 0:
return txt
assert details.max_label_width is not None
assert details.lowest_start is not None
assert details.highest_end is not None
assert details.max_population is not None
sigma_label = f'[{label_formatter}..{label_formatter}): ' % (
details.lowest_start,
details.highest_end,
)
if len(sigma_label) > details.max_label_width:
details.max_label_width = len(sigma_label)
bar_width = width - (details.max_label_width + 17)
for (start, end), pop in sorted(self.buckets.items(), key=lambda x: x[0]):
if start < details.lowest_start:
continue
label = f'[{label_formatter}..{label_formatter}): ' % (start, end)
bar = bar_graph(
(pop / details.max_population),
include_text=False,
width=bar_width,
left_end="",
right_end="",
)
txt += label.rjust(details.max_label_width)
txt += bar
txt += f"({pop/self.count*100.0:5.2f}% n={pop})\n"
if start == details.last_bucket_start:
break
txt += '-' * width + '\n'
txt += sigma_label.rjust(details.max_label_width)
txt += ' ' * (bar_width - 2)
txt += f' pop(Σn)={self.count}\n'
txt += ' ' * (bar_width + details.max_label_width - 2)
txt += f' mean(x̄)={self.stats.get_mean():.3f}\n'
txt += ' ' * (bar_width + details.max_label_width - 2)
txt += f' median(p50)={self.stats.get_median():.3f}\n'
txt += ' ' * (bar_width + details.max_label_width - 2)
txt += f' mode(Mo)={self.stats.get_mode()[0]:.3f}\n'
txt += ' ' * (bar_width + details.max_label_width - 2)
txt += f' stdev(σ)={self.stats.get_stdev():.3f}\n'
txt += '\n'
return txt
|