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
plotaviz/core/plotter.py
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""Figure construction — Plotly for the interactive view, matplotlib for static export.
|
|
2
|
+
|
|
3
|
+
Both renderers consume the same :class:`~plotaviz.core.spec.ChartSpec` and, crucially, the same
|
|
4
|
+
*prepared* data. :func:`prepare` does the aggregating, sorting, category capping, and
|
|
5
|
+
downsampling once, so the interactive chart and the exported PNG cannot disagree with each other.
|
|
6
|
+
|
|
7
|
+
The performance guardrails live here rather than in the UI because they have to apply to CLI
|
|
8
|
+
exports too. Above roughly 100k points a scatter or line will lock up a WebEngine view, so the
|
|
9
|
+
data is downsampled — LTTB for time series, which preserves visual shape far better than random
|
|
10
|
+
sampling — and the caller is handed a note saying exactly what was shown. A chart that quietly
|
|
11
|
+
drops 90% of its data is worse than no chart.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from .errors import PlotError
|
|
23
|
+
from .spec import MATRIX_CHARTS, ChartSpec
|
|
24
|
+
|
|
25
|
+
#: Above this many points a scatter/line becomes unresponsive in a browser view.
|
|
26
|
+
MAX_POINTS = 100_000
|
|
27
|
+
|
|
28
|
+
#: Default cap on distinct categories drawn on a categorical axis.
|
|
29
|
+
MAX_CATEGORIES = 30
|
|
30
|
+
|
|
31
|
+
#: Label used for the bucket holding categories beyond the cap.
|
|
32
|
+
OTHER_LABEL = "Other"
|
|
33
|
+
|
|
34
|
+
#: Charts whose point count scales with row count, so they need downsampling.
|
|
35
|
+
_DENSE_CHARTS = frozenset({"scatter", "line", "area"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PreparedData:
|
|
40
|
+
"""Plot-ready data plus everything the user needs told about it.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
df: The frame to render.
|
|
44
|
+
spec: The **effective** spec — the caller's spec after aggregation renames the measure
|
|
45
|
+
column. Renderers use this one; it describes :attr:`df`, not the source frame.
|
|
46
|
+
source_spec: The spec as the caller supplied it, untouched.
|
|
47
|
+
notes: User-facing remarks — sampling, category bucketing, dropped nulls.
|
|
48
|
+
sampled: Whether rows were dropped for performance.
|
|
49
|
+
rows_original: Row count before preparation.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
df: pd.DataFrame
|
|
53
|
+
spec: ChartSpec
|
|
54
|
+
source_spec: ChartSpec | None = None
|
|
55
|
+
notes: list[str] = field(default_factory=list)
|
|
56
|
+
sampled: bool = False
|
|
57
|
+
rows_original: int = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def sampling_notice(self) -> str:
|
|
61
|
+
"""The "showing N of M points" line, or an empty string when nothing was dropped."""
|
|
62
|
+
if not self.sampled:
|
|
63
|
+
return ""
|
|
64
|
+
return f"Showing a sample of {len(self.df):,} of {self.rows_original:,} points."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------- preparation
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
#: Name given to the measure column produced by a counting aggregation.
|
|
71
|
+
COUNT_COLUMN = "count"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def effective_spec(spec: ChartSpec) -> ChartSpec:
|
|
75
|
+
"""The spec as it describes the data *after* aggregation.
|
|
76
|
+
|
|
77
|
+
Aggregating changes the frame's shape: ``count`` invents a measure column that does not exist
|
|
78
|
+
in the source data. Renderers need a spec that matches what they are handed, but the caller's
|
|
79
|
+
spec must keep matching the source frame — it still has to validate, serialize into a session,
|
|
80
|
+
and generate code. So aggregation produces a separate spec instead of editing one in place.
|
|
81
|
+
|
|
82
|
+
The title is resolved here too, before the measure is renamed, so a counted bar chart is
|
|
83
|
+
titled "Count by department" rather than "Count of count by department".
|
|
84
|
+
"""
|
|
85
|
+
out = spec.copy(title=spec.display_title())
|
|
86
|
+
if not spec.agg:
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
if spec.chart == "heatmap":
|
|
90
|
+
# For a heatmap x and y are both axes, so the measure lives in `color`.
|
|
91
|
+
if not out.color:
|
|
92
|
+
out.color = COUNT_COLUMN
|
|
93
|
+
out.agg = "count"
|
|
94
|
+
elif spec.agg == "count" or not spec.y:
|
|
95
|
+
out.y = COUNT_COLUMN
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prepare(
|
|
100
|
+
df: pd.DataFrame,
|
|
101
|
+
spec: ChartSpec,
|
|
102
|
+
*,
|
|
103
|
+
max_points: int = MAX_POINTS,
|
|
104
|
+
max_categories: int | None = None,
|
|
105
|
+
) -> PreparedData:
|
|
106
|
+
"""Aggregate, cap, and downsample a frame for one chart spec.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
df: The cleaned, filtered data.
|
|
110
|
+
spec: What to draw. Never modified.
|
|
111
|
+
max_points: Downsampling threshold for dense charts.
|
|
112
|
+
max_categories: Category cap. Defaults to ``spec.options['top_n']`` or
|
|
113
|
+
:data:`MAX_CATEGORIES`.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A :class:`PreparedData` whose ``spec`` is the effective spec describing ``df``.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
PlotError: If the spec references missing columns or nothing plottable survives.
|
|
120
|
+
"""
|
|
121
|
+
spec.validate(list(df.columns))
|
|
122
|
+
eff = effective_spec(spec)
|
|
123
|
+
notes: list[str] = []
|
|
124
|
+
rows_original = len(df)
|
|
125
|
+
cap = int(max_categories or spec.options.get("top_n") or MAX_CATEGORIES)
|
|
126
|
+
|
|
127
|
+
if spec.chart in MATRIX_CHARTS:
|
|
128
|
+
numeric = df.select_dtypes(include="number")
|
|
129
|
+
# Outlier flag columns are booleans-as-numbers; they pollute a correlation matrix.
|
|
130
|
+
numeric = numeric[[c for c in numeric.columns if not str(c).endswith("__outlier")]]
|
|
131
|
+
if numeric.shape[1] < 2:
|
|
132
|
+
raise PlotError(
|
|
133
|
+
"This chart needs at least two numeric columns.",
|
|
134
|
+
hint="Use the type override panel if a numeric column was read as text.",
|
|
135
|
+
)
|
|
136
|
+
if numeric.shape[1] > 12:
|
|
137
|
+
keep = list(numeric.columns[:12])
|
|
138
|
+
notes.append(f"Showing the first 12 of {numeric.shape[1]} numeric columns.")
|
|
139
|
+
numeric = numeric[keep]
|
|
140
|
+
return PreparedData(numeric, eff, spec, notes, False, rows_original)
|
|
141
|
+
|
|
142
|
+
work = df[[c for c in spec.columns_used if c in df.columns]].copy()
|
|
143
|
+
|
|
144
|
+
# Drop rows that cannot be plotted, and say how many.
|
|
145
|
+
before = len(work)
|
|
146
|
+
work = work.dropna(subset=[c for c in (spec.x, spec.y) if c])
|
|
147
|
+
if len(work) < before:
|
|
148
|
+
notes.append(
|
|
149
|
+
f"Skipped {before - len(work):,} row(s) with missing values in the plotted columns."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if work.empty:
|
|
153
|
+
raise PlotError(
|
|
154
|
+
"No rows are left to plot once missing values are excluded.",
|
|
155
|
+
hint="Loosen the filters, or choose a different missing-value strategy.",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Cap categories on the x axis (and on the colour split) before aggregating.
|
|
159
|
+
for column, is_axis in ((spec.x, True), (spec.color, False)):
|
|
160
|
+
if not column or column not in work.columns:
|
|
161
|
+
continue
|
|
162
|
+
if pd.api.types.is_numeric_dtype(work[column]) or pd.api.types.is_datetime64_any_dtype(
|
|
163
|
+
work[column]
|
|
164
|
+
):
|
|
165
|
+
continue
|
|
166
|
+
n_unique = work[column].nunique()
|
|
167
|
+
limit = cap if is_axis else min(cap, 12)
|
|
168
|
+
if n_unique > limit:
|
|
169
|
+
work, dropped = _cap_categories(work, column, limit, spec)
|
|
170
|
+
notes.append(
|
|
171
|
+
f"{column!r} has {n_unique:,} categories; showing the top {limit} and grouping "
|
|
172
|
+
f"the remaining {dropped:,} as {OTHER_LABEL!r}."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Aggregate if the spec asks for it.
|
|
176
|
+
if spec.agg:
|
|
177
|
+
work = _aggregate(work, spec, eff)
|
|
178
|
+
|
|
179
|
+
# Time series must be ordered, or the line zig-zags.
|
|
180
|
+
if eff.x and eff.x in work.columns and pd.api.types.is_datetime64_any_dtype(work[eff.x]):
|
|
181
|
+
work = work.sort_values(eff.x)
|
|
182
|
+
elif eff.chart in {"bar", "treemap", "pie"} and eff.y and eff.y in work.columns:
|
|
183
|
+
ascending = bool(eff.options.get("sort_ascending", False))
|
|
184
|
+
work = work.sort_values(eff.y, ascending=ascending)
|
|
185
|
+
|
|
186
|
+
# Downsample dense charts.
|
|
187
|
+
sampled = False
|
|
188
|
+
if eff.chart in _DENSE_CHARTS and len(work) > max_points:
|
|
189
|
+
work = _downsample(work, eff, max_points)
|
|
190
|
+
sampled = True
|
|
191
|
+
notes.append(
|
|
192
|
+
f"Showing a sample of {len(work):,} of {rows_original:,} points to keep the chart "
|
|
193
|
+
"responsive. The exported code uses the full dataset."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return PreparedData(work, eff, spec, notes, sampled, rows_original)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _cap_categories(
|
|
200
|
+
df: pd.DataFrame, column: str, limit: int, spec: ChartSpec
|
|
201
|
+
) -> tuple[pd.DataFrame, int]:
|
|
202
|
+
"""Keep the ``limit`` largest categories in ``column``; bucket the rest as "Other"."""
|
|
203
|
+
if spec.y and spec.y in df.columns and pd.api.types.is_numeric_dtype(df[spec.y]):
|
|
204
|
+
ranking = df.groupby(column, observed=True)[spec.y].sum().nlargest(limit)
|
|
205
|
+
else:
|
|
206
|
+
ranking = df[column].value_counts().nlargest(limit)
|
|
207
|
+
keep = set(ranking.index)
|
|
208
|
+
out = df.copy()
|
|
209
|
+
mask = ~out[column].isin(keep)
|
|
210
|
+
dropped = int(out.loc[mask, column].nunique())
|
|
211
|
+
out[column] = out[column].where(~mask, OTHER_LABEL)
|
|
212
|
+
return out, dropped
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def group_keys(*columns: str | None, available: list[str] | None = None) -> list[str]:
|
|
216
|
+
"""Group-by keys from a spec's mapping — present, ordered, and deduplicated.
|
|
217
|
+
|
|
218
|
+
A spec may legitimately map the same column to two roles (a bar chart coloured by its own x
|
|
219
|
+
axis is a common way to get per-category colours). Passing that column to ``groupby`` twice
|
|
220
|
+
makes ``reset_index`` fail with "cannot insert region, already exists", so the duplicate is
|
|
221
|
+
dropped here rather than surfacing as a crash mid-render.
|
|
222
|
+
"""
|
|
223
|
+
seen: list[str] = []
|
|
224
|
+
for column in columns:
|
|
225
|
+
if not column or column in seen:
|
|
226
|
+
continue
|
|
227
|
+
if available is not None and column not in available:
|
|
228
|
+
continue
|
|
229
|
+
seen.append(column)
|
|
230
|
+
return seen
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _group_keys(df: pd.DataFrame, *columns: str | None) -> list[str]:
|
|
234
|
+
""":func:`group_keys` restricted to the columns a frame actually has."""
|
|
235
|
+
return group_keys(*columns, available=list(df.columns))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _aggregate(df: pd.DataFrame, spec: ChartSpec, eff: ChartSpec) -> pd.DataFrame:
|
|
239
|
+
"""Group by the spec's keys and collapse the measure.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
df: Frame holding the source columns.
|
|
243
|
+
spec: The caller's spec, which names real source columns.
|
|
244
|
+
eff: The effective spec, which names the measure column to produce.
|
|
245
|
+
"""
|
|
246
|
+
keys = _group_keys(df, spec.x, spec.color)
|
|
247
|
+
measure = eff.y
|
|
248
|
+
if spec.chart == "heatmap":
|
|
249
|
+
keys = _group_keys(df, spec.x, spec.y)
|
|
250
|
+
measure = eff.color
|
|
251
|
+
if not keys or not measure:
|
|
252
|
+
return df
|
|
253
|
+
|
|
254
|
+
counting = spec.agg == "count" or not spec.y or spec.y not in df.columns
|
|
255
|
+
if spec.chart == "heatmap":
|
|
256
|
+
counting = eff.agg == "count" or not spec.color or spec.color not in df.columns
|
|
257
|
+
|
|
258
|
+
if counting:
|
|
259
|
+
return df.groupby(keys, observed=True, dropna=False).size().reset_index(name=measure)
|
|
260
|
+
|
|
261
|
+
source_measure = spec.color if spec.chart == "heatmap" else spec.y
|
|
262
|
+
if not source_measure:
|
|
263
|
+
return df
|
|
264
|
+
try:
|
|
265
|
+
grouped = df.groupby(keys, observed=True, dropna=False)[source_measure].agg(spec.agg)
|
|
266
|
+
except (TypeError, ValueError, KeyError) as exc:
|
|
267
|
+
raise PlotError(
|
|
268
|
+
f"Cannot compute the {spec.agg} of {source_measure!r} grouped by {', '.join(keys)}.",
|
|
269
|
+
hint="The measure column is probably not numeric. Check the type override panel.",
|
|
270
|
+
) from exc
|
|
271
|
+
out = grouped.reset_index()
|
|
272
|
+
if source_measure != measure:
|
|
273
|
+
out = out.rename(columns={source_measure: measure})
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _downsample(df: pd.DataFrame, spec: ChartSpec, max_points: int) -> pd.DataFrame:
|
|
278
|
+
"""Reduce a dense frame, preserving shape.
|
|
279
|
+
|
|
280
|
+
Time-ordered data uses LTTB (largest-triangle-three-buckets), which keeps peaks and troughs
|
|
281
|
+
a random sample would erase. Everything else takes a reproducible random sample.
|
|
282
|
+
"""
|
|
283
|
+
is_time_series = (
|
|
284
|
+
spec.x is not None
|
|
285
|
+
and spec.x in df.columns
|
|
286
|
+
and pd.api.types.is_datetime64_any_dtype(df[spec.x])
|
|
287
|
+
and spec.y is not None
|
|
288
|
+
and spec.y in df.columns
|
|
289
|
+
)
|
|
290
|
+
if is_time_series and spec.color is None:
|
|
291
|
+
return _lttb(df, str(spec.x), str(spec.y), max_points)
|
|
292
|
+
return df.sample(max_points, random_state=0).sort_index()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _lttb(df: pd.DataFrame, x: str, y: str, threshold: int) -> pd.DataFrame:
|
|
296
|
+
"""Largest-triangle-three-buckets downsampling for time series.
|
|
297
|
+
|
|
298
|
+
Keeps the first and last points, then picks from each bucket the point forming the largest
|
|
299
|
+
triangle with its neighbours — the standard way to shrink a series without flattening spikes.
|
|
300
|
+
"""
|
|
301
|
+
n = len(df)
|
|
302
|
+
if threshold >= n or threshold < 3:
|
|
303
|
+
return df
|
|
304
|
+
|
|
305
|
+
ordered = df.sort_values(x)
|
|
306
|
+
xs = pd.to_datetime(ordered[x]).astype("int64").to_numpy(dtype=float)
|
|
307
|
+
ys = pd.to_numeric(ordered[y], errors="coerce").to_numpy(dtype=float)
|
|
308
|
+
|
|
309
|
+
bucket_size = (n - 2) / (threshold - 2)
|
|
310
|
+
picked = [0]
|
|
311
|
+
a = 0
|
|
312
|
+
for i in range(threshold - 2):
|
|
313
|
+
start = int(np.floor((i + 1) * bucket_size)) + 1
|
|
314
|
+
end = min(int(np.floor((i + 2) * bucket_size)) + 1, n)
|
|
315
|
+
next_start = end
|
|
316
|
+
next_end = min(int(np.floor((i + 3) * bucket_size)) + 1, n)
|
|
317
|
+
if next_start >= next_end:
|
|
318
|
+
next_start, next_end = max(n - 1, start), n
|
|
319
|
+
|
|
320
|
+
avg_x = float(np.nanmean(xs[next_start:next_end])) if next_end > next_start else xs[-1]
|
|
321
|
+
avg_y = float(np.nanmean(ys[next_start:next_end])) if next_end > next_start else ys[-1]
|
|
322
|
+
|
|
323
|
+
if start >= end:
|
|
324
|
+
continue
|
|
325
|
+
areas = np.abs(
|
|
326
|
+
(xs[a] - avg_x) * (ys[start:end] - ys[a]) - (xs[a] - xs[start:end]) * (avg_y - ys[a])
|
|
327
|
+
)
|
|
328
|
+
offset = int(np.nanargmax(areas)) if np.isfinite(areas).any() else 0
|
|
329
|
+
a = start + offset
|
|
330
|
+
picked.append(a)
|
|
331
|
+
|
|
332
|
+
picked.append(n - 1)
|
|
333
|
+
return ordered.iloc[sorted(set(picked))]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def kde_curve(values: pd.Series, *, points: int = 200) -> tuple[np.ndarray, np.ndarray]:
|
|
337
|
+
"""Gaussian kernel density estimate on a fixed grid.
|
|
338
|
+
|
|
339
|
+
Implemented directly on numpy rather than pulling in scipy — the dependency is not worth one
|
|
340
|
+
curve, and Silverman's rule is a perfectly good default bandwidth here.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
``(grid, density)`` arrays suitable for a line plot.
|
|
344
|
+
"""
|
|
345
|
+
data = pd.to_numeric(values, errors="coerce").dropna().to_numpy(dtype=float)
|
|
346
|
+
if data.size < 2:
|
|
347
|
+
raise PlotError("A density curve needs at least two numeric values.")
|
|
348
|
+
|
|
349
|
+
std = float(np.std(data, ddof=1)) or 1.0
|
|
350
|
+
iqr = float(np.subtract(*np.percentile(data, [75, 25])))
|
|
351
|
+
spread = min(std, iqr / 1.349) if iqr > 0 else std
|
|
352
|
+
bandwidth = 0.9 * spread * data.size ** (-1 / 5) or 1.0
|
|
353
|
+
|
|
354
|
+
grid = np.linspace(data.min() - 3 * bandwidth, data.max() + 3 * bandwidth, points)
|
|
355
|
+
# (grid, data) distance matrix; fine for the sample sizes a KDE is meaningful on.
|
|
356
|
+
if data.size > 20_000:
|
|
357
|
+
data = np.random.default_rng(0).choice(data, 20_000, replace=False)
|
|
358
|
+
z = (grid[:, None] - data[None, :]) / bandwidth
|
|
359
|
+
density = np.exp(-0.5 * z**2).sum(axis=1) / (data.size * bandwidth * np.sqrt(2 * np.pi))
|
|
360
|
+
return grid, density
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------- Plotly
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def build_plotly(df: pd.DataFrame, spec: ChartSpec, *, prepared: PreparedData | None = None) -> Any:
|
|
367
|
+
"""Build an interactive Plotly figure.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
df: Cleaned, filtered data.
|
|
371
|
+
spec: What to draw.
|
|
372
|
+
prepared: Reuse an existing :func:`prepare` result instead of recomputing it.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
A ``plotly.graph_objects.Figure``.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
PlotError: If Plotly is unavailable or the chart cannot be built.
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
import plotly.express as px
|
|
382
|
+
import plotly.graph_objects as go
|
|
383
|
+
except ImportError as exc: # pragma: no cover - dependency is declared
|
|
384
|
+
raise PlotError(
|
|
385
|
+
"Interactive charts need the plotly package.",
|
|
386
|
+
hint="Install it with: pip install plotly",
|
|
387
|
+
) from exc
|
|
388
|
+
|
|
389
|
+
data = prepared or prepare(df, spec)
|
|
390
|
+
frame, spec = data.df, data.spec
|
|
391
|
+
title = spec.display_title()
|
|
392
|
+
opts = spec.options
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
chart = spec.chart
|
|
396
|
+
if chart == "histogram":
|
|
397
|
+
fig = px.histogram(
|
|
398
|
+
frame, x=spec.x, color=spec.color, nbins=opts.get("bins"), title=title
|
|
399
|
+
)
|
|
400
|
+
elif chart == "kde":
|
|
401
|
+
grid, density = kde_curve(frame[spec.x])
|
|
402
|
+
fig = go.Figure(go.Scatter(x=grid, y=density, fill="tozeroy", mode="lines"))
|
|
403
|
+
fig.update_layout(title=title, xaxis_title=spec.x, yaxis_title="density")
|
|
404
|
+
elif chart in {"bar", "grouped_bar", "stacked_bar"}:
|
|
405
|
+
fig = px.bar(
|
|
406
|
+
frame,
|
|
407
|
+
x=spec.x,
|
|
408
|
+
y=spec.y,
|
|
409
|
+
color=spec.color,
|
|
410
|
+
barmode="stack" if chart == "stacked_bar" or opts.get("stacked") else "group",
|
|
411
|
+
orientation=opts.get("orientation", "v"),
|
|
412
|
+
title=title,
|
|
413
|
+
)
|
|
414
|
+
elif chart == "pie":
|
|
415
|
+
fig = px.pie(frame, names=spec.x, values=spec.y, title=title)
|
|
416
|
+
elif chart == "treemap":
|
|
417
|
+
path = [spec.x] if not spec.color else [spec.color, spec.x]
|
|
418
|
+
fig = px.treemap(frame, path=path, values=spec.y, title=title)
|
|
419
|
+
elif chart == "scatter":
|
|
420
|
+
fig = px.scatter(
|
|
421
|
+
frame,
|
|
422
|
+
x=spec.x,
|
|
423
|
+
y=spec.y,
|
|
424
|
+
color=spec.color,
|
|
425
|
+
opacity=opts.get("opacity", 0.75),
|
|
426
|
+
trendline="ols" if opts.get("trendline") and _has_statsmodels() else None,
|
|
427
|
+
title=title,
|
|
428
|
+
)
|
|
429
|
+
elif chart == "line":
|
|
430
|
+
fig = px.line(
|
|
431
|
+
frame, x=spec.x, y=spec.y, color=spec.color, markers=len(frame) < 200, title=title
|
|
432
|
+
)
|
|
433
|
+
elif chart == "area":
|
|
434
|
+
fig = px.area(frame, x=spec.x, y=spec.y, color=spec.color, title=title)
|
|
435
|
+
elif chart == "box":
|
|
436
|
+
fig = px.box(
|
|
437
|
+
frame, x=spec.x, y=spec.y, color=spec.color, points="outliers", title=title
|
|
438
|
+
)
|
|
439
|
+
elif chart == "violin":
|
|
440
|
+
fig = px.violin(frame, x=spec.x, y=spec.y, color=spec.color, box=True, title=title)
|
|
441
|
+
elif chart == "heatmap":
|
|
442
|
+
value = spec.color or "count"
|
|
443
|
+
value = value if value in frame.columns else frame.columns[-1]
|
|
444
|
+
matrix = frame.pivot_table(index=spec.y, columns=spec.x, values=value, aggfunc="mean")
|
|
445
|
+
fig = px.imshow(matrix, aspect="auto", text_auto=matrix.size <= 100, title=title)
|
|
446
|
+
elif chart == "correlation_heatmap":
|
|
447
|
+
corr = frame.corr(numeric_only=True)
|
|
448
|
+
fig = px.imshow(
|
|
449
|
+
corr,
|
|
450
|
+
zmin=-1,
|
|
451
|
+
zmax=1,
|
|
452
|
+
color_continuous_scale="RdBu_r",
|
|
453
|
+
text_auto=".2f" if corr.size <= 100 else False,
|
|
454
|
+
aspect="auto",
|
|
455
|
+
title=title,
|
|
456
|
+
)
|
|
457
|
+
elif chart == "pair_plot":
|
|
458
|
+
fig = px.scatter_matrix(frame, dimensions=list(frame.columns)[:6], title=title)
|
|
459
|
+
fig.update_traces(diagonal_visible=False, showupperhalf=False)
|
|
460
|
+
else: # pragma: no cover - validate() rejects unknown types first
|
|
461
|
+
raise PlotError(f"No Plotly renderer for chart type {chart!r}.")
|
|
462
|
+
except PlotError:
|
|
463
|
+
raise
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
raise PlotError(f"Could not draw the {spec.chart} chart.", hint=str(exc)) from exc
|
|
466
|
+
|
|
467
|
+
if opts.get("log_y"):
|
|
468
|
+
fig.update_yaxes(type="log")
|
|
469
|
+
if opts.get("log_x"):
|
|
470
|
+
fig.update_xaxes(type="log")
|
|
471
|
+
|
|
472
|
+
fig.update_layout(
|
|
473
|
+
template=opts.get("template", "plotly_white"),
|
|
474
|
+
margin={"l": 60, "r": 30, "t": 60, "b": 60},
|
|
475
|
+
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "x": 0},
|
|
476
|
+
)
|
|
477
|
+
if data.sampled:
|
|
478
|
+
fig.add_annotation(
|
|
479
|
+
text=data.sampling_notice,
|
|
480
|
+
xref="paper",
|
|
481
|
+
yref="paper",
|
|
482
|
+
x=0,
|
|
483
|
+
y=-0.16,
|
|
484
|
+
showarrow=False,
|
|
485
|
+
font={"size": 11, "color": "#666"},
|
|
486
|
+
)
|
|
487
|
+
return fig
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _has_statsmodels() -> bool:
|
|
491
|
+
"""Plotly's OLS trendline needs statsmodels; degrade quietly when it is absent."""
|
|
492
|
+
try:
|
|
493
|
+
import statsmodels.api # noqa: F401
|
|
494
|
+
except ImportError:
|
|
495
|
+
return False
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------- matplotlib
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def build_matplotlib(
|
|
503
|
+
df: pd.DataFrame,
|
|
504
|
+
spec: ChartSpec,
|
|
505
|
+
*,
|
|
506
|
+
prepared: PreparedData | None = None,
|
|
507
|
+
figsize: tuple[float, float] = (10.0, 6.0),
|
|
508
|
+
dpi: int = 300,
|
|
509
|
+
) -> Any:
|
|
510
|
+
"""Build a static matplotlib figure for export.
|
|
511
|
+
|
|
512
|
+
Uses the ``Agg`` backend explicitly so this works headless — CLI mode and CI both rely on it.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
df: Cleaned, filtered data.
|
|
516
|
+
spec: What to draw.
|
|
517
|
+
prepared: Reuse an existing :func:`prepare` result.
|
|
518
|
+
figsize: Figure size in inches.
|
|
519
|
+
dpi: Dots per inch. 300 is print quality and the export default.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
A ``matplotlib.figure.Figure``.
|
|
523
|
+
|
|
524
|
+
Raises:
|
|
525
|
+
PlotError: If the chart cannot be built.
|
|
526
|
+
"""
|
|
527
|
+
import matplotlib
|
|
528
|
+
|
|
529
|
+
matplotlib.use("Agg", force=False)
|
|
530
|
+
import matplotlib.pyplot as plt
|
|
531
|
+
|
|
532
|
+
data = prepared or prepare(df, spec)
|
|
533
|
+
frame, spec = data.df, data.spec
|
|
534
|
+
opts = spec.options
|
|
535
|
+
chart = spec.chart
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
if chart == "pair_plot":
|
|
539
|
+
fig = _mpl_pair_plot(frame, figsize=figsize, dpi=dpi)
|
|
540
|
+
fig.suptitle(spec.display_title())
|
|
541
|
+
return fig
|
|
542
|
+
|
|
543
|
+
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
|
|
544
|
+
|
|
545
|
+
if chart == "histogram":
|
|
546
|
+
ax.hist(
|
|
547
|
+
pd.to_numeric(frame[spec.x], errors="coerce").dropna(),
|
|
548
|
+
bins=opts.get("bins", "auto"),
|
|
549
|
+
color="#4C78A8",
|
|
550
|
+
edgecolor="white",
|
|
551
|
+
)
|
|
552
|
+
ax.set_xlabel(str(spec.x))
|
|
553
|
+
ax.set_ylabel("count")
|
|
554
|
+
elif chart == "kde":
|
|
555
|
+
grid, density = kde_curve(frame[spec.x])
|
|
556
|
+
ax.plot(grid, density, color="#4C78A8")
|
|
557
|
+
ax.fill_between(grid, density, alpha=0.3, color="#4C78A8")
|
|
558
|
+
ax.set_xlabel(str(spec.x))
|
|
559
|
+
ax.set_ylabel("density")
|
|
560
|
+
elif chart in {"bar", "grouped_bar", "stacked_bar"}:
|
|
561
|
+
_mpl_bar(ax, frame, spec, stacked=chart == "stacked_bar" or bool(opts.get("stacked")))
|
|
562
|
+
elif chart == "pie":
|
|
563
|
+
values = frame[spec.y] if spec.y and spec.y in frame else frame.iloc[:, -1]
|
|
564
|
+
ax.pie(values, labels=frame[spec.x].astype(str).tolist(), autopct="%1.1f%%")
|
|
565
|
+
ax.set_ylabel("")
|
|
566
|
+
elif chart == "treemap":
|
|
567
|
+
_mpl_treemap(ax, frame, spec)
|
|
568
|
+
elif chart == "scatter":
|
|
569
|
+
_mpl_scatter(ax, frame, spec)
|
|
570
|
+
elif chart in {"line", "area"}:
|
|
571
|
+
_mpl_line(ax, frame, spec, area=chart == "area")
|
|
572
|
+
elif chart in {"box", "violin"}:
|
|
573
|
+
_mpl_distribution(ax, frame, spec, violin=chart == "violin")
|
|
574
|
+
elif chart == "heatmap":
|
|
575
|
+
value = spec.color if spec.color in frame.columns else frame.columns[-1]
|
|
576
|
+
matrix = frame.pivot_table(index=spec.y, columns=spec.x, values=value, aggfunc="mean")
|
|
577
|
+
image = ax.imshow(matrix.to_numpy(dtype=float), aspect="auto", cmap="viridis")
|
|
578
|
+
ax.set_xticks(
|
|
579
|
+
range(len(matrix.columns)),
|
|
580
|
+
[str(c) for c in matrix.columns],
|
|
581
|
+
rotation=45,
|
|
582
|
+
ha="right",
|
|
583
|
+
)
|
|
584
|
+
ax.set_yticks(range(len(matrix.index)), [str(i) for i in matrix.index])
|
|
585
|
+
fig.colorbar(image, ax=ax, label=str(value))
|
|
586
|
+
elif chart == "correlation_heatmap":
|
|
587
|
+
corr = frame.corr(numeric_only=True)
|
|
588
|
+
image = ax.imshow(corr.to_numpy(dtype=float), vmin=-1, vmax=1, cmap="RdBu_r")
|
|
589
|
+
ax.set_xticks(range(len(corr.columns)), list(corr.columns), rotation=45, ha="right")
|
|
590
|
+
ax.set_yticks(range(len(corr.index)), list(corr.index))
|
|
591
|
+
if corr.size <= 100:
|
|
592
|
+
for i in range(len(corr.index)):
|
|
593
|
+
for j in range(len(corr.columns)):
|
|
594
|
+
ax.text(j, i, f"{corr.iat[i, j]:.2f}", ha="center", va="center", fontsize=8)
|
|
595
|
+
fig.colorbar(image, ax=ax, label="Pearson r")
|
|
596
|
+
else: # pragma: no cover
|
|
597
|
+
raise PlotError(f"No matplotlib renderer for chart type {chart!r}.")
|
|
598
|
+
|
|
599
|
+
if opts.get("log_y"):
|
|
600
|
+
ax.set_yscale("log")
|
|
601
|
+
if opts.get("log_x"):
|
|
602
|
+
ax.set_xscale("log")
|
|
603
|
+
|
|
604
|
+
ax.set_title(spec.display_title())
|
|
605
|
+
if chart not in {"pie", "treemap", "heatmap", "correlation_heatmap"}:
|
|
606
|
+
if spec.x:
|
|
607
|
+
ax.set_xlabel(str(spec.x))
|
|
608
|
+
if spec.y and chart not in {"histogram", "kde"}:
|
|
609
|
+
ax.set_ylabel(str(spec.y))
|
|
610
|
+
ax.grid(True, alpha=0.25, linestyle="--")
|
|
611
|
+
ax.set_axisbelow(True)
|
|
612
|
+
|
|
613
|
+
if data.sampled:
|
|
614
|
+
fig.text(0.01, 0.01, data.sampling_notice, fontsize=8, color="#666")
|
|
615
|
+
|
|
616
|
+
fig.tight_layout()
|
|
617
|
+
return fig
|
|
618
|
+
except PlotError:
|
|
619
|
+
raise
|
|
620
|
+
except Exception as exc:
|
|
621
|
+
raise PlotError(f"Could not draw the {spec.chart} chart.", hint=str(exc)) from exc
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _mpl_bar(ax: Any, frame: pd.DataFrame, spec: ChartSpec, *, stacked: bool) -> None:
|
|
625
|
+
"""Draw a plain, grouped, or stacked bar chart on ``ax``."""
|
|
626
|
+
x, y = str(spec.x), str(spec.y) if spec.y else None
|
|
627
|
+
if y is None or y not in frame.columns:
|
|
628
|
+
counts = frame[x].value_counts()
|
|
629
|
+
ax.bar([str(i) for i in counts.index], counts.to_numpy(), color="#4C78A8")
|
|
630
|
+
ax.set_ylabel("count")
|
|
631
|
+
elif spec.color and spec.color in frame.columns:
|
|
632
|
+
pivot = frame.pivot_table(index=x, columns=spec.color, values=y, aggfunc="mean")
|
|
633
|
+
pivot.plot(kind="bar", stacked=stacked, ax=ax, width=0.8)
|
|
634
|
+
ax.legend(title=str(spec.color), fontsize=8)
|
|
635
|
+
else:
|
|
636
|
+
ax.bar(frame[x].astype(str), pd.to_numeric(frame[y], errors="coerce"), color="#4C78A8")
|
|
637
|
+
if len(ax.get_xticklabels()) > 6:
|
|
638
|
+
ax.tick_params(axis="x", rotation=45)
|
|
639
|
+
for label in ax.get_xticklabels():
|
|
640
|
+
label.set_horizontalalignment("right")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _mpl_scatter(ax: Any, frame: pd.DataFrame, spec: ChartSpec) -> None:
|
|
644
|
+
"""Draw a scatter, optionally split by colour, with an optional least-squares trendline."""
|
|
645
|
+
x, y = str(spec.x), str(spec.y)
|
|
646
|
+
if spec.color and spec.color in frame.columns:
|
|
647
|
+
for name, group in frame.groupby(spec.color, observed=True):
|
|
648
|
+
ax.scatter(group[x], group[y], s=14, alpha=0.7, label=str(name))
|
|
649
|
+
ax.legend(title=str(spec.color), fontsize=8)
|
|
650
|
+
else:
|
|
651
|
+
ax.scatter(frame[x], frame[y], s=14, alpha=0.7, color="#4C78A8")
|
|
652
|
+
|
|
653
|
+
if spec.options.get("trendline"):
|
|
654
|
+
xs = pd.to_numeric(frame[x], errors="coerce")
|
|
655
|
+
ys = pd.to_numeric(frame[y], errors="coerce")
|
|
656
|
+
mask = xs.notna() & ys.notna()
|
|
657
|
+
if int(mask.sum()) > 2:
|
|
658
|
+
slope, intercept = np.polyfit(xs[mask], ys[mask], 1)
|
|
659
|
+
line = np.linspace(xs[mask].min(), xs[mask].max(), 100)
|
|
660
|
+
ax.plot(line, slope * line + intercept, color="#E45756", linewidth=1.5, label="trend")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _mpl_line(ax: Any, frame: pd.DataFrame, spec: ChartSpec, *, area: bool) -> None:
|
|
664
|
+
"""Draw a line or filled area, one series per colour value."""
|
|
665
|
+
x, y = str(spec.x), str(spec.y)
|
|
666
|
+
if spec.color and spec.color in frame.columns:
|
|
667
|
+
for name, group in frame.groupby(spec.color, observed=True):
|
|
668
|
+
group = group.sort_values(x)
|
|
669
|
+
ax.plot(group[x], group[y], label=str(name), linewidth=1.6)
|
|
670
|
+
if area:
|
|
671
|
+
ax.fill_between(group[x], group[y], alpha=0.25)
|
|
672
|
+
ax.legend(title=str(spec.color), fontsize=8)
|
|
673
|
+
else:
|
|
674
|
+
ordered = frame.sort_values(x)
|
|
675
|
+
ax.plot(ordered[x], ordered[y], color="#4C78A8", linewidth=1.6)
|
|
676
|
+
if area:
|
|
677
|
+
ax.fill_between(ordered[x], ordered[y], alpha=0.3, color="#4C78A8")
|
|
678
|
+
if pd.api.types.is_datetime64_any_dtype(frame[x]):
|
|
679
|
+
ax.figure.autofmt_xdate()
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _mpl_distribution(ax: Any, frame: pd.DataFrame, spec: ChartSpec, *, violin: bool) -> None:
|
|
683
|
+
"""Draw a box or violin plot, one body per category on the x axis."""
|
|
684
|
+
y = str(spec.y)
|
|
685
|
+
if spec.x and spec.x in frame.columns:
|
|
686
|
+
groups = [
|
|
687
|
+
(str(name), pd.to_numeric(group[y], errors="coerce").dropna().to_numpy())
|
|
688
|
+
for name, group in frame.groupby(spec.x, observed=True)
|
|
689
|
+
]
|
|
690
|
+
groups = [(name, values) for name, values in groups if values.size]
|
|
691
|
+
else:
|
|
692
|
+
groups = [(y, pd.to_numeric(frame[y], errors="coerce").dropna().to_numpy())]
|
|
693
|
+
|
|
694
|
+
if not groups:
|
|
695
|
+
raise PlotError("No numeric values are available to summarize.")
|
|
696
|
+
|
|
697
|
+
labels = [name for name, _ in groups]
|
|
698
|
+
values = [vals for _, vals in groups]
|
|
699
|
+
if violin:
|
|
700
|
+
ax.violinplot(values, showmedians=True)
|
|
701
|
+
ax.set_xticks(range(1, len(labels) + 1), labels)
|
|
702
|
+
else:
|
|
703
|
+
ax.boxplot(values, tick_labels=labels)
|
|
704
|
+
if len(labels) > 6:
|
|
705
|
+
ax.tick_params(axis="x", rotation=45)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _mpl_treemap(ax: Any, frame: pd.DataFrame, spec: ChartSpec) -> None:
|
|
709
|
+
"""Draw a simple slice-and-dice treemap without pulling in a dedicated dependency."""
|
|
710
|
+
import matplotlib.pyplot as plt
|
|
711
|
+
from matplotlib.patches import Rectangle
|
|
712
|
+
|
|
713
|
+
labels = frame[str(spec.x)].astype(str).tolist()
|
|
714
|
+
if spec.y and spec.y in frame.columns:
|
|
715
|
+
sizes = pd.to_numeric(frame[spec.y], errors="coerce").fillna(0).tolist()
|
|
716
|
+
else:
|
|
717
|
+
counts = frame[str(spec.x)].value_counts()
|
|
718
|
+
labels, sizes = [str(i) for i in counts.index], counts.tolist()
|
|
719
|
+
|
|
720
|
+
pairs = sorted(zip(labels, sizes, strict=True), key=lambda p: p[1], reverse=True)
|
|
721
|
+
pairs = [(label, size) for label, size in pairs if size > 0][:40]
|
|
722
|
+
if not pairs:
|
|
723
|
+
raise PlotError("The treemap has no positive values to lay out.")
|
|
724
|
+
|
|
725
|
+
total = sum(size for _, size in pairs)
|
|
726
|
+
cmap = plt.get_cmap("tab20")
|
|
727
|
+
x = y = 0.0
|
|
728
|
+
width = height = 1.0
|
|
729
|
+
horizontal = True
|
|
730
|
+
for i, (label, size) in enumerate(pairs):
|
|
731
|
+
fraction = size / total if total else 0
|
|
732
|
+
if horizontal:
|
|
733
|
+
rect_w, rect_h = width * fraction / (fraction + _remaining(pairs, i, total)), height
|
|
734
|
+
else:
|
|
735
|
+
rect_w, rect_h = width, height * fraction / (fraction + _remaining(pairs, i, total))
|
|
736
|
+
ax.add_patch(
|
|
737
|
+
Rectangle(
|
|
738
|
+
(x, y), rect_w, rect_h, facecolor=cmap(i % 20), edgecolor="white", linewidth=1.5
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
if rect_w > 0.06 and rect_h > 0.05:
|
|
742
|
+
ax.text(
|
|
743
|
+
x + rect_w / 2,
|
|
744
|
+
y + rect_h / 2,
|
|
745
|
+
label,
|
|
746
|
+
ha="center",
|
|
747
|
+
va="center",
|
|
748
|
+
fontsize=8,
|
|
749
|
+
color="white",
|
|
750
|
+
weight="bold",
|
|
751
|
+
)
|
|
752
|
+
if horizontal:
|
|
753
|
+
x += rect_w
|
|
754
|
+
width -= rect_w
|
|
755
|
+
else:
|
|
756
|
+
y += rect_h
|
|
757
|
+
height -= rect_h
|
|
758
|
+
horizontal = not horizontal
|
|
759
|
+
ax.set_xlim(0, 1)
|
|
760
|
+
ax.set_ylim(0, 1)
|
|
761
|
+
ax.axis("off")
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _remaining(pairs: list[tuple[str, float]], index: int, total: float) -> float:
|
|
765
|
+
"""Fraction of the total still to be laid out after ``index``."""
|
|
766
|
+
rest = sum(size for _, size in pairs[index + 1 :])
|
|
767
|
+
return rest / total if total else 0.0
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _mpl_pair_plot(frame: pd.DataFrame, *, figsize: tuple[float, float], dpi: int) -> Any:
|
|
771
|
+
"""Scatter-matrix of up to five numeric columns, histograms on the diagonal."""
|
|
772
|
+
import matplotlib.pyplot as plt
|
|
773
|
+
|
|
774
|
+
cols = list(frame.columns)[:5]
|
|
775
|
+
n = len(cols)
|
|
776
|
+
fig, axes = plt.subplots(n, n, figsize=figsize, dpi=dpi, squeeze=False)
|
|
777
|
+
for i, row_col in enumerate(cols):
|
|
778
|
+
for j, col_col in enumerate(cols):
|
|
779
|
+
ax = axes[i][j]
|
|
780
|
+
if i == j:
|
|
781
|
+
ax.hist(frame[row_col].dropna(), bins=20, color="#4C78A8", edgecolor="white")
|
|
782
|
+
else:
|
|
783
|
+
ax.scatter(frame[col_col], frame[row_col], s=6, alpha=0.5, color="#4C78A8")
|
|
784
|
+
if i == n - 1:
|
|
785
|
+
ax.set_xlabel(str(col_col), fontsize=8)
|
|
786
|
+
if j == 0:
|
|
787
|
+
ax.set_ylabel(str(row_col), fontsize=8)
|
|
788
|
+
ax.tick_params(labelsize=6)
|
|
789
|
+
fig.tight_layout()
|
|
790
|
+
return fig
|