plotaviz 0.1.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.
- plotaviz/__init__.py +19 -0
- plotaviz/core/__init__.py +53 -0
- plotaviz/core/analysis.py +230 -0
- plotaviz/core/codegen.py +482 -0
- plotaviz/core/errors.py +65 -0
- plotaviz/core/exporter.py +145 -0
- plotaviz/core/llm/__init__.py +211 -0
- plotaviz/core/llm/anthropic.py +61 -0
- plotaviz/core/llm/base.py +373 -0
- plotaviz/core/llm/gemini.py +51 -0
- plotaviz/core/llm/ollama.py +113 -0
- plotaviz/core/llm/openai.py +58 -0
- plotaviz/core/loader.py +366 -0
- plotaviz/core/plotter.py +790 -0
- plotaviz/core/preprocess.py +836 -0
- plotaviz/core/profiler.py +509 -0
- plotaviz/core/rules.yaml +190 -0
- plotaviz/core/selector.py +495 -0
- plotaviz/core/session.py +174 -0
- plotaviz/core/spec.py +236 -0
- plotaviz/main.py +329 -0
- plotaviz/ui/__init__.py +23 -0
- plotaviz/ui/export_dialog.py +228 -0
- plotaviz/ui/filter_panel.py +273 -0
- plotaviz/ui/interactive_view.py +210 -0
- plotaviz/ui/main_window.py +859 -0
- plotaviz/ui/nl_query_bar.py +219 -0
- plotaviz/ui/settings_dialog.py +313 -0
- plotaviz/ui/table_model.py +184 -0
- plotaviz/ui/type_override.py +159 -0
- plotaviz/ui/workers.py +222 -0
- plotaviz-0.1.0.dist-info/METADATA +410 -0
- plotaviz-0.1.0.dist-info/RECORD +37 -0
- plotaviz-0.1.0.dist-info/WHEEL +5 -0
- plotaviz-0.1.0.dist-info/entry_points.txt +2 -0
- plotaviz-0.1.0.dist-info/licenses/LICENSE +21 -0
- plotaviz-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Chart selection — a rules + scoring hybrid.
|
|
2
|
+
|
|
3
|
+
Two layers, deliberately separated:
|
|
4
|
+
|
|
5
|
+
**Rules** (``rules.yaml``) map a data *shape* to candidate chart types. They are broad and
|
|
6
|
+
generous: a datetime plus a measure proposes line, area, and scatter, and does not try to decide
|
|
7
|
+
between them.
|
|
8
|
+
|
|
9
|
+
**Scoring** ranks those candidates against the actual data — cardinality, missingness,
|
|
10
|
+
distribution skew, correlation strength, and readability limits. This is where "you have 400
|
|
11
|
+
categories, a bar chart will be unreadable" gets expressed, as a penalty rather than a
|
|
12
|
+
prohibition.
|
|
13
|
+
|
|
14
|
+
Every returned :class:`~plotaviz.core.spec.ChartSpec` carries a ``why`` string assembled from the
|
|
15
|
+
rule's rationale plus whatever the scoring layer had to say. The user always sees the reasoning,
|
|
16
|
+
and the ranked alternatives are all real specs they can switch to.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import itertools
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from functools import partial
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
from .errors import SelectionError
|
|
30
|
+
from .profiler import BOOLEAN, CATEGORICAL, DATETIME, NUMERIC, TEXT, DatasetProfile, as_float
|
|
31
|
+
from .spec import MATRIX_CHARTS, ChartSpec
|
|
32
|
+
|
|
33
|
+
#: Location of the shipped rules file.
|
|
34
|
+
RULES_PATH = Path(__file__).with_name("rules.yaml")
|
|
35
|
+
|
|
36
|
+
#: How many columns per role a rule slot is allowed to explore. Keeps the candidate space small.
|
|
37
|
+
_MAX_COLUMNS_PER_SLOT = 3
|
|
38
|
+
|
|
39
|
+
#: Cap on returned recommendations.
|
|
40
|
+
DEFAULT_TOP_K = 8
|
|
41
|
+
|
|
42
|
+
_ROLE_TOKENS = {
|
|
43
|
+
"numeric": NUMERIC,
|
|
44
|
+
"categorical": CATEGORICAL,
|
|
45
|
+
"datetime": DATETIME,
|
|
46
|
+
"boolean": BOOLEAN,
|
|
47
|
+
"text": TEXT,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RulesConfig:
|
|
53
|
+
"""The parsed contents of ``rules.yaml``.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
rules: Rule definitions in file order.
|
|
57
|
+
scoring: Scoring caps and weights.
|
|
58
|
+
version: Config schema version.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
rules: list[dict[str, Any]]
|
|
62
|
+
scoring: dict[str, Any]
|
|
63
|
+
version: int = 1
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def weights(self) -> dict[str, float]:
|
|
67
|
+
"""Scoring weights, with sane defaults if the file omits any."""
|
|
68
|
+
defaults = {
|
|
69
|
+
"cardinality_penalty": 0.35,
|
|
70
|
+
"missing_penalty": 0.25,
|
|
71
|
+
"correlation_bonus": 0.30,
|
|
72
|
+
"skew_bonus": 0.15,
|
|
73
|
+
"timespan_bonus": 0.10,
|
|
74
|
+
"identifier_penalty": 0.60,
|
|
75
|
+
"constant_penalty": 0.50,
|
|
76
|
+
}
|
|
77
|
+
return {**defaults, **(self.scoring.get("weights") or {})}
|
|
78
|
+
|
|
79
|
+
def cap(self, name: str, default: int) -> int:
|
|
80
|
+
"""Read a readability cap such as ``max_bar_categories``."""
|
|
81
|
+
try:
|
|
82
|
+
return int(self.scoring.get(name, default))
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return default
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_rules(path: str | Path | None = None) -> RulesConfig:
|
|
88
|
+
"""Load the selection rules.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
path: Alternate rules file. Defaults to the one shipped with the package, so users can
|
|
92
|
+
point at their own without editing the install.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
SelectionError: If the file is missing or is not valid YAML with a ``rules`` list.
|
|
96
|
+
"""
|
|
97
|
+
target = Path(path) if path else RULES_PATH
|
|
98
|
+
try:
|
|
99
|
+
with open(target, encoding="utf-8") as handle:
|
|
100
|
+
data = yaml.safe_load(handle) or {}
|
|
101
|
+
except FileNotFoundError as exc:
|
|
102
|
+
raise SelectionError(
|
|
103
|
+
f"Chart rules file not found at {target}.",
|
|
104
|
+
hint="Reinstall PlotaViz, or point at your own rules file.",
|
|
105
|
+
) from exc
|
|
106
|
+
except yaml.YAMLError as exc:
|
|
107
|
+
raise SelectionError(
|
|
108
|
+
f"Chart rules file {target.name} is not valid YAML.", hint=str(exc)
|
|
109
|
+
) from exc
|
|
110
|
+
|
|
111
|
+
rules = data.get("rules")
|
|
112
|
+
if not isinstance(rules, list) or not rules:
|
|
113
|
+
raise SelectionError(f"{target.name} contains no rules.")
|
|
114
|
+
|
|
115
|
+
return RulesConfig(
|
|
116
|
+
rules=rules, scoring=data.get("scoring") or {}, version=int(data.get("version", 1))
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ChartSelector:
|
|
121
|
+
"""Turns a :class:`~plotaviz.core.profiler.DatasetProfile` into ranked chart specs.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
config: Loaded rules. Defaults to the shipped ``rules.yaml``.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
#: At most this many variants of one chart type survive into the ranked alternatives. Without
|
|
128
|
+
#: a cap, a wide dataset returns eight line charts and the user has no real choice to make.
|
|
129
|
+
max_per_chart_type: int = 2
|
|
130
|
+
|
|
131
|
+
def __init__(self, config: RulesConfig | None = None) -> None:
|
|
132
|
+
self.config = config or load_rules()
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------ public API
|
|
135
|
+
|
|
136
|
+
def recommend(
|
|
137
|
+
self,
|
|
138
|
+
profile: DatasetProfile,
|
|
139
|
+
*,
|
|
140
|
+
top_k: int = DEFAULT_TOP_K,
|
|
141
|
+
prefer_columns: list[str] | None = None,
|
|
142
|
+
) -> list[ChartSpec]:
|
|
143
|
+
"""Rank chart specs for a dataset, best first.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
profile: The dataset profile.
|
|
147
|
+
top_k: Maximum number of recommendations to return.
|
|
148
|
+
prefer_columns: Columns the user has signalled interest in (selected in the UI, or
|
|
149
|
+
named in a natural-language query). Candidates using them are boosted.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Ranked :class:`ChartSpec` objects, each with a populated ``why`` and ``score``.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
SelectionError: If no rule matches — a dataset of nothing but identifiers, say.
|
|
156
|
+
"""
|
|
157
|
+
columns_by_role = self._rank_columns(profile)
|
|
158
|
+
candidates = self._candidates_for(profile, columns_by_role)
|
|
159
|
+
|
|
160
|
+
if not candidates and any(prof.is_identifier for prof in profile.columns.values()):
|
|
161
|
+
# Every usable column was filtered out as an identifier. Rather than refusing to draw
|
|
162
|
+
# anything — which is what a table of IDs and a dense integer key looks like — offer
|
|
163
|
+
# the identifier columns too, and say so in the justification. A chart the user can
|
|
164
|
+
# correct beats a dead end.
|
|
165
|
+
columns_by_role = self._rank_columns(profile, include_identifiers=True)
|
|
166
|
+
candidates = self._candidates_for(profile, columns_by_role)
|
|
167
|
+
for spec in candidates:
|
|
168
|
+
spec.why += (
|
|
169
|
+
" Every column in this dataset looks like an identifier, so this chart uses "
|
|
170
|
+
"one anyway — use the type override panel if that is wrong."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if not candidates:
|
|
174
|
+
raise SelectionError(
|
|
175
|
+
"PlotaViz could not find a chart that suits this dataset.",
|
|
176
|
+
hint=(
|
|
177
|
+
"This usually means every column looks like an identifier or free text. "
|
|
178
|
+
"Use the type override panel to mark a column as numeric, categorical, or a "
|
|
179
|
+
"date."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Rank on the unclipped score. `ChartSpec.score` is a 0–1 confidence for display, and
|
|
184
|
+
# several strong candidates can all saturate at 1.0 — sorting on that would scramble a
|
|
185
|
+
# ranking the raw numbers get right.
|
|
186
|
+
scored = [self._score(spec, profile, prefer_columns or []) for spec in candidates]
|
|
187
|
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
|
188
|
+
return self._dedupe([spec for spec, _ in scored])[:top_k]
|
|
189
|
+
|
|
190
|
+
def best(self, profile: DatasetProfile, **kwargs: Any) -> ChartSpec:
|
|
191
|
+
"""The single top recommendation. Convenience for CLI ``--auto`` mode."""
|
|
192
|
+
return self.recommend(profile, **kwargs)[0]
|
|
193
|
+
|
|
194
|
+
def is_ambiguous(self, ranked: list[ChartSpec], *, margin: float = 0.06) -> bool:
|
|
195
|
+
"""Whether the top two recommendations are close enough to be a coin flip.
|
|
196
|
+
|
|
197
|
+
The UI uses this to decide when consulting an LLM is worth the latency: a clear winner
|
|
198
|
+
needs no second opinion.
|
|
199
|
+
"""
|
|
200
|
+
return len(ranked) >= 2 and (ranked[0].score - ranked[1].score) < margin
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------ internals
|
|
203
|
+
|
|
204
|
+
def _candidates_for(
|
|
205
|
+
self, profile: DatasetProfile, columns: dict[str, list[str]]
|
|
206
|
+
) -> list[ChartSpec]:
|
|
207
|
+
"""Expand every rule that matches the available columns into concrete specs."""
|
|
208
|
+
candidates: list[ChartSpec] = []
|
|
209
|
+
for rule in self.config.rules:
|
|
210
|
+
if not self._rule_matches(rule, columns):
|
|
211
|
+
continue
|
|
212
|
+
for candidate in rule.get("candidates", []):
|
|
213
|
+
candidates.extend(self._expand(rule, candidate, columns, profile))
|
|
214
|
+
return candidates
|
|
215
|
+
|
|
216
|
+
def _rank_columns(
|
|
217
|
+
self, profile: DatasetProfile, *, include_identifiers: bool = False
|
|
218
|
+
) -> dict[str, list[str]]:
|
|
219
|
+
"""Order each role's columns by how good an axis they would make.
|
|
220
|
+
|
|
221
|
+
Identifiers are excluded unless the caller asks for them back. Within a role, prefer
|
|
222
|
+
complete columns, then moderate cardinality (a 4-category column beats a 400-category one
|
|
223
|
+
for an axis).
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def sort_key(name: str, *, role: str) -> tuple[float, float]:
|
|
227
|
+
prof = profile.columns[name]
|
|
228
|
+
if role in (CATEGORICAL, TEXT):
|
|
229
|
+
# Distance from an ideal ~6 categories, so tiny and huge both rank lower.
|
|
230
|
+
cardinality_cost = abs(prof.n_unique - 6) / 100.0
|
|
231
|
+
else:
|
|
232
|
+
cardinality_cost = 0.0
|
|
233
|
+
return (prof.pct_missing / 100.0, cardinality_cost)
|
|
234
|
+
|
|
235
|
+
ranked: dict[str, list[str]] = {}
|
|
236
|
+
for role in _ROLE_TOKENS.values():
|
|
237
|
+
names = profile.by_role(role, include_identifiers=include_identifiers)
|
|
238
|
+
ranked[role] = sorted(names, key=partial(sort_key, role=role))
|
|
239
|
+
return ranked
|
|
240
|
+
|
|
241
|
+
def _rule_matches(self, rule: dict[str, Any], columns: dict[str, list[str]]) -> bool:
|
|
242
|
+
"""Whether the dataset has at least the columns a rule requires."""
|
|
243
|
+
requires = rule.get("requires") or {}
|
|
244
|
+
for token, needed in requires.items():
|
|
245
|
+
role = _ROLE_TOKENS.get(str(token))
|
|
246
|
+
if role is None:
|
|
247
|
+
return False
|
|
248
|
+
if len(columns.get(role, [])) < int(needed):
|
|
249
|
+
return False
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
def _expand(
|
|
253
|
+
self,
|
|
254
|
+
rule: dict[str, Any],
|
|
255
|
+
candidate: dict[str, Any],
|
|
256
|
+
columns: dict[str, list[str]],
|
|
257
|
+
profile: DatasetProfile,
|
|
258
|
+
) -> list[ChartSpec]:
|
|
259
|
+
"""Turn one rule candidate into concrete specs by resolving column placeholders.
|
|
260
|
+
|
|
261
|
+
A candidate like ``{x: categorical.0, y: numeric.0}`` produces several specs — the top
|
|
262
|
+
few columns of each role — so the user gets real alternatives rather than one guess.
|
|
263
|
+
"""
|
|
264
|
+
mapping = candidate.get("map") or {}
|
|
265
|
+
if not mapping and candidate.get("chart") in MATRIX_CHARTS:
|
|
266
|
+
return [
|
|
267
|
+
ChartSpec(
|
|
268
|
+
chart=str(candidate["chart"]),
|
|
269
|
+
why=str(candidate.get("why", "")),
|
|
270
|
+
score=float(candidate.get("base", 0.5)),
|
|
271
|
+
options=dict(candidate.get("options") or {}),
|
|
272
|
+
agg=candidate.get("agg"),
|
|
273
|
+
)
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
# Resolve every placeholder to the list of columns it could mean.
|
|
277
|
+
options_per_slot: dict[str, list[str | None]] = {}
|
|
278
|
+
for slot, token in mapping.items():
|
|
279
|
+
token = str(token)
|
|
280
|
+
optional = token.startswith("?")
|
|
281
|
+
role_name, _, index_text = token.lstrip("?").partition(".")
|
|
282
|
+
role = _ROLE_TOKENS.get(role_name)
|
|
283
|
+
if role is None:
|
|
284
|
+
return []
|
|
285
|
+
available = columns.get(role, [])
|
|
286
|
+
try:
|
|
287
|
+
start = int(index_text) if index_text else 0
|
|
288
|
+
except ValueError:
|
|
289
|
+
start = 0
|
|
290
|
+
|
|
291
|
+
picks: list[str | None] = list(available[start : start + _MAX_COLUMNS_PER_SLOT])
|
|
292
|
+
if optional:
|
|
293
|
+
picks = [None, *picks[:1]] # no grouping, or the single best grouping column
|
|
294
|
+
if not picks:
|
|
295
|
+
if not optional:
|
|
296
|
+
return []
|
|
297
|
+
picks = [None]
|
|
298
|
+
options_per_slot[slot] = picks
|
|
299
|
+
|
|
300
|
+
specs: list[ChartSpec] = []
|
|
301
|
+
slots = list(options_per_slot)
|
|
302
|
+
for combo in itertools.product(*(options_per_slot[s] for s in slots)):
|
|
303
|
+
assignment = dict(zip(slots, combo, strict=True))
|
|
304
|
+
chosen = [c for c in combo if c is not None]
|
|
305
|
+
if len(set(chosen)) != len(chosen):
|
|
306
|
+
continue # the same column mapped twice is never meaningful
|
|
307
|
+
|
|
308
|
+
spec = ChartSpec(
|
|
309
|
+
chart=str(candidate["chart"]),
|
|
310
|
+
x=assignment.get("x"),
|
|
311
|
+
y=assignment.get("y"),
|
|
312
|
+
color=assignment.get("color"),
|
|
313
|
+
agg=candidate.get("agg"),
|
|
314
|
+
options=dict(candidate.get("options") or {}),
|
|
315
|
+
why=str(candidate.get("why", "")),
|
|
316
|
+
score=float(candidate.get("base", 0.5)),
|
|
317
|
+
source="rules",
|
|
318
|
+
)
|
|
319
|
+
spec.options.setdefault("rule", rule.get("name", ""))
|
|
320
|
+
try:
|
|
321
|
+
spec.validate(list(profile.columns))
|
|
322
|
+
except Exception:
|
|
323
|
+
continue
|
|
324
|
+
specs.append(spec)
|
|
325
|
+
|
|
326
|
+
return specs
|
|
327
|
+
|
|
328
|
+
def _score(
|
|
329
|
+
self, spec: ChartSpec, profile: DatasetProfile, prefer: list[str]
|
|
330
|
+
) -> tuple[ChartSpec, float]:
|
|
331
|
+
"""Adjust a candidate's base score against the real data, recording the reasons.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
``(spec, raw_score)``. The spec carries the clipped 0–1 confidence for display; the
|
|
335
|
+
raw score is unbounded and is what the ranking sorts on.
|
|
336
|
+
"""
|
|
337
|
+
weights = self.config.weights
|
|
338
|
+
score = spec.score
|
|
339
|
+
notes: list[str] = []
|
|
340
|
+
|
|
341
|
+
# --- readability: too many categories on a categorical axis
|
|
342
|
+
cap_name = {
|
|
343
|
+
"pie": "max_pie_categories",
|
|
344
|
+
"treemap": "max_treemap_categories",
|
|
345
|
+
}.get(spec.chart, "max_bar_categories")
|
|
346
|
+
cap = self.config.cap(cap_name, 30)
|
|
347
|
+
|
|
348
|
+
axis_col = spec.x if spec.chart not in MATRIX_CHARTS else None
|
|
349
|
+
if axis_col and axis_col in profile.columns:
|
|
350
|
+
prof = profile.columns[axis_col]
|
|
351
|
+
if prof.role in (CATEGORICAL, TEXT, BOOLEAN):
|
|
352
|
+
if prof.n_unique > cap:
|
|
353
|
+
overflow = min(1.0, (prof.n_unique - cap) / max(cap, 1))
|
|
354
|
+
score -= weights["cardinality_penalty"] * overflow
|
|
355
|
+
notes.append(
|
|
356
|
+
f"{axis_col!r} has {prof.n_unique:,} categories, well past the {cap} "
|
|
357
|
+
"that stay readable, so this ranks lower."
|
|
358
|
+
)
|
|
359
|
+
elif spec.chart == "treemap" and prof.n_unique > 12:
|
|
360
|
+
score += 0.12
|
|
361
|
+
notes.append(
|
|
362
|
+
f"{prof.n_unique} categories is more than a bar chart handles "
|
|
363
|
+
"comfortably, which suits a treemap."
|
|
364
|
+
)
|
|
365
|
+
if prof.n_unique <= 1:
|
|
366
|
+
score -= weights["constant_penalty"]
|
|
367
|
+
notes.append(f"{axis_col!r} has a single value, so there is nothing to compare.")
|
|
368
|
+
|
|
369
|
+
# --- grouping series count
|
|
370
|
+
if spec.color and spec.color in profile.columns:
|
|
371
|
+
n_series = profile.columns[spec.color].n_unique
|
|
372
|
+
max_series = self.config.cap("max_grouped_series", 8)
|
|
373
|
+
if n_series > max_series:
|
|
374
|
+
score -= weights["cardinality_penalty"] * min(1.0, n_series / (max_series * 4))
|
|
375
|
+
notes.append(
|
|
376
|
+
f"Splitting by {spec.color!r} would draw {n_series:,} series, which is too "
|
|
377
|
+
"many to distinguish."
|
|
378
|
+
)
|
|
379
|
+
elif 2 <= n_series <= max_series:
|
|
380
|
+
score += 0.05
|
|
381
|
+
notes.append(f"Splitting by {spec.color!r} adds {n_series} comparable series.")
|
|
382
|
+
|
|
383
|
+
# --- missing data across the mapped columns
|
|
384
|
+
used = [c for c in spec.columns_used if c in profile.columns]
|
|
385
|
+
if used:
|
|
386
|
+
worst = max(profile.columns[c].pct_missing for c in used)
|
|
387
|
+
if worst > 5:
|
|
388
|
+
score -= weights["missing_penalty"] * min(1.0, worst / 100.0)
|
|
389
|
+
notes.append(f"Up to {worst:.0f}% of values in the columns used are missing.")
|
|
390
|
+
|
|
391
|
+
# --- identifiers should almost never be plotted
|
|
392
|
+
if any(profile.columns[c].is_identifier for c in used):
|
|
393
|
+
score -= weights["identifier_penalty"]
|
|
394
|
+
notes.append(
|
|
395
|
+
"One of the mapped columns looks like an identifier rather than a measure."
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# --- correlation strength for scatter
|
|
399
|
+
if spec.chart == "scatter" and spec.x and spec.y and profile.correlations is not None:
|
|
400
|
+
corr = profile.correlations
|
|
401
|
+
if spec.x in corr.columns and spec.y in corr.columns:
|
|
402
|
+
r = as_float(corr.loc[spec.x, spec.y])
|
|
403
|
+
if r is not None:
|
|
404
|
+
score += weights["correlation_bonus"] * abs(r)
|
|
405
|
+
strength = "strong" if abs(r) > 0.7 else "moderate" if abs(r) > 0.4 else "weak"
|
|
406
|
+
direction = "positive" if r > 0 else "negative"
|
|
407
|
+
notes.append(
|
|
408
|
+
f"These two columns have a {strength} {direction} correlation "
|
|
409
|
+
f"(r = {r:.2f})."
|
|
410
|
+
)
|
|
411
|
+
if abs(r) > 0.4:
|
|
412
|
+
spec.options.setdefault("trendline", True)
|
|
413
|
+
|
|
414
|
+
# --- skew favours distribution-shape charts and log scales
|
|
415
|
+
if spec.y and spec.y in profile.columns:
|
|
416
|
+
skew = abs(profile.columns[spec.y].stats.get("skew", 0.0))
|
|
417
|
+
if skew > 2:
|
|
418
|
+
if spec.chart in {"box", "violin"}:
|
|
419
|
+
score += weights["skew_bonus"]
|
|
420
|
+
notes.append(
|
|
421
|
+
f"{spec.y!r} is heavily skewed, which this chart shows better than "
|
|
422
|
+
"an average."
|
|
423
|
+
)
|
|
424
|
+
elif spec.chart in {"bar", "line", "scatter"}:
|
|
425
|
+
spec.options.setdefault("log_y", True)
|
|
426
|
+
notes.append(f"{spec.y!r} is heavily skewed, so a log y axis is suggested.")
|
|
427
|
+
|
|
428
|
+
# --- a real time span makes a time series more compelling
|
|
429
|
+
if spec.chart in {"line", "area"} and spec.x and spec.x in profile.columns:
|
|
430
|
+
prof = profile.columns[spec.x]
|
|
431
|
+
if prof.role == DATETIME:
|
|
432
|
+
periods = prof.stats.get("n_periods", 0)
|
|
433
|
+
if periods >= 10:
|
|
434
|
+
score += weights["timespan_bonus"]
|
|
435
|
+
notes.append(
|
|
436
|
+
f"{int(periods):,} distinct time points give the trend real shape."
|
|
437
|
+
)
|
|
438
|
+
elif periods < 3:
|
|
439
|
+
score -= 0.25
|
|
440
|
+
notes.append(
|
|
441
|
+
f"Only {int(periods)} distinct time point(s) — too few to show a trend."
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# --- histogram bin sanity
|
|
445
|
+
if spec.chart in {"histogram", "kde"} and profile.n_rows < self.config.cap(
|
|
446
|
+
"ideal_hist_rows", 30
|
|
447
|
+
):
|
|
448
|
+
score -= 0.15
|
|
449
|
+
notes.append(f"Only {profile.n_rows} rows, which makes the distribution shape noisy.")
|
|
450
|
+
|
|
451
|
+
# --- user or NL query interest
|
|
452
|
+
if prefer:
|
|
453
|
+
hits = [c for c in used if c in prefer]
|
|
454
|
+
if hits:
|
|
455
|
+
score += 0.15 * len(hits)
|
|
456
|
+
notes.append(f"Uses the column(s) you asked about: {', '.join(hits)}.")
|
|
457
|
+
|
|
458
|
+
spec.score = max(0.0, min(1.0, score))
|
|
459
|
+
spec.why = " ".join([spec.why, *notes]).strip()
|
|
460
|
+
return spec, score
|
|
461
|
+
|
|
462
|
+
def _dedupe(self, specs: list[ChartSpec]) -> list[ChartSpec]:
|
|
463
|
+
"""Drop duplicate mappings and keep the alternatives genuinely varied.
|
|
464
|
+
|
|
465
|
+
Two passes: exact duplicate mappings go, then each chart type is capped so the list the
|
|
466
|
+
user picks from offers different *kinds* of chart rather than eight near-identical ones.
|
|
467
|
+
Anything squeezed out by the cap is appended after the varied set, so nothing is lost.
|
|
468
|
+
"""
|
|
469
|
+
seen: set[tuple[Any, ...]] = set()
|
|
470
|
+
unique: list[ChartSpec] = []
|
|
471
|
+
for spec in specs:
|
|
472
|
+
key = (spec.chart, spec.x, spec.y, spec.color, spec.agg)
|
|
473
|
+
if key in seen:
|
|
474
|
+
continue
|
|
475
|
+
seen.add(key)
|
|
476
|
+
unique.append(spec)
|
|
477
|
+
|
|
478
|
+
per_type: dict[str, int] = {}
|
|
479
|
+
varied: list[ChartSpec] = []
|
|
480
|
+
overflow: list[ChartSpec] = []
|
|
481
|
+
for spec in unique:
|
|
482
|
+
count = per_type.get(spec.chart, 0)
|
|
483
|
+
if count < self.max_per_chart_type:
|
|
484
|
+
per_type[spec.chart] = count + 1
|
|
485
|
+
varied.append(spec)
|
|
486
|
+
else:
|
|
487
|
+
overflow.append(spec)
|
|
488
|
+
return varied + overflow
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def recommend(
|
|
492
|
+
profile: DatasetProfile, *, top_k: int = DEFAULT_TOP_K, rules_path: str | Path | None = None
|
|
493
|
+
) -> list[ChartSpec]:
|
|
494
|
+
"""Convenience wrapper: build a selector and rank charts in one call."""
|
|
495
|
+
return ChartSelector(load_rules(rules_path)).recommend(profile, top_k=top_k)
|
plotaviz/core/session.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Session files — ``.pviz`` projects.
|
|
2
|
+
|
|
3
|
+
A session stores the *recipe*, never the data: source path and hash, type overrides, the
|
|
4
|
+
preprocessing step list, active filters, the chart spec, and view settings. Reopening replays the
|
|
5
|
+
recipe against the source file.
|
|
6
|
+
|
|
7
|
+
That design has one consequence worth being explicit about: if the source file changed after the
|
|
8
|
+
session was saved, the replay may produce something different. The stored SHA-256 is checked on
|
|
9
|
+
load and the mismatch is reported rather than swallowed.
|
|
10
|
+
|
|
11
|
+
Sessions never contain credentials. API keys live in the OS keyring.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .errors import SessionError
|
|
23
|
+
from .loader import file_hash
|
|
24
|
+
from .preprocess import Pipeline
|
|
25
|
+
from .spec import ChartSpec
|
|
26
|
+
|
|
27
|
+
#: Bump when the on-disk shape changes incompatibly.
|
|
28
|
+
SESSION_VERSION = 1
|
|
29
|
+
|
|
30
|
+
#: Session file extension.
|
|
31
|
+
SESSION_SUFFIX = ".pviz"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Session:
|
|
36
|
+
"""A saved PlotaViz project.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
source_path: Dataset the session was built from.
|
|
40
|
+
source_hash: SHA-256 of that file at save time.
|
|
41
|
+
type_overrides: ``{column: role}`` corrections the user made.
|
|
42
|
+
pipeline: The preprocessing steps, including filters.
|
|
43
|
+
spec: The chart that was on screen.
|
|
44
|
+
view: View settings — window geometry, active panel, renderer choice.
|
|
45
|
+
app_version: PlotaViz version that wrote the file.
|
|
46
|
+
saved_at: ISO-8601 UTC timestamp.
|
|
47
|
+
version: Session schema version.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
source_path: str
|
|
51
|
+
source_hash: str = ""
|
|
52
|
+
type_overrides: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
pipeline: Pipeline = field(default_factory=Pipeline)
|
|
54
|
+
spec: ChartSpec | None = None
|
|
55
|
+
view: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
app_version: str = "0.1.0"
|
|
57
|
+
saved_at: str = ""
|
|
58
|
+
version: int = SESSION_VERSION
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------ writing
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
"""Serialize to the JSON structure written to disk."""
|
|
64
|
+
return {
|
|
65
|
+
"version": self.version,
|
|
66
|
+
"app_version": self.app_version,
|
|
67
|
+
"saved_at": self.saved_at or datetime.now(UTC).isoformat(timespec="seconds"),
|
|
68
|
+
"source": {"path": str(self.source_path), "sha256": self.source_hash},
|
|
69
|
+
"type_overrides": dict(self.type_overrides),
|
|
70
|
+
"pipeline": self.pipeline.to_list(),
|
|
71
|
+
"chart": self.spec.to_dict() if self.spec else None,
|
|
72
|
+
"view": dict(self.view),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def save(self, path: str | Path) -> Path:
|
|
76
|
+
"""Write the session to ``path``, adding the ``.pviz`` suffix if absent.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
SessionError: If the file cannot be written.
|
|
80
|
+
"""
|
|
81
|
+
target = Path(path).expanduser()
|
|
82
|
+
if target.suffix != SESSION_SUFFIX:
|
|
83
|
+
target = target.with_suffix(SESSION_SUFFIX)
|
|
84
|
+
|
|
85
|
+
source = Path(self.source_path)
|
|
86
|
+
if source.exists() and not self.source_hash:
|
|
87
|
+
self.source_hash = file_hash(source)
|
|
88
|
+
self.saved_at = datetime.now(UTC).isoformat(timespec="seconds")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
target.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise SessionError(f"Could not save the session to {target}.", hint=str(exc)) from exc
|
|
95
|
+
return target
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ reading
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def load(cls, path: str | Path) -> Session:
|
|
101
|
+
"""Read a ``.pviz`` file.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
SessionError: If the file is missing, is not valid JSON, or was written by a newer
|
|
105
|
+
incompatible version.
|
|
106
|
+
"""
|
|
107
|
+
target = Path(path).expanduser()
|
|
108
|
+
try:
|
|
109
|
+
payload = json.loads(target.read_text(encoding="utf-8"))
|
|
110
|
+
except FileNotFoundError as exc:
|
|
111
|
+
raise SessionError(f"No session file at {target}.") from exc
|
|
112
|
+
except json.JSONDecodeError as exc:
|
|
113
|
+
raise SessionError(
|
|
114
|
+
f"{target.name} is not a valid PlotaViz session file.", hint=str(exc)
|
|
115
|
+
) from exc
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise SessionError(f"Could not read {target}.", hint=str(exc)) from exc
|
|
118
|
+
|
|
119
|
+
version = int(payload.get("version", 0))
|
|
120
|
+
if version > SESSION_VERSION:
|
|
121
|
+
raise SessionError(
|
|
122
|
+
f"{target.name} was saved by a newer version of PlotaViz "
|
|
123
|
+
f"(session format {version}, this build understands {SESSION_VERSION}).",
|
|
124
|
+
hint="Update PlotaViz to open it.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
source = payload.get("source") or {}
|
|
128
|
+
chart = payload.get("chart")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
pipeline = Pipeline.from_list(payload.get("pipeline") or [])
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
raise SessionError(
|
|
134
|
+
f"The preprocessing steps in {target.name} could not be restored.",
|
|
135
|
+
hint=str(exc),
|
|
136
|
+
) from exc
|
|
137
|
+
|
|
138
|
+
return cls(
|
|
139
|
+
source_path=str(source.get("path", "")),
|
|
140
|
+
source_hash=str(source.get("sha256", "")),
|
|
141
|
+
type_overrides=dict(payload.get("type_overrides") or {}),
|
|
142
|
+
pipeline=pipeline,
|
|
143
|
+
spec=ChartSpec.from_dict(chart) if chart else None,
|
|
144
|
+
view=dict(payload.get("view") or {}),
|
|
145
|
+
app_version=str(payload.get("app_version", "")),
|
|
146
|
+
saved_at=str(payload.get("saved_at", "")),
|
|
147
|
+
version=version,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------ validation
|
|
151
|
+
|
|
152
|
+
def check_source(self) -> str | None:
|
|
153
|
+
"""Verify the source file is where it was and unchanged.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
``None`` when everything matches, otherwise a user-facing warning describing the
|
|
157
|
+
discrepancy. The caller decides whether to continue — usually it should, because a
|
|
158
|
+
changed source is normal and only worth mentioning.
|
|
159
|
+
"""
|
|
160
|
+
source = Path(self.source_path)
|
|
161
|
+
if not self.source_path:
|
|
162
|
+
return "This session does not record which file it came from."
|
|
163
|
+
if not source.exists():
|
|
164
|
+
return (
|
|
165
|
+
f"The source file {source} is missing. Open the dataset again to restore this "
|
|
166
|
+
"session."
|
|
167
|
+
)
|
|
168
|
+
if self.source_hash and file_hash(source) != self.source_hash:
|
|
169
|
+
saved = self.saved_at or "the time it was saved"
|
|
170
|
+
return (
|
|
171
|
+
f"{source.name} has changed since {saved}. The cleaning steps and chart will be "
|
|
172
|
+
"replayed against the new contents, so results may differ."
|
|
173
|
+
)
|
|
174
|
+
return None
|