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/__init__.py +20 -0
- viseda/cli.py +144 -0
- viseda/core/__init__.py +3 -0
- viseda/core/base.py +69 -0
- viseda/hyperspectral/__init__.py +1 -0
- viseda/hyperspectral/eda.py +1849 -0
- viseda/image/__init__.py +1 -0
- viseda/image/eda.py +1840 -0
- viseda/pointcloud/__init__.py +3 -0
- viseda/pointcloud/eda.py +1167 -0
- viseda/report/__init__.py +3 -0
- viseda/report/html_report.py +213 -0
- viseda/text/__init__.py +3 -0
- viseda/text/eda.py +1613 -0
- viseda/utils/__init__.py +27 -0
- viseda/utils/helpers.py +120 -0
- viseda/video/__init__.py +3 -0
- viseda/video/eda.py +587 -0
- viseda-1.0.0.dist-info/METADATA +266 -0
- viseda-1.0.0.dist-info/RECORD +24 -0
- viseda-1.0.0.dist-info/WHEEL +5 -0
- viseda-1.0.0.dist-info/entry_points.txt +2 -0
- viseda-1.0.0.dist-info/licenses/LICENSE +21 -0
- viseda-1.0.0.dist-info/top_level.txt +1 -0
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"]
|