File size: 11,630 Bytes
c0bce59 | 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 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | """S8: dataset statistics (duration, tag/emotion distributions, characters per second).
Pure functions over small typed rows; no I/O. Durations are integer milliseconds.
Nothing is dropped silently: duration values outside the histogram edges land in explicit
underflow/overflow bins, and segments without tags or emotion are counted separately.
"""
from __future__ import annotations
import math
from bisect import bisect_right
from collections import Counter
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass
from itertools import pairwise
import numpy as np
__all__ = [
"DEFAULT_CPS_PERCENTILES",
"MS_PER_HOUR",
"SegmentStat",
"StatsSummary",
"duration_histogram",
"render_stats_markdown",
"summarize",
]
MS_PER_HOUR = 3_600_000
_MS_PER_SECOND = 1000
DEFAULT_CPS_PERCENTILES: tuple[float, ...] = (1.0, 5.0, 50.0, 95.0, 99.0)
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
@dataclass(frozen=True)
class SegmentStat:
"""The per-segment facts S8 needs.
Attributes:
duration_ms: Segment length in milliseconds, > 0.
num_chars: Characters of the final transcript used for the chars-per-second rate
(the caller decides whether tags count), >= 0.
tags: Tags occurring in the segment, one entry per occurrence.
emotion: Emotion label of the segment, or ``None`` when unlabelled.
"""
duration_ms: int
num_chars: int
tags: tuple[str, ...] = ()
emotion: str | None = None
def __post_init__(self) -> None:
if not _is_int(self.duration_ms) or self.duration_ms <= 0:
raise ValueError(f"duration_ms must be a positive int, got {self.duration_ms!r}")
if not _is_int(self.num_chars) or self.num_chars < 0:
raise ValueError(f"num_chars must be a non-negative int, got {self.num_chars!r}")
if any(not isinstance(tag, str) or not tag for tag in self.tags):
raise ValueError(f"tags must be non-empty strings, got {self.tags!r}")
if self.emotion is not None and (not isinstance(self.emotion, str) or not self.emotion):
raise ValueError(f"emotion must be None or a non-empty string, got {self.emotion!r}")
@property
def chars_per_second(self) -> float:
return self.num_chars / (self.duration_ms / _MS_PER_SECOND)
@staticmethod
def from_mapping(
row: Mapping[str, object],
*,
duration_key: str = "duration_ms",
chars_key: str = "num_chars",
tags_key: str = "tags",
emotion_key: str = "emotion",
) -> SegmentStat:
"""Build from a manifest row, validating the types strictly.
Raises:
ValueError: if a key is missing, or a value has the wrong type or range.
"""
for key in (duration_key, chars_key, tags_key, emotion_key):
if key not in row:
raise ValueError(f"row has no column {key!r}")
duration, chars, tags, emotion = (
row[duration_key],
row[chars_key],
row[tags_key],
row[emotion_key],
)
if not isinstance(duration, int) or not isinstance(chars, int):
raise ValueError(f"{duration_key!r} and {chars_key!r} must be ints")
if not isinstance(tags, list | tuple) or any(not isinstance(t, str) for t in tags):
raise ValueError(f"{tags_key!r} must be a list of strings, got {tags!r}")
if emotion is not None and not isinstance(emotion, str):
raise ValueError(f"{emotion_key!r} must be a string or None, got {emotion!r}")
return SegmentStat(duration_ms=duration, num_chars=chars, tags=tuple(tags), emotion=emotion)
def _check_edges(bin_edges_ms: Sequence[int]) -> None:
if not bin_edges_ms:
raise ValueError("bin_edges_ms must not be empty")
if any(not _is_int(edge) or edge < 0 for edge in bin_edges_ms):
raise ValueError(f"bin edges must be non-negative ints, got {list(bin_edges_ms)!r}")
if any(b <= a for a, b in pairwise(bin_edges_ms)):
raise ValueError(f"bin edges must be strictly increasing, got {list(bin_edges_ms)!r}")
def duration_histogram(durations_ms: Iterable[int], bin_edges_ms: Sequence[int]) -> list[int]:
"""Histogram of durations over ``n`` strictly increasing edges.
Returns ``n + 1`` counts: index 0 is the underflow bin ``(-inf, e0)``, index ``k`` for
``1 <= k < n`` is ``[e(k-1), e(k))``, and index ``n`` is the overflow bin ``[e(n-1), inf)``.
Every duration is counted exactly once, so ``sum(result) == len(durations_ms)``.
Raises:
ValueError: if the edges are empty, negative or not strictly increasing, or a
duration is not a non-negative int.
"""
_check_edges(bin_edges_ms)
counts = [0] * (len(bin_edges_ms) + 1)
for duration in durations_ms:
if not _is_int(duration) or duration < 0:
raise ValueError(f"duration must be a non-negative int in ms, got {duration!r}")
counts[bisect_right(bin_edges_ms, duration)] += 1
return counts
def _percentile_name(percentile: float) -> str:
return f"p{percentile:g}"
@dataclass(frozen=True)
class StatsSummary:
"""Result of :func:`summarize`.
``duration_histogram`` has ``len(duration_bin_edges_ms) + 1`` entries (see
:func:`duration_histogram`). ``cps_percentiles`` maps names such as ``"p5"`` to
characters per second and is empty when there are no segments. Count mappings are
ordered most frequent first (ties alphabetically).
"""
num_segments: int
total_duration_ms: int
total_hours: float
duration_bin_edges_ms: tuple[int, ...]
duration_histogram: tuple[int, ...]
tag_counts: dict[str, int]
tag_segment_counts: dict[str, int]
num_segments_without_tags: int
emotion_counts: dict[str, int]
num_segments_without_emotion: int
cps_percentiles: dict[str, float]
def to_dict(self) -> dict[str, object]:
"""JSON-compatible dict (for ``metrics.json``)."""
return asdict(self)
def _ranked(counter: Mapping[str, int]) -> dict[str, int]:
return dict(sorted(counter.items(), key=lambda item: (-item[1], item[0])))
def summarize(
rows: Iterable[SegmentStat],
*,
duration_bin_edges_ms: Sequence[int],
percentiles: Sequence[float] = DEFAULT_CPS_PERCENTILES,
) -> StatsSummary:
"""Aggregate segment statistics.
Args:
rows: Segments to describe (consumed once).
duration_bin_edges_ms: Edges for the duration histogram.
percentiles: Percentiles in [0, 100] of the chars-per-second distribution
(linear interpolation, ``numpy.percentile`` default); no duplicates.
Raises:
ValueError: on invalid edges or percentiles.
"""
_check_edges(duration_bin_edges_ms)
if any(not (math.isfinite(p) and 0.0 <= p <= 100.0) for p in percentiles):
raise ValueError(f"percentiles must be in [0, 100], got {list(percentiles)!r}")
if len(set(percentiles)) != len(percentiles):
raise ValueError(f"duplicate percentiles: {list(percentiles)!r}")
durations: list[int] = []
rates: list[float] = []
tag_counts: Counter[str] = Counter()
tag_segment_counts: Counter[str] = Counter()
emotion_counts: Counter[str] = Counter()
without_tags = 0
without_emotion = 0
for row in rows:
durations.append(row.duration_ms)
rates.append(row.chars_per_second)
tag_counts.update(row.tags)
tag_segment_counts.update(set(row.tags))
if not row.tags:
without_tags += 1
if row.emotion is None:
without_emotion += 1
else:
emotion_counts[row.emotion] += 1
total_ms = sum(durations)
cps: dict[str, float] = {}
if rates:
values = np.percentile(np.asarray(rates, dtype=np.float64), list(percentiles))
cps = {_percentile_name(p): float(v) for p, v in zip(percentiles, values, strict=True)}
return StatsSummary(
num_segments=len(durations),
total_duration_ms=total_ms,
total_hours=total_ms / MS_PER_HOUR,
duration_bin_edges_ms=tuple(duration_bin_edges_ms),
duration_histogram=tuple(duration_histogram(durations, duration_bin_edges_ms)),
tag_counts=_ranked(tag_counts),
tag_segment_counts=_ranked(tag_segment_counts),
num_segments_without_tags=without_tags,
emotion_counts=_ranked(emotion_counts),
num_segments_without_emotion=without_emotion,
cps_percentiles=cps,
)
def _backtick_runs(text: str) -> list[int]:
"""Lengths of the maximal runs of backticks in ``text``."""
runs: list[int] = []
current = 0
for char in text:
if char == "`":
current += 1
elif current:
runs.append(current)
current = 0
if current:
runs.append(current)
return runs
def _code(text: str) -> str:
"""Inline-code span that survives pipes, angle brackets and backticks in ``text``.
The fence is one backtick longer than the longest backtick run inside the text.
"""
runs = _backtick_runs(text)
fence = "`" * ((max(runs) + 1) if runs else 1)
return f"{fence} {text} {fence}".replace("|", r"\|")
def _seconds(edge_ms: int) -> str:
return f"{edge_ms / _MS_PER_SECOND:g}"
def _bin_labels(edges_ms: Sequence[int]) -> list[str]:
labels = [f"< {_seconds(edges_ms[0])} s"]
labels += [f"[{_seconds(lo)}, {_seconds(hi)}) s" for lo, hi in pairwise(edges_ms)]
labels.append(f">= {_seconds(edges_ms[-1])} s")
return labels
def _share(count: int, total: int) -> str:
return f"{100.0 * count / total:.1f}%" if total else "-"
def render_stats_markdown(summary: StatsSummary, *, title: str = "Veri istatistikleri") -> str:
"""Render a summary as a Markdown report section (Turkish headings)."""
n = summary.num_segments
lines: list[str] = [f"## {title}", ""]
lines += [
f"- Segment sayısı: {n}",
f"- Toplam süre: {summary.total_hours:.2f} saat ({summary.total_duration_ms} ms)",
f"- Etiketsiz segment: {summary.num_segments_without_tags}",
f"- Duygu etiketsiz segment: {summary.num_segments_without_emotion}",
"",
"### Süre dağılımı",
"",
"| Aralık | Sayı | Oran |",
"|---|---:|---:|",
]
labels = _bin_labels(summary.duration_bin_edges_ms)
for label, count in zip(labels, summary.duration_histogram, strict=True):
lines.append(f"| {label} | {count} | {_share(count, n)} |")
lines += ["", "### Etiketler", ""]
if summary.tag_counts:
lines += ["| Etiket | Geçiş | Segment |", "|---|---:|---:|"]
for tag, count in summary.tag_counts.items():
lines.append(f"| {_code(tag)} | {count} | {summary.tag_segment_counts[tag]} |")
else:
lines.append("Etiket yok.")
lines += ["", "### Duygular", ""]
if summary.emotion_counts:
lines += ["| Duygu | Segment | Oran |", "|---|---:|---:|"]
for emotion, count in summary.emotion_counts.items():
lines.append(f"| {_code(emotion)} | {count} | {_share(count, n)} |")
else:
lines.append("Duygu etiketi yok.")
lines += ["", "### Harf/saniye", ""]
if summary.cps_percentiles:
lines += ["| Yüzdelik | Harf/sn |", "|---|---:|"]
for name, value in summary.cps_percentiles.items():
lines.append(f"| {name} | {value:.2f} |")
else:
lines.append("Veri yok.")
return "\n".join(lines) + "\n"
|