viseda 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
viseda/text/eda.py ADDED
@@ -0,0 +1,1613 @@
1
+ """
2
+ viseda.text.eda
3
+ ===============
4
+ Comprehensive exploratory data analysis for text and NLP datasets.
5
+
6
+ The module provides one unified class, :class:`TextEDA`, for analysing a
7
+ single text document, a list of documents, structured text files, or a full
8
+ directory of text data.
9
+
10
+ Supported inputs
11
+ ----------------
12
+ * Plain text: ``.txt``, ``.text``, ``.md``, ``.rst``, ``.log``
13
+ * Web text: ``.html``, ``.htm`` (tags, script, and style content removed)
14
+ * Delimited data: ``.csv``, ``.tsv``
15
+ * Structured data: ``.json``, ``.jsonl``, ``.ndjson``
16
+ * In-memory strings through :meth:`TextEDA.load_texts`
17
+
18
+ Core analyses
19
+ -------------
20
+ * Inventory, encoding, labels, formats, corrupt files
21
+ * Character, word, sentence, paragraph, and line statistics
22
+ * Vocabulary size, lexical diversity, hapax ratio, token-length statistics
23
+ * Stopword, punctuation, digit, URL, email, hashtag, mention, emoji, and
24
+ non-ASCII statistics
25
+ * Readability estimates (Flesch Reading Ease and Flesch-Kincaid grade)
26
+ * Dominant writing-script distribution
27
+ * Exact duplicate detection and TF-IDF document-distance analysis
28
+ * Word and n-gram frequencies
29
+ * Dataset and single-document dashboards
30
+ * Styled, self-contained HTML reports
31
+
32
+ The implementation intentionally uses a lightweight regex tokenizer by
33
+ default. It does not silently depend on spaCy, NLTK, or transformer models.
34
+ Optional scikit-learn support is used for TF-IDF distances when available;
35
+ a NumPy fallback is provided.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import csv
41
+ import hashlib
42
+ import html
43
+ import json
44
+ import math
45
+ import re
46
+ import statistics
47
+ import warnings
48
+ from collections import Counter, defaultdict
49
+ from html.parser import HTMLParser
50
+ from pathlib import Path
51
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
52
+
53
+ import numpy as np
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Lazy plotting imports
58
+ # ---------------------------------------------------------------------------
59
+ def _plt():
60
+ import matplotlib.pyplot as plt
61
+ return plt
62
+
63
+
64
+ def _mpl():
65
+ import matplotlib as mpl
66
+ return mpl
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Constants and regexes
71
+ # ---------------------------------------------------------------------------
72
+ TOKEN_RE = re.compile(
73
+ r"[^\W\d_]+(?:['’\-][^\W\d_]+)*|\d+(?:[.,]\d+)*",
74
+ flags=re.UNICODE,
75
+ )
76
+ WORD_RE = re.compile(r"[^\W\d_]+(?:['’\-][^\W\d_]+)*", flags=re.UNICODE)
77
+ SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])(?:[\"'’”)]*)\s+|\n+")
78
+ URL_RE = re.compile(r"\b(?:https?://|www\.)[^\s<>()]+", flags=re.IGNORECASE)
79
+ EMAIL_RE = re.compile(r"\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b", flags=re.IGNORECASE)
80
+ HASHTAG_RE = re.compile(r"(?<!\w)#[\w_]+", flags=re.UNICODE)
81
+ MENTION_RE = re.compile(r"(?<!\w)@[\w_]+", flags=re.UNICODE)
82
+ WHITESPACE_RE = re.compile(r"\s+")
83
+ EMOJI_RE = re.compile(
84
+ "["
85
+ "\U0001F300-\U0001F5FF"
86
+ "\U0001F600-\U0001F64F"
87
+ "\U0001F680-\U0001F6FF"
88
+ "\U0001F700-\U0001F77F"
89
+ "\U0001F780-\U0001F7FF"
90
+ "\U0001F800-\U0001F8FF"
91
+ "\U0001F900-\U0001F9FF"
92
+ "\U0001FA00-\U0001FAFF"
93
+ "\u2600-\u26FF"
94
+ "\u2700-\u27BF"
95
+ "]",
96
+ flags=re.UNICODE,
97
+ )
98
+
99
+ DEFAULT_TEXT_FIELDS = (
100
+ "text", "content", "document", "sentence", "review", "comment",
101
+ "body", "description", "message", "abstract", "article", "question",
102
+ )
103
+ DEFAULT_LABEL_FIELDS = (
104
+ "label", "class", "category", "target", "sentiment", "topic", "intent",
105
+ )
106
+
107
+ # Compact, built-in English stopword list. The user can replace it.
108
+ DEFAULT_STOPWORDS = frozenset({
109
+ "a", "about", "above", "after", "again", "against", "all", "am", "an",
110
+ "and", "any", "are", "as", "at", "be", "because", "been", "before",
111
+ "being", "below", "between", "both", "but", "by", "can", "could", "did",
112
+ "do", "does", "doing", "down", "during", "each", "few", "for", "from",
113
+ "further", "had", "has", "have", "having", "he", "her", "here", "hers",
114
+ "herself", "him", "himself", "his", "how", "i", "if", "in", "into",
115
+ "is", "it", "its", "itself", "just", "me", "more", "most", "my",
116
+ "myself", "no", "nor", "not", "now", "of", "off", "on", "once",
117
+ "only", "or", "other", "our", "ours", "ourselves", "out", "over", "own",
118
+ "same", "she", "should", "so", "some", "such", "than", "that", "the",
119
+ "their", "theirs", "them", "themselves", "then", "there", "these", "they",
120
+ "this", "those", "through", "to", "too", "under", "until", "up", "very",
121
+ "was", "we", "were", "what", "when", "where", "which", "while", "who",
122
+ "whom", "why", "will", "with", "would", "you", "your", "yours",
123
+ "yourself", "yourselves",
124
+ })
125
+
126
+ SCRIPT_RANGES = {
127
+ "Latin": ((0x0041, 0x024F), (0x1E00, 0x1EFF)),
128
+ "Cyrillic": ((0x0400, 0x052F),),
129
+ "Greek": ((0x0370, 0x03FF),),
130
+ "Arabic": ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF)),
131
+ "Hebrew": ((0x0590, 0x05FF),),
132
+ "Devanagari": ((0x0900, 0x097F),),
133
+ "Bengali": ((0x0980, 0x09FF),),
134
+ "Thai": ((0x0E00, 0x0E7F),),
135
+ "CJK": ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF)),
136
+ "Hiragana": ((0x3040, 0x309F),),
137
+ "Katakana": ((0x30A0, 0x30FF),),
138
+ "Hangul": ((0xAC00, 0xD7AF), (0x1100, 0x11FF)),
139
+ }
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Helpers
144
+ # ---------------------------------------------------------------------------
145
+ def _stat_dict(values: Union[Sequence[float], np.ndarray]) -> Dict[str, Any]:
146
+ arr = np.asarray(values, dtype=float)
147
+ arr = arr[np.isfinite(arr)]
148
+ if arr.size == 0:
149
+ return {
150
+ "count": 0, "mean": None, "std": None, "min": None,
151
+ "p25": None, "median": None, "p75": None, "max": None,
152
+ }
153
+ return {
154
+ "count": int(arr.size),
155
+ "mean": float(arr.mean()),
156
+ "std": float(arr.std()),
157
+ "min": float(arr.min()),
158
+ "p25": float(np.percentile(arr, 25)),
159
+ "median": float(np.median(arr)),
160
+ "p75": float(np.percentile(arr, 75)),
161
+ "max": float(arr.max()),
162
+ }
163
+
164
+
165
+ def _safe_ratio(num: float, den: float) -> float:
166
+ return float(num / den) if den else 0.0
167
+
168
+
169
+ def _normalise_whitespace(text: str) -> str:
170
+ return WHITESPACE_RE.sub(" ", text).strip()
171
+
172
+
173
+ def _normalised_hash(text: str) -> str:
174
+ normal = _normalise_whitespace(text).casefold()
175
+ return hashlib.sha256(normal.encode("utf-8", errors="ignore")).hexdigest()
176
+
177
+
178
+ def _tokenise(text: str, lowercase: bool = True, min_length: int = 1) -> List[str]:
179
+ toks = TOKEN_RE.findall(text)
180
+ if lowercase:
181
+ toks = [t.casefold() for t in toks]
182
+ if min_length > 1:
183
+ toks = [t for t in toks if len(t) >= min_length or t[0].isdigit()]
184
+ return toks
185
+
186
+
187
+ def _sentences(text: str) -> List[str]:
188
+ return [s.strip() for s in SENTENCE_SPLIT_RE.split(text) if s.strip()]
189
+
190
+
191
+ def _paragraphs(text: str) -> List[str]:
192
+ return [p.strip() for p in re.split(r"\n\s*\n+", text) if p.strip()]
193
+
194
+
195
+ def _count_syllables(word: str) -> int:
196
+ """A lightweight English syllable heuristic for readability estimates."""
197
+ w = re.sub(r"[^a-z]", "", word.casefold())
198
+ if not w:
199
+ return 0
200
+ if len(w) <= 3:
201
+ return 1
202
+ groups = re.findall(r"[aeiouy]+", w)
203
+ count = len(groups)
204
+ if w.endswith("e") and not w.endswith(("le", "ye")) and count > 1:
205
+ count -= 1
206
+ if w.endswith("es") and len(w) > 4 and count > 1:
207
+ count -= 1
208
+ return max(1, count)
209
+
210
+
211
+ def _readability(words: Sequence[str], sentence_count: int) -> Tuple[Optional[float], Optional[float]]:
212
+ alpha = [w for w in words if WORD_RE.fullmatch(w)]
213
+ if not alpha or sentence_count <= 0:
214
+ return None, None
215
+ syllables = sum(_count_syllables(w) for w in alpha)
216
+ n_words = len(alpha)
217
+ flesch = 206.835 - 1.015 * (n_words / sentence_count) - 84.6 * (syllables / n_words)
218
+ grade = 0.39 * (n_words / sentence_count) + 11.8 * (syllables / n_words) - 15.59
219
+ return float(flesch), float(grade)
220
+
221
+
222
+ def _script_distribution(text: str) -> Tuple[Dict[str, float], str]:
223
+ counts: Counter[str] = Counter()
224
+ total = 0
225
+ for ch in text:
226
+ if not ch.isalpha():
227
+ continue
228
+ cp = ord(ch)
229
+ total += 1
230
+ matched = False
231
+ for name, ranges in SCRIPT_RANGES.items():
232
+ if any(lo <= cp <= hi for lo, hi in ranges):
233
+ counts[name] += 1
234
+ matched = True
235
+ break
236
+ if not matched:
237
+ counts["Other"] += 1
238
+ if total == 0:
239
+ return {}, "None"
240
+ dist = {k: float(v / total) for k, v in counts.items()}
241
+ dominant = max(counts, key=counts.get)
242
+ return dist, dominant
243
+
244
+
245
+ def _ngrams(tokens: Sequence[str], n: int) -> Iterable[Tuple[str, ...]]:
246
+ if n <= 0:
247
+ raise ValueError("n must be positive")
248
+ for i in range(len(tokens) - n + 1):
249
+ yield tuple(tokens[i:i + n])
250
+
251
+
252
+ class _HTMLTextExtractor(HTMLParser):
253
+ def __init__(self) -> None:
254
+ super().__init__()
255
+ self.parts: List[str] = []
256
+ self._ignored_depth = 0
257
+
258
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
259
+ if tag.lower() in {"script", "style", "noscript", "svg"}:
260
+ self._ignored_depth += 1
261
+ elif tag.lower() in {"p", "br", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6"}:
262
+ self.parts.append("\n")
263
+
264
+ def handle_endtag(self, tag: str) -> None:
265
+ if tag.lower() in {"script", "style", "noscript", "svg"} and self._ignored_depth:
266
+ self._ignored_depth -= 1
267
+ elif tag.lower() in {"p", "div", "li"}:
268
+ self.parts.append("\n")
269
+
270
+ def handle_data(self, data: str) -> None:
271
+ if self._ignored_depth == 0:
272
+ self.parts.append(data)
273
+
274
+ def text(self) -> str:
275
+ raw = html.unescape(" ".join(self.parts))
276
+ lines = [WHITESPACE_RE.sub(" ", line).strip() for line in raw.splitlines()]
277
+ return "\n".join(line for line in lines if line)
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Per-document record
282
+ # ---------------------------------------------------------------------------
283
+ class TextRecord:
284
+ """Container for per-document statistics."""
285
+
286
+ __slots__ = (
287
+ # identity/status
288
+ "path", "name", "label", "file_ext", "source_index", "file_size_kb",
289
+ "encoding", "is_corrupt", "error", "normalised_hash",
290
+ # counts
291
+ "char_count", "byte_count", "word_count", "alpha_word_count",
292
+ "numeric_token_count", "unique_word_count", "sentence_count",
293
+ "paragraph_count", "line_count", "nonempty_line_count",
294
+ # length/distribution
295
+ "avg_word_length", "median_word_length", "word_length_std",
296
+ "avg_sentence_length", "median_sentence_length", "sentence_length_std",
297
+ "avg_paragraph_words", "max_sentence_words",
298
+ # lexical
299
+ "lexical_diversity", "hapax_count", "hapax_ratio", "stopword_count",
300
+ "stopword_ratio", "top_tokens", "top_bigrams", "top_trigrams",
301
+ # character/symbol
302
+ "punctuation_count", "punctuation_rate", "digit_count", "digit_rate",
303
+ "uppercase_count", "uppercase_rate", "whitespace_count", "whitespace_rate",
304
+ "non_ascii_count", "non_ascii_rate", "emoji_count", "url_count",
305
+ "email_count", "hashtag_count", "mention_count",
306
+ # quality/style
307
+ "repeated_line_fraction", "empty", "very_short", "very_long",
308
+ "readability_flesch", "readability_grade", "script_distribution",
309
+ "dominant_script", "preview",
310
+ )
311
+
312
+ def __init__(self) -> None:
313
+ for field in self.__slots__:
314
+ setattr(self, field, None)
315
+ self.is_corrupt = False
316
+ self.error = None
317
+ self.empty = False
318
+ self.very_short = False
319
+ self.very_long = False
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Main class
324
+ # ---------------------------------------------------------------------------
325
+ class TextEDA:
326
+ """
327
+ Comprehensive EDA for text and NLP datasets.
328
+
329
+ Parameters
330
+ ----------
331
+ verbose:
332
+ Print progress information.
333
+ max_documents:
334
+ Analyse at most this many documents after loading/expansion.
335
+ lowercase:
336
+ Lowercase/casefold tokens for vocabulary and frequency analyses.
337
+ min_token_length:
338
+ Minimum token length used for vocabulary statistics.
339
+ stopwords:
340
+ Optional iterable replacing the built-in English stopword list.
341
+ short_document_words:
342
+ Documents below this word count are flagged as very short.
343
+ long_document_words:
344
+ Documents above this word count are flagged as very long.
345
+ top_n:
346
+ Number of top unigrams/bigrams/trigrams retained in each record.
347
+ encoding:
348
+ Preferred file encoding. Automatic fallbacks are tried after it.
349
+ random_seed:
350
+ Seed used for reproducible sampling in plots and distance fallback.
351
+ """
352
+
353
+ SUPPORTED_EXTS = {
354
+ ".txt", ".text", ".md", ".rst", ".log", ".html", ".htm",
355
+ ".csv", ".tsv", ".json", ".jsonl", ".ndjson",
356
+ }
357
+
358
+ def __init__(
359
+ self,
360
+ verbose: bool = True,
361
+ max_documents: Optional[int] = None,
362
+ lowercase: bool = True,
363
+ min_token_length: int = 1,
364
+ stopwords: Optional[Iterable[str]] = None,
365
+ short_document_words: int = 5,
366
+ long_document_words: int = 1_000,
367
+ top_n: int = 30,
368
+ encoding: Optional[str] = None,
369
+ random_seed: int = 0,
370
+ ) -> None:
371
+ self.verbose = verbose
372
+ self.max_documents = max_documents
373
+ self.lowercase = lowercase
374
+ self.min_token_length = max(1, int(min_token_length))
375
+ self.stopwords = frozenset(
376
+ s.casefold() if lowercase else s for s in (stopwords or DEFAULT_STOPWORDS)
377
+ )
378
+ self.short_document_words = int(short_document_words)
379
+ self.long_document_words = int(long_document_words)
380
+ self.top_n = int(top_n)
381
+ self.encoding = encoding
382
+ self.random_seed = int(random_seed)
383
+
384
+ self._records: List[TextRecord] = []
385
+ self._texts: Dict[str, str] = {}
386
+ self._loaded = False
387
+ self._results: Dict[str, Any] = {}
388
+
389
+ # ------------------------------------------------------------------
390
+ # Loading
391
+ # ------------------------------------------------------------------
392
+ def load(
393
+ self,
394
+ source: Union[str, Path, Sequence[Union[str, Path]]],
395
+ labels: Optional[Mapping[str, str]] = None,
396
+ label_from_parent: bool = False,
397
+ recursive: bool = True,
398
+ text_field: Optional[str] = None,
399
+ label_field: Optional[str] = None,
400
+ ) -> "TextEDA":
401
+ """Load one file, a list of files, or a directory of text data."""
402
+ paths = self._resolve_paths(source, recursive=recursive)
403
+ label_map = {str(Path(k).resolve()): str(v) for k, v in (labels or {}).items()}
404
+ self._records = []
405
+ self._texts = {}
406
+ self._results = {}
407
+
408
+ self._log(f"Found {len(paths)} text file(s) — extracting documents …")
409
+ document_counter = 0
410
+
411
+ for file_idx, path in enumerate(paths):
412
+ if self.max_documents is not None and document_counter >= self.max_documents:
413
+ break
414
+ if self.verbose:
415
+ self._log(f" [{file_idx + 1}/{len(paths)}] {path.name}")
416
+
417
+ base_label = label_map.get(str(path.resolve()))
418
+ if base_label is None and label_from_parent:
419
+ base_label = path.parent.name
420
+
421
+ try:
422
+ docs, detected_encoding = self._read_documents(
423
+ path, text_field=text_field, label_field=label_field
424
+ )
425
+ except Exception as exc:
426
+ rec = TextRecord()
427
+ rec.path = str(path)
428
+ rec.name = path.name
429
+ rec.label = base_label
430
+ rec.file_ext = path.suffix.lower()
431
+ rec.file_size_kb = path.stat().st_size / 1024 if path.exists() else None
432
+ rec.is_corrupt = True
433
+ rec.error = str(exc)
434
+ self._records.append(rec)
435
+ self._log(f" ✗ {path.name}: {exc}")
436
+ continue
437
+
438
+ for source_index, text, structured_label in docs:
439
+ if self.max_documents is not None and document_counter >= self.max_documents:
440
+ break
441
+ rec = TextRecord()
442
+ rec.path = str(path)
443
+ rec.name = path.name if source_index is None else f"{path.name}#{source_index}"
444
+ rec.label = structured_label if structured_label is not None else base_label
445
+ rec.file_ext = path.suffix.lower()
446
+ rec.source_index = source_index
447
+ rec.file_size_kb = path.stat().st_size / 1024 if path.exists() else None
448
+ rec.encoding = detected_encoding
449
+ key = self._record_key(rec, len(self._records))
450
+ try:
451
+ self._fill_stats(rec, str(text))
452
+ self._texts[key] = str(text)
453
+ except Exception as exc:
454
+ rec.is_corrupt = True
455
+ rec.error = str(exc)
456
+ self._records.append(rec)
457
+ document_counter += 1
458
+
459
+ self._loaded = True
460
+ bad = sum(r.is_corrupt for r in self._records)
461
+ self._log(f"Done. {len(self._records)} document(s) loaded ({bad} corrupt).")
462
+ return self
463
+
464
+ def load_texts(
465
+ self,
466
+ texts: Sequence[str],
467
+ labels: Optional[Sequence[str]] = None,
468
+ names: Optional[Sequence[str]] = None,
469
+ ) -> "TextEDA":
470
+ """Load in-memory text strings directly."""
471
+ self._records = []
472
+ self._texts = {}
473
+ self._results = {}
474
+ n = len(texts) if self.max_documents is None else min(len(texts), self.max_documents)
475
+ self._log(f"Loading {n} in-memory text document(s) …")
476
+ for i, text in enumerate(texts[:n]):
477
+ rec = TextRecord()
478
+ rec.path = names[i] if names and i < len(names) else f"<text_{i}>"
479
+ rec.name = rec.path
480
+ rec.file_ext = "text"
481
+ rec.source_index = i
482
+ rec.label = labels[i] if labels and i < len(labels) else None
483
+ rec.encoding = "unicode"
484
+ key = self._record_key(rec, i)
485
+ try:
486
+ self._fill_stats(rec, str(text))
487
+ self._texts[key] = str(text)
488
+ except Exception as exc:
489
+ rec.is_corrupt = True
490
+ rec.error = str(exc)
491
+ self._records.append(rec)
492
+ self._loaded = True
493
+ return self
494
+
495
+ # ------------------------------------------------------------------
496
+ # Public access and summaries
497
+ # ------------------------------------------------------------------
498
+ def get_record(self, index: int = 0) -> TextRecord:
499
+ self._check_loaded()
500
+ return self._records[index]
501
+
502
+ def get_text(self, index: int = 0) -> str:
503
+ self._check_loaded()
504
+ rec = self._records[index]
505
+ key = self._record_key(rec, index)
506
+ if key not in self._texts:
507
+ raise KeyError(f"Text content is unavailable for record {index}.")
508
+ return self._texts[key]
509
+
510
+ def vocabulary(
511
+ self,
512
+ min_frequency: int = 1,
513
+ top_n: Optional[int] = None,
514
+ exclude_stopwords: bool = False,
515
+ ) -> Dict[str, int]:
516
+ """Return the dataset vocabulary ordered by descending frequency."""
517
+ self._check_loaded()
518
+ counts = self._dataset_token_counts(exclude_stopwords=exclude_stopwords)
519
+ items = [(t, c) for t, c in counts.items() if c >= min_frequency]
520
+ items.sort(key=lambda x: (-x[1], x[0]))
521
+ if top_n is not None:
522
+ items = items[:top_n]
523
+ return dict(items)
524
+
525
+ def summary(self) -> Dict[str, Any]:
526
+ """Return a nested summary dictionary for all loaded documents."""
527
+ self._check_loaded()
528
+ valid = [r for r in self._records if not r.is_corrupt]
529
+ corrupt = [r for r in self._records if r.is_corrupt]
530
+ if not valid:
531
+ result = {
532
+ "inventory": {
533
+ "total_documents": len(self._records),
534
+ "valid_documents": 0,
535
+ "corrupt_documents": len(corrupt),
536
+ "corrupt_paths": [r.path for r in corrupt],
537
+ "format_distribution": {},
538
+ "label_distribution": None,
539
+ },
540
+ "error": "No valid text documents found.",
541
+ }
542
+ self._results["summary"] = result
543
+ return result
544
+
545
+ def arr(attr: str) -> np.ndarray:
546
+ vals = [getattr(r, attr) for r in valid if getattr(r, attr) is not None]
547
+ return np.asarray(vals, dtype=float) if vals else np.asarray([], dtype=float)
548
+
549
+ label_values = [r.label for r in valid if r.label is not None]
550
+ label_dist = dict(Counter(label_values)) if label_values else None
551
+ format_dist = dict(Counter(r.file_ext for r in valid))
552
+ encoding_dist = dict(Counter(r.encoding for r in valid if r.encoding))
553
+ script_dist = dict(Counter(r.dominant_script for r in valid if r.dominant_script))
554
+
555
+ token_counts = self._dataset_token_counts(exclude_stopwords=False)
556
+ content_counts = self._dataset_token_counts(exclude_stopwords=True)
557
+ total_tokens = int(sum(token_counts.values()))
558
+ unique_tokens = len(token_counts)
559
+ hapax = sum(1 for c in token_counts.values() if c == 1)
560
+ top_words = token_counts.most_common(50)
561
+ top_content_words = content_counts.most_common(50)
562
+ bigram_counts = self._dataset_ngram_counts(2, exclude_stopwords=False)
563
+ trigram_counts = self._dataset_ngram_counts(3, exclude_stopwords=False)
564
+
565
+ hash_groups: Dict[str, List[str]] = defaultdict(list)
566
+ for r in valid:
567
+ hash_groups[r.normalised_hash].append(r.name or r.path)
568
+ duplicate_groups = [names for names in hash_groups.values() if len(names) > 1]
569
+ duplicate_docs = sum(len(g) for g in duplicate_groups)
570
+
571
+ result = {
572
+ "inventory": {
573
+ "total_documents": len(self._records),
574
+ "valid_documents": len(valid),
575
+ "corrupt_documents": len(corrupt),
576
+ "corrupt_paths": [r.path for r in corrupt],
577
+ "format_distribution": format_dist,
578
+ "encoding_distribution": encoding_dist,
579
+ "label_distribution": label_dist,
580
+ "dominant_script_distribution": script_dist,
581
+ },
582
+ "length": {
583
+ "characters": _stat_dict(arr("char_count")),
584
+ "bytes": _stat_dict(arr("byte_count")),
585
+ "words": _stat_dict(arr("word_count")),
586
+ "sentences": _stat_dict(arr("sentence_count")),
587
+ "paragraphs": _stat_dict(arr("paragraph_count")),
588
+ "lines": _stat_dict(arr("line_count")),
589
+ "avg_word_length": _stat_dict(arr("avg_word_length")),
590
+ "avg_sentence_length": _stat_dict(arr("avg_sentence_length")),
591
+ "sentence_length_std": _stat_dict(arr("sentence_length_std")),
592
+ },
593
+ "lexical": {
594
+ "dataset_total_tokens": total_tokens,
595
+ "dataset_unique_tokens": unique_tokens,
596
+ "dataset_type_token_ratio": _safe_ratio(unique_tokens, total_tokens),
597
+ "dataset_hapax_count": hapax,
598
+ "dataset_hapax_ratio": _safe_ratio(hapax, unique_tokens),
599
+ "document_lexical_diversity": _stat_dict(arr("lexical_diversity")),
600
+ "document_hapax_ratio": _stat_dict(arr("hapax_ratio")),
601
+ "stopword_ratio": _stat_dict(arr("stopword_ratio")),
602
+ "top_words": top_words,
603
+ "top_content_words": top_content_words,
604
+ "top_bigrams": [(" ".join(k), v) for k, v in bigram_counts.most_common(50)],
605
+ "top_trigrams": [(" ".join(k), v) for k, v in trigram_counts.most_common(50)],
606
+ },
607
+ "symbols": {
608
+ "punctuation_rate": _stat_dict(arr("punctuation_rate")),
609
+ "digit_rate": _stat_dict(arr("digit_rate")),
610
+ "uppercase_rate": _stat_dict(arr("uppercase_rate")),
611
+ "whitespace_rate": _stat_dict(arr("whitespace_rate")),
612
+ "non_ascii_rate": _stat_dict(arr("non_ascii_rate")),
613
+ "emoji_count": _stat_dict(arr("emoji_count")),
614
+ "url_count": _stat_dict(arr("url_count")),
615
+ "email_count": _stat_dict(arr("email_count")),
616
+ "hashtag_count": _stat_dict(arr("hashtag_count")),
617
+ "mention_count": _stat_dict(arr("mention_count")),
618
+ },
619
+ "readability": {
620
+ "flesch_reading_ease": _stat_dict(arr("readability_flesch")),
621
+ "flesch_kincaid_grade": _stat_dict(arr("readability_grade")),
622
+ },
623
+ "quality": {
624
+ "empty_documents": int(sum(bool(r.empty) for r in valid)),
625
+ "very_short_documents": int(sum(bool(r.very_short) for r in valid)),
626
+ "very_long_documents": int(sum(bool(r.very_long) for r in valid)),
627
+ "repeated_line_fraction": _stat_dict(arr("repeated_line_fraction")),
628
+ "exact_duplicate_groups": duplicate_groups,
629
+ "documents_in_duplicate_groups": duplicate_docs,
630
+ "exact_duplicate_fraction": _safe_ratio(duplicate_docs, len(valid)),
631
+ },
632
+ "labels": {
633
+ "label_distribution": label_dist,
634
+ "class_imbalance_ratio": self._imbalance_ratio(label_dist),
635
+ },
636
+ }
637
+ self._results["summary"] = result
638
+ return result
639
+
640
+ def pairwise_document_distances(
641
+ self,
642
+ max_documents: int = 50,
643
+ max_features: int = 5_000,
644
+ exclude_stopwords: bool = True,
645
+ ) -> Tuple[np.ndarray, List[str]]:
646
+ """Return a TF-IDF cosine-distance matrix and document names."""
647
+ self._check_loaded()
648
+ pairs = [
649
+ (i, r) for i, r in enumerate(self._records) if not r.is_corrupt
650
+ ][:max_documents]
651
+ if len(pairs) < 2:
652
+ raise ValueError("Need at least two valid documents.")
653
+ texts = [self.get_text(i) for i, _ in pairs]
654
+ names = [r.label or r.name or f"document_{i}" for i, r in pairs]
655
+
656
+ try:
657
+ from sklearn.feature_extraction.text import TfidfVectorizer
658
+ from sklearn.metrics.pairwise import cosine_distances
659
+
660
+ vectorizer = TfidfVectorizer(
661
+ lowercase=self.lowercase,
662
+ token_pattern=r"(?u)\b\w+\b",
663
+ stop_words=list(self.stopwords) if exclude_stopwords else None,
664
+ max_features=max_features,
665
+ min_df=1,
666
+ )
667
+ X = vectorizer.fit_transform(texts)
668
+ if X.shape[1] == 0:
669
+ return np.zeros((len(texts), len(texts))), names
670
+ dist = cosine_distances(X)
671
+ return np.asarray(dist, dtype=float), names
672
+ except Exception:
673
+ return self._pairwise_fallback(texts, names, max_features, exclude_stopwords)
674
+
675
+ def near_duplicate_pairs(
676
+ self,
677
+ threshold: float = 0.15,
678
+ max_documents: int = 100,
679
+ ) -> List[Tuple[str, str, float]]:
680
+ """Return pairs whose TF-IDF cosine distance is at most *threshold*."""
681
+ dist, names = self.pairwise_document_distances(max_documents=max_documents)
682
+ pairs: List[Tuple[str, str, float]] = []
683
+ for i in range(len(names)):
684
+ for j in range(i + 1, len(names)):
685
+ if dist[i, j] <= threshold:
686
+ pairs.append((names[i], names[j], float(dist[i, j])))
687
+ return pairs
688
+
689
+ # ------------------------------------------------------------------
690
+ # Plotting
691
+ # ------------------------------------------------------------------
692
+ def plot_dataset(
693
+ self,
694
+ figsize: Tuple[int, int] = (22, 28),
695
+ save_path: Optional[str] = None,
696
+ dpi: int = 160,
697
+ top_n: int = 20,
698
+ ) -> None:
699
+ """Generate a comprehensive dataset-level TextEDA dashboard."""
700
+ self._check_loaded()
701
+ valid = [r for r in self._records if not r.is_corrupt]
702
+ if not valid:
703
+ raise RuntimeError("No valid documents to plot.")
704
+
705
+ plt = _plt()
706
+ mpl = _mpl()
707
+ fig = plt.figure(figsize=figsize, facecolor="white")
708
+ fig.suptitle("TextEDA — Dataset Analysis", fontsize=20, fontweight="bold", y=0.995)
709
+ gs = mpl.gridspec.GridSpec(7, 4, figure=fig, hspace=0.65, wspace=0.42)
710
+
711
+ self._plot_dataset_card(fig.add_subplot(gs[0, :2]), valid)
712
+ self._plot_label_distribution(fig.add_subplot(gs[0, 2:]), valid)
713
+
714
+ self._plot_hist(fig.add_subplot(gs[1, 0]), [r.word_count for r in valid], "Word Count", "Words")
715
+ self._plot_hist(fig.add_subplot(gs[1, 1]), [r.char_count for r in valid], "Character Count", "Characters")
716
+ self._plot_hist(fig.add_subplot(gs[1, 2]), [r.sentence_count for r in valid], "Sentence Count", "Sentences")
717
+ self._plot_hist(fig.add_subplot(gs[1, 3]), [r.lexical_diversity for r in valid], "Lexical Diversity", "Unique / words")
718
+
719
+ self._plot_hist(fig.add_subplot(gs[2, 0]), [r.avg_sentence_length for r in valid], "Average Sentence Length", "Words / sentence")
720
+ self._plot_hist(fig.add_subplot(gs[2, 1]), [r.avg_word_length for r in valid], "Average Word Length", "Characters / word")
721
+ self._plot_hist(fig.add_subplot(gs[2, 2]), [r.stopword_ratio for r in valid], "Stopword Ratio", "Fraction")
722
+ self._plot_hist(fig.add_subplot(gs[2, 3]), [r.readability_flesch for r in valid if r.readability_flesch is not None], "Flesch Reading Ease", "Score")
723
+
724
+ self._plot_hist(fig.add_subplot(gs[3, 0]), [r.punctuation_rate for r in valid], "Punctuation Rate", "Fraction of characters")
725
+ self._plot_hist(fig.add_subplot(gs[3, 1]), [r.digit_rate for r in valid], "Digit Rate", "Fraction of characters")
726
+ self._plot_hist(fig.add_subplot(gs[3, 2]), [r.non_ascii_rate for r in valid], "Non-ASCII Rate", "Fraction of characters")
727
+ self._plot_hist(fig.add_subplot(gs[3, 3]), [r.repeated_line_fraction for r in valid], "Repeated-Line Fraction", "Fraction")
728
+
729
+ self._plot_frequency(fig.add_subplot(gs[4, :2]), self.vocabulary(top_n=top_n), "Top Words")
730
+ bigrams = {" ".join(k): v for k, v in self._dataset_ngram_counts(2).most_common(top_n)}
731
+ self._plot_frequency(fig.add_subplot(gs[4, 2:]), bigrams, "Top Bigrams")
732
+
733
+ self._plot_script_distribution(fig.add_subplot(gs[5, 0]), valid)
734
+ self._plot_format_distribution(fig.add_subplot(gs[5, 1]), valid)
735
+ self._plot_scatter(fig.add_subplot(gs[5, 2]), valid, "word_count", "unique_word_count", "Words vs Unique Words")
736
+ self._plot_scatter(fig.add_subplot(gs[5, 3]), valid, "avg_sentence_length", "readability_flesch", "Sentence Length vs Readability")
737
+
738
+ self._plot_pairwise_panel(fig.add_subplot(gs[6, :2]), max_documents=30)
739
+ self._plot_quality_flags(fig.add_subplot(gs[6, 2:]), valid)
740
+
741
+ self._finalise(fig, save_path, dpi)
742
+
743
+ def plot(
744
+ self,
745
+ document_index: int = 0,
746
+ figsize: Tuple[int, int] = (18, 15),
747
+ save_path: Optional[str] = None,
748
+ dpi: int = 160,
749
+ top_n: int = 20,
750
+ ) -> None:
751
+ """Generate a single-document deep-dive dashboard."""
752
+ self._check_loaded()
753
+ rec = self._records[document_index]
754
+ if rec.is_corrupt:
755
+ raise RuntimeError(f"Document is corrupt: {rec.error}")
756
+ text = self.get_text(document_index)
757
+ tokens = _tokenise(text, self.lowercase, self.min_token_length)
758
+ sentences = _sentences(text)
759
+
760
+ plt = _plt()
761
+ mpl = _mpl()
762
+ fig = plt.figure(figsize=figsize, facecolor="white")
763
+ fig.suptitle(f"TextEDA — {rec.label or rec.name}", fontsize=18, fontweight="bold", y=0.99)
764
+ gs = mpl.gridspec.GridSpec(4, 3, figure=fig, hspace=0.55, wspace=0.38)
765
+
766
+ self._plot_record_card(fig.add_subplot(gs[0, 0]), rec)
767
+ self._plot_text_preview(fig.add_subplot(gs[0, 1:]), text)
768
+
769
+ counts = Counter(tokens)
770
+ self._plot_frequency(fig.add_subplot(gs[1, 0]), dict(counts.most_common(top_n)), "Top Tokens")
771
+ self._plot_hist(fig.add_subplot(gs[1, 1]), [len(t) for t in tokens], "Token Length Distribution", "Characters")
772
+ self._plot_hist(fig.add_subplot(gs[1, 2]), [len(_tokenise(s, self.lowercase, self.min_token_length)) for s in sentences], "Sentence Length Distribution", "Words")
773
+
774
+ bigrams = {" ".join(k): v for k, v in Counter(_ngrams(tokens, 2)).most_common(top_n)}
775
+ self._plot_frequency(fig.add_subplot(gs[2, 0]), bigrams, "Top Bigrams")
776
+ self._plot_character_categories(fig.add_subplot(gs[2, 1]), rec)
777
+ self._plot_token_coverage(fig.add_subplot(gs[2, 2]), counts)
778
+
779
+ self._plot_script_record(fig.add_subplot(gs[3, 0]), rec)
780
+ self._plot_quality_record(fig.add_subplot(gs[3, 1]), rec)
781
+ self._plot_sentence_sequence(fig.add_subplot(gs[3, 2]), sentences)
782
+
783
+ self._finalise(fig, save_path, dpi)
784
+
785
+ def plot_word_frequency(
786
+ self,
787
+ top_n: int = 30,
788
+ exclude_stopwords: bool = False,
789
+ figsize: Tuple[int, int] = (12, 8),
790
+ save_path: Optional[str] = None,
791
+ dpi: int = 160,
792
+ ) -> None:
793
+ counts = self.vocabulary(top_n=top_n, exclude_stopwords=exclude_stopwords)
794
+ plt = _plt()
795
+ fig, ax = plt.subplots(figsize=figsize, facecolor="white")
796
+ self._plot_frequency(ax, counts, "Dataset Word Frequency")
797
+ self._finalise(fig, save_path, dpi)
798
+
799
+ def plot_ngram_frequency(
800
+ self,
801
+ n: int = 2,
802
+ top_n: int = 30,
803
+ exclude_stopwords: bool = False,
804
+ figsize: Tuple[int, int] = (12, 8),
805
+ save_path: Optional[str] = None,
806
+ dpi: int = 160,
807
+ ) -> None:
808
+ counts = self._dataset_ngram_counts(n, exclude_stopwords=exclude_stopwords)
809
+ data = {" ".join(k): v for k, v in counts.most_common(top_n)}
810
+ plt = _plt()
811
+ fig, ax = plt.subplots(figsize=figsize, facecolor="white")
812
+ self._plot_frequency(ax, data, f"Top {n}-gram Frequency")
813
+ self._finalise(fig, save_path, dpi)
814
+
815
+ def plot_length_distribution(
816
+ self,
817
+ figsize: Tuple[int, int] = (16, 10),
818
+ save_path: Optional[str] = None,
819
+ dpi: int = 160,
820
+ ) -> None:
821
+ self._check_loaded()
822
+ valid = [r for r in self._records if not r.is_corrupt]
823
+ plt = _plt()
824
+ fig, axes = plt.subplots(2, 2, figsize=figsize, facecolor="white")
825
+ fig.suptitle("TextEDA — Length Distributions", fontsize=16, fontweight="bold")
826
+ self._plot_hist(axes[0, 0], [r.word_count for r in valid], "Word Count", "Words")
827
+ self._plot_hist(axes[0, 1], [r.char_count for r in valid], "Character Count", "Characters")
828
+ self._plot_hist(axes[1, 0], [r.sentence_count for r in valid], "Sentence Count", "Sentences")
829
+ self._plot_hist(axes[1, 1], [r.avg_sentence_length for r in valid], "Average Sentence Length", "Words")
830
+ self._finalise(fig, save_path, dpi)
831
+
832
+ def plot_label_comparison(
833
+ self,
834
+ metric: str = "word_count",
835
+ figsize: Tuple[int, int] = (12, 7),
836
+ save_path: Optional[str] = None,
837
+ dpi: int = 160,
838
+ ) -> None:
839
+ self._check_loaded()
840
+ valid = [r for r in self._records if not r.is_corrupt and r.label is not None]
841
+ if not valid:
842
+ raise ValueError("No labelled documents are available.")
843
+ if not hasattr(valid[0], metric):
844
+ raise ValueError(f"Unknown TextRecord metric: {metric}")
845
+ groups: Dict[str, List[float]] = defaultdict(list)
846
+ for rec in valid:
847
+ value = getattr(rec, metric)
848
+ if value is not None and np.isfinite(value):
849
+ groups[str(rec.label)].append(float(value))
850
+ labels = sorted(groups)
851
+ data = [groups[label] for label in labels]
852
+ plt = _plt()
853
+ fig, ax = plt.subplots(figsize=figsize, facecolor="white")
854
+ # Matplotlib compatibility:
855
+ # older releases use ``labels``; newer releases renamed it to
856
+ # ``tick_labels``. Try the new name first, then fall back.
857
+ try:
858
+ ax.boxplot(data, tick_labels=labels, showmeans=True)
859
+ except TypeError:
860
+ ax.boxplot(data, labels=labels, showmeans=True)
861
+ ax.set_title(f"{metric.replace('_', ' ').title()} by Label")
862
+ ax.set_ylabel(metric.replace("_", " ").title())
863
+ ax.tick_params(axis="x", rotation=30)
864
+ self._style_axis(ax)
865
+ self._finalise(fig, save_path, dpi)
866
+
867
+ def plot_pairwise_document_distances(
868
+ self,
869
+ max_documents: int = 50,
870
+ figsize: Tuple[int, int] = (11, 9),
871
+ save_path: Optional[str] = None,
872
+ dpi: int = 160,
873
+ ) -> None:
874
+ dist, names = self.pairwise_document_distances(max_documents=max_documents)
875
+ plt = _plt()
876
+ fig, ax = plt.subplots(figsize=figsize, facecolor="white")
877
+ im = ax.imshow(dist, aspect="auto")
878
+ fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="Cosine distance")
879
+ if len(names) <= 35:
880
+ ax.set_xticks(range(len(names)))
881
+ ax.set_xticklabels(names, rotation=45, ha="right", fontsize=7)
882
+ ax.set_yticks(range(len(names)))
883
+ ax.set_yticklabels(names, fontsize=7)
884
+ ax.set_title("Pairwise Document Distances")
885
+ ax.set_xlabel("Document")
886
+ ax.set_ylabel("Document")
887
+ self._finalise(fig, save_path, dpi)
888
+
889
+ def plot_text_samples(
890
+ self,
891
+ n: int = 12,
892
+ cols: int = 3,
893
+ chars: int = 450,
894
+ figsize: Optional[Tuple[int, int]] = None,
895
+ save_path: Optional[str] = None,
896
+ dpi: int = 160,
897
+ ) -> None:
898
+ self._check_loaded()
899
+ valid_indices = [i for i, r in enumerate(self._records) if not r.is_corrupt]
900
+ if not valid_indices:
901
+ raise RuntimeError("No valid documents to plot.")
902
+ n = min(n, len(valid_indices))
903
+ rows = int(math.ceil(n / cols))
904
+ if figsize is None:
905
+ figsize = (6 * cols, 3.5 * rows)
906
+ plt = _plt()
907
+ fig, axes = plt.subplots(rows, cols, figsize=figsize, facecolor="white", squeeze=False)
908
+ fig.suptitle("TextEDA — Text Sample Grid", fontsize=17, fontweight="bold")
909
+ for ax, idx in zip(axes.flat, valid_indices[:n]):
910
+ rec = self._records[idx]
911
+ preview = _normalise_whitespace(self.get_text(idx))[:chars]
912
+ ax.text(0.02, 0.95, preview, va="top", ha="left", wrap=True, fontsize=9, transform=ax.transAxes)
913
+ ax.set_title(rec.label or rec.name, fontsize=10)
914
+ ax.set_xticks([])
915
+ ax.set_yticks([])
916
+ self._style_axis(ax)
917
+ for ax in axes.flat[n:]:
918
+ ax.axis("off")
919
+ self._finalise(fig, save_path, dpi)
920
+
921
+ # ------------------------------------------------------------------
922
+ # HTML report
923
+ # ------------------------------------------------------------------
924
+ def report(self, output_path: str = "viseda_text_report.html") -> str:
925
+ """Write a styled self-contained HTML report and return its path."""
926
+ self._check_loaded()
927
+ summary = self.summary()
928
+ output = Path(output_path)
929
+ output.parent.mkdir(parents=True, exist_ok=True)
930
+
931
+ inv = summary.get("inventory", {})
932
+ label_dist = inv.get("label_distribution") or {}
933
+ format_dist = inv.get("format_distribution") or {}
934
+ script_dist = inv.get("dominant_script_distribution") or {}
935
+
936
+ sections = [
937
+ self._html_section("Length", summary.get("length", {})),
938
+ self._html_section("Symbols and Markup", summary.get("symbols", {})),
939
+ self._html_section("Readability", summary.get("readability", {})),
940
+ self._html_section("Quality", summary.get("quality", {}), skip_complex=True),
941
+ ]
942
+
943
+ lexical = summary.get("lexical", {})
944
+ top_words = lexical.get("top_words", [])[:20]
945
+ top_bigrams = lexical.get("top_bigrams", [])[:20]
946
+
947
+ html_text = f"""<!DOCTYPE html>
948
+ <html lang="en"><head><meta charset="UTF-8"/>
949
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
950
+ <title>VisEDA — Text Report</title>
951
+ <style>
952
+ :root{{--bg:white;--surface:#f6f8fa;--border:#d0d7de;--text:#1f2328;
953
+ --muted:#57606a;--accent:#58a6ff;--green:#3fb950;--red:#f78166;}}
954
+ *{{box-sizing:border-box;margin:0;padding:0}}
955
+ body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:2rem}}
956
+ h1{{font-size:1.9rem;margin-bottom:.25rem}} h2{{font-size:1.05rem;color:var(--accent);margin:1.8rem 0 .6rem}}
957
+ h3{{font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.5rem}}
958
+ .sub{{color:var(--muted);font-size:.85rem;margin-bottom:1.5rem}}
959
+ .grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.9rem}}
960
+ .card{{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem}}
961
+ .stat{{display:flex;justify-content:space-between;gap:.6rem;font-size:.82rem;padding:.18rem 0;border-bottom:1px solid var(--border)}}
962
+ .stat:last-child{{border-bottom:none}} .val{{color:var(--accent);font-variant-numeric:tabular-nums;text-align:right}}
963
+ .badge{{display:inline-block;padding:.15rem .5rem;border-radius:12px;font-size:.72rem;font-weight:600;margin:.15rem}}
964
+ .blue{{background:rgba(88,166,255,.15);color:var(--accent)}} .green{{background:rgba(63,185,80,.15);color:var(--green)}}
965
+ .bar-row{{display:flex;align-items:center;gap:.4rem;margin:.2rem 0;font-size:.76rem}}
966
+ .bar-label{{width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)}}
967
+ .bar{{flex:1;background:var(--border);border-radius:3px;height:9px}} .bar-fill{{height:100%;border-radius:3px;background:var(--accent)}}
968
+ .bar-count{{width:55px;text-align:right;color:var(--accent)}} footer{{margin-top:3rem;color:var(--muted);font-size:.72rem;border-top:1px solid var(--border);padding-top:1rem}}
969
+ </style></head><body>
970
+ <h1>📝 VisEDA — Text EDA Report</h1>
971
+ <p class="sub">Generated by <strong>VisEDA TextEDA</strong></p>
972
+ <p><span class="badge blue">{inv.get('total_documents', 0)} documents</span>
973
+ <span class="badge green">{inv.get('valid_documents', 0)} valid</span></p>
974
+ <h2>📦 Inventory</h2><div class="grid">
975
+ {self._html_card('Counts', {'Total documents': inv.get('total_documents'), 'Valid documents': inv.get('valid_documents'), 'Corrupt documents': inv.get('corrupt_documents')})}
976
+ {self._html_bar_card('Label Distribution', label_dist)}
977
+ {self._html_bar_card('Format Distribution', format_dist)}
978
+ {self._html_bar_card('Dominant Script', script_dist)}
979
+ </div>
980
+ {''.join(sections)}
981
+ <h2>🔤 Vocabulary</h2><div class="grid">
982
+ {self._html_card('Dataset Vocabulary', {'Total tokens': lexical.get('dataset_total_tokens'), 'Unique tokens': lexical.get('dataset_unique_tokens'), 'Type-token ratio': lexical.get('dataset_type_token_ratio'), 'Hapax count': lexical.get('dataset_hapax_count'), 'Hapax ratio': lexical.get('dataset_hapax_ratio')})}
983
+ {self._html_bar_card('Top Words', dict(top_words))}
984
+ {self._html_bar_card('Top Bigrams', dict(top_bigrams))}
985
+ </div>
986
+ <footer>Generated by VisEDA TextEDA.</footer>
987
+ </body></html>"""
988
+ output.write_text(html_text, encoding="utf-8")
989
+ self._log(f"Report saved → {output}")
990
+ return str(output)
991
+
992
+ # ------------------------------------------------------------------
993
+ # Internal loading helpers
994
+ # ------------------------------------------------------------------
995
+ def _resolve_paths(
996
+ self,
997
+ source: Union[str, Path, Sequence[Union[str, Path]]],
998
+ recursive: bool,
999
+ ) -> List[Path]:
1000
+ if isinstance(source, (str, Path)):
1001
+ path = Path(source)
1002
+ if path.is_dir():
1003
+ iterator = path.rglob("*") if recursive else path.glob("*")
1004
+ return sorted(p for p in iterator if p.is_file() and p.suffix.lower() in self.SUPPORTED_EXTS)
1005
+ if path.is_file():
1006
+ if path.suffix.lower() not in self.SUPPORTED_EXTS:
1007
+ raise ValueError(f"Unsupported text format: {path.suffix}")
1008
+ return [path]
1009
+ raise FileNotFoundError(path)
1010
+ paths = [Path(p) for p in source]
1011
+ unsupported = [p for p in paths if p.suffix.lower() not in self.SUPPORTED_EXTS]
1012
+ if unsupported:
1013
+ raise ValueError(f"Unsupported text format(s): {unsupported}")
1014
+ return sorted(paths)
1015
+
1016
+ def _read_documents(
1017
+ self,
1018
+ path: Path,
1019
+ text_field: Optional[str],
1020
+ label_field: Optional[str],
1021
+ ) -> Tuple[List[Tuple[Optional[int], str, Optional[str]]], str]:
1022
+ ext = path.suffix.lower()
1023
+ raw, encoding = self._read_text(path)
1024
+
1025
+ if ext in {".txt", ".text", ".md", ".rst", ".log"}:
1026
+ return [(None, raw, None)], encoding
1027
+ if ext in {".html", ".htm"}:
1028
+ parser = _HTMLTextExtractor()
1029
+ parser.feed(raw)
1030
+ return [(None, parser.text(), None)], encoding
1031
+ if ext in {".csv", ".tsv"}:
1032
+ delimiter = "\t" if ext == ".tsv" else ","
1033
+ return self._read_delimited(raw, delimiter, text_field, label_field), encoding
1034
+ if ext == ".json":
1035
+ obj = json.loads(raw)
1036
+ return self._extract_json_documents(obj, text_field, label_field), encoding
1037
+ if ext in {".jsonl", ".ndjson"}:
1038
+ rows = []
1039
+ for line_number, line in enumerate(raw.splitlines()):
1040
+ if not line.strip():
1041
+ continue
1042
+ obj = json.loads(line)
1043
+ extracted = self._extract_json_documents(obj, text_field, label_field)
1044
+ for _, txt, lbl in extracted:
1045
+ rows.append((line_number, txt, lbl))
1046
+ return rows, encoding
1047
+ raise ValueError(f"Unsupported text format: {ext}")
1048
+
1049
+ def _read_text(self, path: Path) -> Tuple[str, str]:
1050
+ encodings = []
1051
+ if self.encoding:
1052
+ encodings.append(self.encoding)
1053
+ encodings.extend(["utf-8", "utf-8-sig", "utf-16", "utf-16-le", "utf-16-be", "cp1252", "latin-1"])
1054
+ seen = set()
1055
+ last_error: Optional[Exception] = None
1056
+ for enc in encodings:
1057
+ if enc in seen:
1058
+ continue
1059
+ seen.add(enc)
1060
+ try:
1061
+ return path.read_text(encoding=enc), enc
1062
+ except UnicodeError as exc:
1063
+ last_error = exc
1064
+ raise UnicodeError(f"Unable to decode {path}: {last_error}")
1065
+
1066
+ def _read_delimited(
1067
+ self,
1068
+ raw: str,
1069
+ delimiter: str,
1070
+ text_field: Optional[str],
1071
+ label_field: Optional[str],
1072
+ ) -> List[Tuple[Optional[int], str, Optional[str]]]:
1073
+ reader = csv.DictReader(raw.splitlines(), delimiter=delimiter)
1074
+ fields = reader.fieldnames or []
1075
+ selected_text = self._select_field(fields, text_field, DEFAULT_TEXT_FIELDS, "text")
1076
+ selected_label = self._select_optional_field(fields, label_field, DEFAULT_LABEL_FIELDS)
1077
+ documents = []
1078
+ for i, row in enumerate(reader):
1079
+ value = row.get(selected_text)
1080
+ if value is None:
1081
+ continue
1082
+ label = row.get(selected_label) if selected_label else None
1083
+ documents.append((i, str(value), str(label) if label not in (None, "") else None))
1084
+ return documents
1085
+
1086
+ def _extract_json_documents(
1087
+ self,
1088
+ obj: Any,
1089
+ text_field: Optional[str],
1090
+ label_field: Optional[str],
1091
+ ) -> List[Tuple[Optional[int], str, Optional[str]]]:
1092
+ if isinstance(obj, str):
1093
+ return [(None, obj, None)]
1094
+ if isinstance(obj, list):
1095
+ items = obj
1096
+ elif isinstance(obj, dict):
1097
+ for key in ("data", "records", "items", "documents", "examples"):
1098
+ if isinstance(obj.get(key), list):
1099
+ items = obj[key]
1100
+ break
1101
+ else:
1102
+ # One object can be one document.
1103
+ items = [obj]
1104
+ else:
1105
+ return [(None, str(obj), None)]
1106
+
1107
+ documents: List[Tuple[Optional[int], str, Optional[str]]] = []
1108
+ for i, item in enumerate(items):
1109
+ if isinstance(item, str):
1110
+ documents.append((i, item, None))
1111
+ continue
1112
+ if not isinstance(item, dict):
1113
+ documents.append((i, str(item), None))
1114
+ continue
1115
+ fields = list(item.keys())
1116
+ selected_text = self._select_field(fields, text_field, DEFAULT_TEXT_FIELDS, "text")
1117
+ selected_label = self._select_optional_field(fields, label_field, DEFAULT_LABEL_FIELDS)
1118
+ value = item.get(selected_text)
1119
+ if value is None:
1120
+ continue
1121
+ label = item.get(selected_label) if selected_label else None
1122
+ documents.append((i, str(value), str(label) if label not in (None, "") else None))
1123
+ return documents
1124
+
1125
+ @staticmethod
1126
+ def _select_field(fields: Sequence[str], requested: Optional[str], candidates: Sequence[str], kind: str) -> str:
1127
+ if requested:
1128
+ if requested not in fields:
1129
+ raise KeyError(f"Requested {kind}_field '{requested}' not found. Available fields: {list(fields)}")
1130
+ return requested
1131
+ lower_map = {f.casefold(): f for f in fields}
1132
+ for candidate in candidates:
1133
+ if candidate.casefold() in lower_map:
1134
+ return lower_map[candidate.casefold()]
1135
+ if len(fields) == 1:
1136
+ return fields[0]
1137
+ raise KeyError(
1138
+ f"Could not identify a {kind} field automatically. Available fields: {list(fields)}. "
1139
+ f"Pass {kind}_field explicitly."
1140
+ )
1141
+
1142
+ @staticmethod
1143
+ def _select_optional_field(fields: Sequence[str], requested: Optional[str], candidates: Sequence[str]) -> Optional[str]:
1144
+ if requested:
1145
+ if requested not in fields:
1146
+ raise KeyError(f"Requested label_field '{requested}' not found. Available fields: {list(fields)}")
1147
+ return requested
1148
+ lower_map = {f.casefold(): f for f in fields}
1149
+ for candidate in candidates:
1150
+ if candidate.casefold() in lower_map:
1151
+ return lower_map[candidate.casefold()]
1152
+ return None
1153
+
1154
+ # ------------------------------------------------------------------
1155
+ # Internal analysis
1156
+ # ------------------------------------------------------------------
1157
+ def _fill_stats(self, rec: TextRecord, text: str) -> None:
1158
+ chars = len(text)
1159
+ rec.char_count = chars
1160
+ rec.byte_count = len(text.encode("utf-8", errors="replace"))
1161
+ rec.normalised_hash = _normalised_hash(text)
1162
+ rec.preview = _normalise_whitespace(text)[:500]
1163
+
1164
+ tokens = _tokenise(text, lowercase=self.lowercase, min_length=self.min_token_length)
1165
+ words = [t for t in tokens if WORD_RE.fullmatch(t)]
1166
+ numeric = [t for t in tokens if any(ch.isdigit() for ch in t)]
1167
+ sentences = _sentences(text)
1168
+ paragraphs = _paragraphs(text)
1169
+ lines = text.splitlines() if text else []
1170
+ nonempty_lines = [line.strip() for line in lines if line.strip()]
1171
+
1172
+ rec.word_count = len(tokens)
1173
+ rec.alpha_word_count = len(words)
1174
+ rec.numeric_token_count = len(numeric)
1175
+ rec.unique_word_count = len(set(tokens))
1176
+ rec.sentence_count = len(sentences)
1177
+ rec.paragraph_count = len(paragraphs)
1178
+ rec.line_count = len(lines) if lines else (1 if text else 0)
1179
+ rec.nonempty_line_count = len(nonempty_lines)
1180
+
1181
+ word_lengths = np.asarray([len(w) for w in tokens], dtype=float)
1182
+ sentence_lengths = np.asarray([
1183
+ len(_tokenise(s, lowercase=self.lowercase, min_length=self.min_token_length))
1184
+ for s in sentences
1185
+ ], dtype=float)
1186
+ paragraph_lengths = [
1187
+ len(_tokenise(p, lowercase=self.lowercase, min_length=self.min_token_length))
1188
+ for p in paragraphs
1189
+ ]
1190
+
1191
+ rec.avg_word_length = float(word_lengths.mean()) if word_lengths.size else 0.0
1192
+ rec.median_word_length = float(np.median(word_lengths)) if word_lengths.size else 0.0
1193
+ rec.word_length_std = float(word_lengths.std()) if word_lengths.size else 0.0
1194
+ rec.avg_sentence_length = float(sentence_lengths.mean()) if sentence_lengths.size else 0.0
1195
+ rec.median_sentence_length = float(np.median(sentence_lengths)) if sentence_lengths.size else 0.0
1196
+ rec.sentence_length_std = float(sentence_lengths.std()) if sentence_lengths.size else 0.0
1197
+ rec.max_sentence_words = int(sentence_lengths.max()) if sentence_lengths.size else 0
1198
+ rec.avg_paragraph_words = float(np.mean(paragraph_lengths)) if paragraph_lengths else 0.0
1199
+
1200
+ counts = Counter(tokens)
1201
+ rec.lexical_diversity = _safe_ratio(rec.unique_word_count, rec.word_count)
1202
+ rec.hapax_count = sum(1 for c in counts.values() if c == 1)
1203
+ rec.hapax_ratio = _safe_ratio(rec.hapax_count, rec.unique_word_count)
1204
+ rec.stopword_count = sum(c for t, c in counts.items() if t in self.stopwords)
1205
+ rec.stopword_ratio = _safe_ratio(rec.stopword_count, rec.word_count)
1206
+ rec.top_tokens = counts.most_common(self.top_n)
1207
+ rec.top_bigrams = [(" ".join(k), v) for k, v in Counter(_ngrams(tokens, 2)).most_common(self.top_n)]
1208
+ rec.top_trigrams = [(" ".join(k), v) for k, v in Counter(_ngrams(tokens, 3)).most_common(self.top_n)]
1209
+
1210
+ rec.punctuation_count = sum(1 for ch in text if not ch.isalnum() and not ch.isspace())
1211
+ rec.punctuation_rate = _safe_ratio(rec.punctuation_count, chars)
1212
+ rec.digit_count = sum(1 for ch in text if ch.isdigit())
1213
+ rec.digit_rate = _safe_ratio(rec.digit_count, chars)
1214
+ alpha_chars = sum(1 for ch in text if ch.isalpha())
1215
+ rec.uppercase_count = sum(1 for ch in text if ch.isupper())
1216
+ rec.uppercase_rate = _safe_ratio(rec.uppercase_count, alpha_chars)
1217
+ rec.whitespace_count = sum(1 for ch in text if ch.isspace())
1218
+ rec.whitespace_rate = _safe_ratio(rec.whitespace_count, chars)
1219
+ rec.non_ascii_count = sum(1 for ch in text if ord(ch) > 127)
1220
+ rec.non_ascii_rate = _safe_ratio(rec.non_ascii_count, chars)
1221
+ rec.emoji_count = len(EMOJI_RE.findall(text))
1222
+ rec.url_count = len(URL_RE.findall(text))
1223
+ rec.email_count = len(EMAIL_RE.findall(text))
1224
+ rec.hashtag_count = len(HASHTAG_RE.findall(text))
1225
+ rec.mention_count = len(MENTION_RE.findall(text))
1226
+
1227
+ if nonempty_lines:
1228
+ normal_lines = [_normalise_whitespace(line).casefold() for line in nonempty_lines]
1229
+ rec.repeated_line_fraction = 1.0 - len(set(normal_lines)) / len(normal_lines)
1230
+ else:
1231
+ rec.repeated_line_fraction = 0.0
1232
+
1233
+ rec.empty = not bool(text.strip())
1234
+ rec.very_short = rec.word_count < self.short_document_words
1235
+ rec.very_long = rec.word_count > self.long_document_words
1236
+ rec.readability_flesch, rec.readability_grade = _readability(words, rec.sentence_count)
1237
+ rec.script_distribution, rec.dominant_script = _script_distribution(text)
1238
+
1239
+ def _dataset_token_counts(self, exclude_stopwords: bool = False) -> Counter[str]:
1240
+ counts: Counter[str] = Counter()
1241
+ for i, rec in enumerate(self._records):
1242
+ if rec.is_corrupt:
1243
+ continue
1244
+ tokens = _tokenise(self.get_text(i), self.lowercase, self.min_token_length)
1245
+ if exclude_stopwords:
1246
+ tokens = [t for t in tokens if t not in self.stopwords]
1247
+ counts.update(tokens)
1248
+ return counts
1249
+
1250
+ def _dataset_ngram_counts(self, n: int, exclude_stopwords: bool = False) -> Counter[Tuple[str, ...]]:
1251
+ counts: Counter[Tuple[str, ...]] = Counter()
1252
+ for i, rec in enumerate(self._records):
1253
+ if rec.is_corrupt:
1254
+ continue
1255
+ tokens = _tokenise(self.get_text(i), self.lowercase, self.min_token_length)
1256
+ if exclude_stopwords:
1257
+ tokens = [t for t in tokens if t not in self.stopwords]
1258
+ counts.update(_ngrams(tokens, n))
1259
+ return counts
1260
+
1261
+ def _pairwise_fallback(
1262
+ self,
1263
+ texts: Sequence[str],
1264
+ names: List[str],
1265
+ max_features: int,
1266
+ exclude_stopwords: bool,
1267
+ ) -> Tuple[np.ndarray, List[str]]:
1268
+ doc_counts: List[Counter[str]] = []
1269
+ df: Counter[str] = Counter()
1270
+ corpus_counts: Counter[str] = Counter()
1271
+ for text in texts:
1272
+ tokens = _tokenise(text, self.lowercase, self.min_token_length)
1273
+ if exclude_stopwords:
1274
+ tokens = [t for t in tokens if t not in self.stopwords]
1275
+ c = Counter(tokens)
1276
+ doc_counts.append(c)
1277
+ corpus_counts.update(c)
1278
+ df.update(c.keys())
1279
+ vocab = [t for t, _ in corpus_counts.most_common(max_features)]
1280
+ if not vocab:
1281
+ return np.zeros((len(texts), len(texts))), names
1282
+ index = {t: j for j, t in enumerate(vocab)}
1283
+ X = np.zeros((len(texts), len(vocab)), dtype=float)
1284
+ n_docs = len(texts)
1285
+ for i, counts in enumerate(doc_counts):
1286
+ total = sum(counts.values()) or 1
1287
+ for token, count in counts.items():
1288
+ j = index.get(token)
1289
+ if j is None:
1290
+ continue
1291
+ tf = count / total
1292
+ idf = math.log((1 + n_docs) / (1 + df[token])) + 1.0
1293
+ X[i, j] = tf * idf
1294
+ norms = np.linalg.norm(X, axis=1, keepdims=True)
1295
+ norms[norms == 0] = 1.0
1296
+ Xn = X / norms
1297
+ similarity = np.clip(Xn @ Xn.T, -1.0, 1.0)
1298
+ dist = 1.0 - similarity
1299
+ np.fill_diagonal(dist, 0.0)
1300
+ return dist, names
1301
+
1302
+ # ------------------------------------------------------------------
1303
+ # Plot helpers
1304
+ # ------------------------------------------------------------------
1305
+ @staticmethod
1306
+ def _style_axis(ax) -> None:
1307
+ ax.set_facecolor("#f6f8fa")
1308
+ for spine in ax.spines.values():
1309
+ spine.set_edgecolor("#d0d7de")
1310
+ ax.grid(alpha=0.16, linewidth=0.6)
1311
+
1312
+ def _plot_hist(self, ax, values: Sequence[float], title: str, xlabel: str = "Value") -> None:
1313
+ vals = np.asarray([v for v in values if v is not None and np.isfinite(v)], dtype=float)
1314
+ self._style_axis(ax)
1315
+ ax.set_title(title, fontsize=10)
1316
+ ax.set_xlabel(xlabel, fontsize=8)
1317
+ ax.set_ylabel("Documents", fontsize=8)
1318
+ if vals.size == 0:
1319
+ ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes)
1320
+ return
1321
+ bins = min(30, max(5, int(np.sqrt(vals.size)) + 1))
1322
+ ax.hist(vals, bins=bins, alpha=0.9)
1323
+ mean = float(vals.mean())
1324
+ ax.axvline(mean, linestyle="--", linewidth=1)
1325
+ ax.legend([f"Mean = {mean:.3g}"], fontsize=7)
1326
+
1327
+ def _plot_dataset_card(self, ax, valid: Sequence[TextRecord]) -> None:
1328
+ ax.axis("off")
1329
+ s = self.summary()
1330
+ inv = s["inventory"]
1331
+ lexical = s["lexical"]
1332
+ quality = s["quality"]
1333
+ lines = [
1334
+ f"Total documents: {inv['total_documents']}",
1335
+ f"Valid documents: {inv['valid_documents']}",
1336
+ f"Corrupt documents: {inv['corrupt_documents']}",
1337
+ f"Unique labels: {len(inv['label_distribution'] or {})}",
1338
+ f"Mean words: {s['length']['words']['mean']:.2f}",
1339
+ f"Vocabulary size: {lexical['dataset_unique_tokens']:,}",
1340
+ f"Type-token ratio: {lexical['dataset_type_token_ratio']:.4f}",
1341
+ f"Exact dup. frac: {quality['exact_duplicate_fraction']:.4f}",
1342
+ ]
1343
+ ax.set_title("Dataset Overview", fontsize=10)
1344
+ ax.text(
1345
+ 0.03, 0.92, "\n".join(lines), va="top", ha="left",
1346
+ family="monospace", fontsize=8.5,
1347
+ bbox=dict(boxstyle="round,pad=0.5", facecolor="#eaeff5", edgecolor="#d0d7de"),
1348
+ transform=ax.transAxes,
1349
+ )
1350
+
1351
+ def _plot_label_distribution(self, ax, valid: Sequence[TextRecord]) -> None:
1352
+ counts = Counter(r.label for r in valid if r.label is not None)
1353
+ self._style_axis(ax)
1354
+ ax.set_title("Label Distribution", fontsize=10)
1355
+ if not counts:
1356
+ ax.text(0.5, 0.5, "No labels", ha="center", va="center", transform=ax.transAxes)
1357
+ return
1358
+ labels, vals = zip(*counts.most_common(25)[::-1])
1359
+ ax.barh(labels, vals)
1360
+ ax.set_xlabel("Documents", fontsize=8)
1361
+
1362
+ def _plot_frequency(self, ax, data: Mapping[str, int], title: str) -> None:
1363
+ self._style_axis(ax)
1364
+ ax.set_title(title, fontsize=10)
1365
+ if not data:
1366
+ ax.text(0.5, 0.5, "No tokens", ha="center", va="center", transform=ax.transAxes)
1367
+ return
1368
+ items = list(data.items())
1369
+ labels = [str(k) for k, _ in items][::-1]
1370
+ vals = [v for _, v in items][::-1]
1371
+ ax.barh(labels, vals)
1372
+ ax.tick_params(axis="y", labelsize=7)
1373
+ ax.set_xlabel("Frequency", fontsize=8)
1374
+
1375
+ def _plot_script_distribution(self, ax, valid: Sequence[TextRecord]) -> None:
1376
+ counts = Counter(r.dominant_script for r in valid if r.dominant_script)
1377
+ self._style_axis(ax)
1378
+ ax.set_title("Dominant Script", fontsize=10)
1379
+ if not counts:
1380
+ ax.text(0.5, 0.5, "No alphabetic text", ha="center", va="center", transform=ax.transAxes)
1381
+ return
1382
+ labels, vals = zip(*counts.most_common())
1383
+ ax.bar(labels, vals)
1384
+ ax.tick_params(axis="x", rotation=35, labelsize=7)
1385
+ ax.set_ylabel("Documents", fontsize=8)
1386
+
1387
+ def _plot_format_distribution(self, ax, valid: Sequence[TextRecord]) -> None:
1388
+ counts = Counter(r.file_ext for r in valid)
1389
+ self._style_axis(ax)
1390
+ ax.set_title("Format Distribution", fontsize=10)
1391
+ labels, vals = zip(*counts.items())
1392
+ ax.bar(labels, vals)
1393
+ ax.tick_params(axis="x", rotation=35, labelsize=7)
1394
+ ax.set_ylabel("Documents", fontsize=8)
1395
+
1396
+ def _plot_scatter(self, ax, valid: Sequence[TextRecord], x_attr: str, y_attr: str, title: str) -> None:
1397
+ pairs = [
1398
+ (getattr(r, x_attr), getattr(r, y_attr))
1399
+ for r in valid
1400
+ if getattr(r, x_attr) is not None and getattr(r, y_attr) is not None
1401
+ and np.isfinite(getattr(r, x_attr)) and np.isfinite(getattr(r, y_attr))
1402
+ ]
1403
+ self._style_axis(ax)
1404
+ ax.set_title(title, fontsize=10)
1405
+ ax.set_xlabel(x_attr.replace("_", " ").title(), fontsize=8)
1406
+ ax.set_ylabel(y_attr.replace("_", " ").title(), fontsize=8)
1407
+ if not pairs:
1408
+ ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes)
1409
+ return
1410
+ x, y = zip(*pairs)
1411
+ ax.scatter(x, y, alpha=0.65, s=18)
1412
+
1413
+ def _plot_pairwise_panel(self, ax, max_documents: int) -> None:
1414
+ self._style_axis(ax)
1415
+ ax.set_title("Pairwise Document Distances", fontsize=10)
1416
+ try:
1417
+ dist, names = self.pairwise_document_distances(max_documents=max_documents)
1418
+ im = ax.imshow(dist, aspect="auto")
1419
+ if len(names) <= 25:
1420
+ ax.set_xticks(range(len(names)))
1421
+ ax.set_xticklabels(names, rotation=45, ha="right", fontsize=6)
1422
+ ax.set_yticks(range(len(names)))
1423
+ ax.set_yticklabels(names, fontsize=6)
1424
+ ax.figure.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
1425
+ except Exception as exc:
1426
+ ax.text(0.5, 0.5, str(exc), ha="center", va="center", wrap=True, transform=ax.transAxes)
1427
+
1428
+ def _plot_quality_flags(self, ax, valid: Sequence[TextRecord]) -> None:
1429
+ labels = ["Empty", "Very short", "Very long", "Exact duplicate"]
1430
+ hashes = Counter(r.normalised_hash for r in valid)
1431
+ values = [
1432
+ sum(bool(r.empty) for r in valid),
1433
+ sum(bool(r.very_short) for r in valid),
1434
+ sum(bool(r.very_long) for r in valid),
1435
+ sum(hashes[r.normalised_hash] > 1 for r in valid),
1436
+ ]
1437
+ rates = [_safe_ratio(v, len(valid)) for v in values]
1438
+ self._style_axis(ax)
1439
+ ax.set_title("Quality Flags / Rates", fontsize=10)
1440
+ ax.bar(labels, rates)
1441
+ ax.set_ylim(0, max(1.0, max(rates, default=0) * 1.15))
1442
+ ax.set_ylabel("Fraction of documents", fontsize=8)
1443
+ ax.tick_params(axis="x", rotation=25, labelsize=8)
1444
+
1445
+ def _plot_record_card(self, ax, rec: TextRecord) -> None:
1446
+ ax.axis("off")
1447
+ lines = [
1448
+ f"Path: {rec.name}",
1449
+ f"Label: {rec.label}",
1450
+ f"Words: {rec.word_count:,}",
1451
+ f"Unique words: {rec.unique_word_count:,}",
1452
+ f"Sentences: {rec.sentence_count:,}",
1453
+ f"Paragraphs: {rec.paragraph_count:,}",
1454
+ f"Lexical diversity: {rec.lexical_diversity:.4f}",
1455
+ f"Stopword ratio: {rec.stopword_ratio:.4f}",
1456
+ f"Readability: {rec.readability_flesch if rec.readability_flesch is not None else 'N/A'}",
1457
+ f"Dominant script: {rec.dominant_script}",
1458
+ ]
1459
+ ax.set_title("Document Overview", fontsize=10)
1460
+ ax.text(
1461
+ 0.02, 0.95, "\n".join(lines), va="top", ha="left", family="monospace", fontsize=8,
1462
+ bbox=dict(boxstyle="round,pad=0.5", facecolor="#eaeff5", edgecolor="#d0d7de"),
1463
+ transform=ax.transAxes,
1464
+ )
1465
+
1466
+ def _plot_text_preview(self, ax, text: str) -> None:
1467
+ self._style_axis(ax)
1468
+ ax.set_title("Text Preview", fontsize=10)
1469
+ preview = _normalise_whitespace(text)[:1_300]
1470
+ ax.text(0.02, 0.95, preview, va="top", ha="left", wrap=True, fontsize=9, transform=ax.transAxes)
1471
+ ax.set_xticks([])
1472
+ ax.set_yticks([])
1473
+
1474
+ def _plot_character_categories(self, ax, rec: TextRecord) -> None:
1475
+ labels = ["Punctuation", "Digits", "Uppercase", "Whitespace", "Non-ASCII"]
1476
+ values = [rec.punctuation_rate, rec.digit_rate, rec.uppercase_rate, rec.whitespace_rate, rec.non_ascii_rate]
1477
+ self._style_axis(ax)
1478
+ ax.set_title("Character Category Rates", fontsize=10)
1479
+ ax.bar(labels, values)
1480
+ ax.tick_params(axis="x", rotation=35, labelsize=7)
1481
+ ax.set_ylabel("Rate", fontsize=8)
1482
+
1483
+ def _plot_token_coverage(self, ax, counts: Counter[str]) -> None:
1484
+ self._style_axis(ax)
1485
+ ax.set_title("Cumulative Token Coverage", fontsize=10)
1486
+ freqs = np.asarray(sorted(counts.values(), reverse=True), dtype=float)
1487
+ if freqs.size == 0:
1488
+ ax.text(0.5, 0.5, "No tokens", ha="center", va="center", transform=ax.transAxes)
1489
+ return
1490
+ coverage = np.cumsum(freqs) / freqs.sum()
1491
+ ax.plot(np.arange(1, len(coverage) + 1), coverage)
1492
+ ax.axhline(0.8, linestyle="--", linewidth=1)
1493
+ ax.set_xlabel("Top-N vocabulary items", fontsize=8)
1494
+ ax.set_ylabel("Token coverage", fontsize=8)
1495
+ ax.set_ylim(0, 1.03)
1496
+
1497
+ def _plot_script_record(self, ax, rec: TextRecord) -> None:
1498
+ self._style_axis(ax)
1499
+ ax.set_title("Writing Script Composition", fontsize=10)
1500
+ data = rec.script_distribution or {}
1501
+ if not data:
1502
+ ax.text(0.5, 0.5, "No alphabetic text", ha="center", va="center", transform=ax.transAxes)
1503
+ return
1504
+ labels, values = zip(*sorted(data.items(), key=lambda x: -x[1]))
1505
+ ax.bar(labels, values)
1506
+ ax.tick_params(axis="x", rotation=35, labelsize=7)
1507
+ ax.set_ylabel("Fraction of alphabetic characters", fontsize=8)
1508
+
1509
+ def _plot_quality_record(self, ax, rec: TextRecord) -> None:
1510
+ labels = ["Short", "Long", "Repeated lines", "Stopwords", "Non-ASCII"]
1511
+ values = [float(rec.very_short), float(rec.very_long), rec.repeated_line_fraction, rec.stopword_ratio, rec.non_ascii_rate]
1512
+ self._style_axis(ax)
1513
+ ax.set_title("Document Quality / Composition", fontsize=10)
1514
+ ax.bar(labels, values)
1515
+ ax.set_ylim(0, max(1.0, max(values, default=0) * 1.15))
1516
+ ax.tick_params(axis="x", rotation=35, labelsize=7)
1517
+
1518
+ def _plot_sentence_sequence(self, ax, sentences: Sequence[str]) -> None:
1519
+ lengths = [len(_tokenise(s, self.lowercase, self.min_token_length)) for s in sentences]
1520
+ self._style_axis(ax)
1521
+ ax.set_title("Sentence Length Sequence", fontsize=10)
1522
+ if not lengths:
1523
+ ax.text(0.5, 0.5, "No sentences", ha="center", va="center", transform=ax.transAxes)
1524
+ return
1525
+ ax.plot(range(1, len(lengths) + 1), lengths, marker="o", markersize=3)
1526
+ ax.set_xlabel("Sentence index", fontsize=8)
1527
+ ax.set_ylabel("Words", fontsize=8)
1528
+
1529
+ def _finalise(self, fig, save_path: Optional[str], dpi: int) -> None:
1530
+ plt = _plt()
1531
+ try:
1532
+ fig.set_constrained_layout(False)
1533
+ except Exception:
1534
+ pass
1535
+ fig.subplots_adjust(left=0.055, right=0.975, top=0.965, bottom=0.055, hspace=0.68, wspace=0.42)
1536
+ if save_path:
1537
+ path = Path(save_path)
1538
+ path.parent.mkdir(parents=True, exist_ok=True)
1539
+ fig.savefig(path, dpi=dpi, bbox_inches=None, pad_inches=0.18, facecolor="white")
1540
+ plt.close(fig)
1541
+ else:
1542
+ plt.show()
1543
+
1544
+ # ------------------------------------------------------------------
1545
+ # HTML helpers
1546
+ # ------------------------------------------------------------------
1547
+ @staticmethod
1548
+ def _fmt_html(value: Any) -> str:
1549
+ if value is None:
1550
+ return "N/A"
1551
+ if isinstance(value, float):
1552
+ return f"{value:,.4f}"
1553
+ if isinstance(value, (int, np.integer)):
1554
+ return f"{int(value):,}"
1555
+ return html.escape(str(value))
1556
+
1557
+ def _html_card(self, title: str, data: Mapping[str, Any]) -> str:
1558
+ rows = "".join(
1559
+ f'<div class="stat"><span>{html.escape(str(k))}</span><span class="val">{self._fmt_html(v)}</span></div>'
1560
+ for k, v in data.items()
1561
+ )
1562
+ return f'<div class="card"><h3>{html.escape(title)}</h3>{rows}</div>'
1563
+
1564
+ def _html_bar_card(self, title: str, data: Mapping[Any, Any]) -> str:
1565
+ if not data:
1566
+ return self._html_card(title, {"Status": "No data"})
1567
+ max_value = max(float(v) for v in data.values()) or 1.0
1568
+ rows = "".join(
1569
+ '<div class="bar-row">'
1570
+ f'<span class="bar-label">{html.escape(str(k))}</span>'
1571
+ f'<div class="bar"><div class="bar-fill" style="width:{100 * float(v) / max_value:.1f}%"></div></div>'
1572
+ f'<span class="bar-count">{self._fmt_html(v)}</span></div>'
1573
+ for k, v in data.items()
1574
+ )
1575
+ return f'<div class="card"><h3>{html.escape(title)}</h3>{rows}</div>'
1576
+
1577
+ def _html_section(self, title: str, section: Mapping[str, Any], skip_complex: bool = False) -> str:
1578
+ cards = []
1579
+ for key, value in section.items():
1580
+ if isinstance(value, dict) and {"mean", "min", "max"}.intersection(value):
1581
+ cards.append(self._html_card(key.replace("_", " ").title(), value))
1582
+ elif not isinstance(value, (dict, list, tuple)):
1583
+ cards.append(self._html_card(key.replace("_", " ").title(), {"value": value}))
1584
+ elif not skip_complex and isinstance(value, dict):
1585
+ cards.append(self._html_card(key.replace("_", " ").title(), value))
1586
+ return f'<h2>{html.escape(title)}</h2><div class="grid">{"".join(cards)}</div>'
1587
+
1588
+ # ------------------------------------------------------------------
1589
+ # Misc
1590
+ # ------------------------------------------------------------------
1591
+ @staticmethod
1592
+ def _imbalance_ratio(label_dist: Optional[Mapping[str, int]]) -> Optional[float]:
1593
+ if not label_dist:
1594
+ return None
1595
+ values = [v for v in label_dist.values() if v > 0]
1596
+ if not values:
1597
+ return None
1598
+ return float(max(values) / min(values))
1599
+
1600
+ @staticmethod
1601
+ def _record_key(rec: TextRecord, fallback_index: int) -> str:
1602
+ return f"{rec.path}|{rec.source_index if rec.source_index is not None else fallback_index}"
1603
+
1604
+ def _check_loaded(self) -> None:
1605
+ if not self._loaded:
1606
+ raise RuntimeError("Call load() or load_texts() before requesting analysis.")
1607
+
1608
+ def _log(self, message: str) -> None:
1609
+ if self.verbose:
1610
+ print(f"[viseda] {message}")
1611
+
1612
+
1613
+ __all__ = ["TextEDA", "TextRecord"]