loss_chart.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. # -*- coding: utf-8 -*-
  2. """
  3. Braille-basiertes Loss-Chart Widget.
  4. Zeigt Training- und Validation-Loss als Braille-Grafik
  5. mit Y-Achse, Legende und optionalem Downsampling.
  6. """
  7. from __future__ import annotations
  8. from textual.widget import Widget
  9. from textual.app import RenderResult
  10. from rich.text import Text
  11. # Braille-Zeichen: 2x4 Punkte pro Zeichen
  12. # Bit-Layout:
  13. # 0 3
  14. # 1 4
  15. # 2 5
  16. # 6 7
  17. BRAILLE_BASE = 0x2800
  18. BRAILLE_DOTS = [
  19. [0x01, 0x08], # Zeile 0: Bit 0, Bit 3
  20. [0x02, 0x10], # Zeile 1: Bit 1, Bit 4
  21. [0x04, 0x20], # Zeile 2: Bit 2, Bit 5
  22. [0x40, 0x80], # Zeile 3: Bit 6, Bit 7
  23. ]
  24. class LossChart(Widget):
  25. """
  26. Braille-basiertes Chart fuer Training/Validation Loss.
  27. Zeigt zwei Linien (Train=cyan, Val=magenta) mit
  28. Y-Achse (5 Stufen) und Legende.
  29. """
  30. DEFAULT_CSS = """
  31. LossChart {
  32. height: 14;
  33. margin: 0 1;
  34. }
  35. """
  36. def __init__(self, **kwargs) -> None:
  37. super().__init__(**kwargs)
  38. self._train_loss: list[float] = []
  39. self._val_loss: list[float] = []
  40. self._chart_height: int = 12
  41. def update_data(
  42. self,
  43. train_loss: list[float],
  44. val_loss: list[float] | None = None,
  45. ) -> None:
  46. """Aktualisiert die Chart-Daten."""
  47. self._train_loss = list(train_loss)
  48. self._val_loss = list(val_loss) if val_loss else []
  49. self.refresh()
  50. def render(self) -> RenderResult:
  51. """Rendert das Braille-Chart."""
  52. if not self._train_loss:
  53. return Text("Keine Daten", style="dim")
  54. # Daten vorbereiten (mit Downsampling bei >200 Punkten)
  55. train = self._downsample(self._train_loss)
  56. val = self._downsample(self._val_loss) if self._val_loss else []
  57. # Dimensionen berechnen
  58. chart_rows = self._chart_height
  59. # Braille: 4 Unterzeilen pro Textzeile, 2 Spalten pro Zeichen
  60. pixel_rows = chart_rows * 4
  61. all_values = train + val
  62. y_min = min(all_values)
  63. y_max = max(all_values)
  64. if y_max == y_min:
  65. y_max = y_min + 1.0
  66. # Verfuegbare Breite (ohne Y-Achse Label, ca. 8 Zeichen)
  67. available_width = max(self.size.width - 10, 20)
  68. pixel_cols = available_width * 2
  69. # Daten auf Pixel-Koordinaten abbilden
  70. def to_pixel_y(value: float) -> int:
  71. normalized = (value - y_min) / (y_max - y_min)
  72. return max(0, min(pixel_rows - 1, int((1.0 - normalized) * (pixel_rows - 1))))
  73. def to_pixel_x(index: int, total: int) -> int:
  74. if total <= 1:
  75. return 0
  76. return int(index / (total - 1) * (pixel_cols - 1))
  77. # Grid erstellen (chart_rows x available_width)
  78. # Jede Zelle speichert: (train_dot, val_dot)
  79. grid_train = [[False] * pixel_cols for _ in range(pixel_rows)]
  80. grid_val = [[False] * pixel_cols for _ in range(pixel_rows)]
  81. # Punkte setzen
  82. for i, v in enumerate(train):
  83. px = to_pixel_x(i, len(train))
  84. py = to_pixel_y(v)
  85. if 0 <= px < pixel_cols and 0 <= py < pixel_rows:
  86. grid_train[py][px] = True
  87. # Verbindungslinie zum naechsten Punkt
  88. if i + 1 < len(train):
  89. next_px = to_pixel_x(i + 1, len(train))
  90. next_py = to_pixel_y(train[i + 1])
  91. self._draw_line(grid_train, px, py, next_px, next_py, pixel_cols, pixel_rows)
  92. for i, v in enumerate(val):
  93. px = to_pixel_x(i, len(val))
  94. py = to_pixel_y(v)
  95. if 0 <= px < pixel_cols and 0 <= py < pixel_rows:
  96. grid_val[py][px] = True
  97. if i + 1 < len(val):
  98. next_px = to_pixel_x(i + 1, len(val))
  99. next_py = to_pixel_y(val[i + 1])
  100. self._draw_line(grid_val, px, py, next_px, next_py, pixel_cols, pixel_rows)
  101. # Braille-Zeichen generieren
  102. output = Text()
  103. # Y-Achse Labels (5 Stufen)
  104. y_labels = []
  105. for i in range(5):
  106. val_at = y_max - i * (y_max - y_min) / 4
  107. y_labels.append(f"{val_at:.4f}"[:7].rjust(7))
  108. for row in range(chart_rows):
  109. # Y-Achse Label (alle 3 Zeilen)
  110. if chart_rows >= 4:
  111. label_idx = int(row / (chart_rows - 1) * 4) if chart_rows > 1 else 0
  112. label_idx = min(label_idx, 4)
  113. else:
  114. label_idx = min(row, 4)
  115. if row == 0 or row == chart_rows - 1 or (chart_rows >= 6 and row == chart_rows // 2):
  116. output.append(y_labels[label_idx] + " |", style="dim")
  117. else:
  118. output.append(" |", style="dim")
  119. # Braille-Zeichen fuer diese Zeile
  120. for col in range(available_width):
  121. char_code = BRAILLE_BASE
  122. has_train = False
  123. has_val = False
  124. for sub_row in range(4):
  125. for sub_col in range(2):
  126. py = row * 4 + sub_row
  127. px = col * 2 + sub_col
  128. if py < pixel_rows and px < pixel_cols:
  129. if grid_train[py][px]:
  130. char_code |= BRAILLE_DOTS[sub_row][sub_col]
  131. has_train = True
  132. if grid_val[py][px]:
  133. char_code |= BRAILLE_DOTS[sub_row][sub_col]
  134. has_val = True
  135. braille = chr(char_code)
  136. if has_train and has_val:
  137. output.append(braille, style="bold yellow")
  138. elif has_train:
  139. output.append(braille, style="cyan")
  140. elif has_val:
  141. output.append(braille, style="magenta")
  142. else:
  143. output.append(braille, style="dim")
  144. output.append("\n")
  145. # Legende
  146. epoch_count = max(len(self._train_loss), len(self._val_loss))
  147. output.append(" ")
  148. output.append(" \u2588 Train-Loss ", style="cyan")
  149. if self._val_loss:
  150. output.append(" \u2588 Val-Loss ", style="magenta")
  151. output.append(f" Epochen: {epoch_count}", style="dim")
  152. return output
  153. @staticmethod
  154. def _draw_line(
  155. grid: list[list[bool]],
  156. x0: int, y0: int, x1: int, y1: int,
  157. max_x: int, max_y: int,
  158. ) -> None:
  159. """Zeichnet eine Linie mit Bresenham-Algorithmus."""
  160. dx = abs(x1 - x0)
  161. dy = abs(y1 - y0)
  162. sx = 1 if x0 < x1 else -1
  163. sy = 1 if y0 < y1 else -1
  164. err = dx - dy
  165. while True:
  166. if 0 <= x0 < max_x and 0 <= y0 < max_y:
  167. grid[y0][x0] = True
  168. if x0 == x1 and y0 == y1:
  169. break
  170. e2 = 2 * err
  171. if e2 > -dy:
  172. err -= dy
  173. x0 += sx
  174. if e2 < dx:
  175. err += dx
  176. y0 += sy
  177. @staticmethod
  178. def _downsample(data: list[float], max_points: int = 200) -> list[float]:
  179. """Reduziert Datenpunkte durch Mittelwert-Aggregation."""
  180. if len(data) <= max_points:
  181. return list(data)
  182. chunk_size = len(data) / max_points
  183. result = []
  184. for i in range(max_points):
  185. start = int(i * chunk_size)
  186. end = int((i + 1) * chunk_size)
  187. chunk = data[start:end]
  188. if chunk:
  189. result.append(sum(chunk) / len(chunk))
  190. return result