lessPython 0.2.0__py3-none-any.whl → 0.3.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.
Files changed (48) hide show
  1. lessPy/ANOVA.py +5 -5
  2. lessPy/Chart.py +386 -88
  3. lessPy/Correlation.py +2 -2
  4. lessPy/Logit.py +2 -2
  5. lessPy/Regression.py +6 -4
  6. lessPy/X.py +279 -42
  7. lessPy/XY.py +814 -158
  8. lessPy/__init__.py +12 -2
  9. lessPy/_themes.py +5007 -0
  10. lessPy/bc_items_plotly.py +31 -18
  11. lessPy/bc_plotly.py +23 -7
  12. lessPy/bubble_plotly.py +17 -2
  13. lessPy/corCFA.py +3 -3
  14. lessPy/corProp.py +2 -2
  15. lessPy/corReflect.py +2 -2
  16. lessPy/corReorder.py +2 -2
  17. lessPy/corScree.py +3 -3
  18. lessPy/data/StockPrice.csv +1401 -1386
  19. lessPy/details.py +17 -4
  20. lessPy/dn_plotly.py +65 -45
  21. lessPy/freq_poly_plotly.py +12 -5
  22. lessPy/getColors.py +247 -20
  23. lessPy/hier_plotly.py +36 -9
  24. lessPy/hs_plotly.py +16 -4
  25. lessPy/plotly_utils.py +78 -9
  26. lessPy/plt_mat_plotly.py +74 -2
  27. lessPy/plt_time.py +10 -3
  28. lessPy/prob_norm.py +3 -3
  29. lessPy/prob_tcut.py +3 -3
  30. lessPy/prob_znorm.py +3 -1
  31. lessPy/rcolors.py +461 -0
  32. lessPy/simCImean.py +3 -3
  33. lessPy/simCLT.py +3 -3
  34. lessPy/simFlips.py +3 -3
  35. lessPy/simMeans.py +3 -3
  36. lessPy/stats_out.py +257 -11
  37. lessPy/style.py +160 -0
  38. lessPy/sunflower_plotly.py +140 -0
  39. lessPy/ttest.py +5 -5
  40. lessPy/utils.py +43 -6
  41. lessPy/vbs_plotly.py +16 -5
  42. lessPy/x_console.py +763 -0
  43. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/METADATA +6 -2
  44. lesspython-0.3.0.dist-info/RECORD +89 -0
  45. lesspython-0.2.0.dist-info/RECORD +0 -84
  46. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/WHEEL +0 -0
  47. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/licenses/LICENSE +0 -0
  48. {lesspython-0.2.0.dist-info → lesspython-0.3.0.dist-info}/top_level.txt +0 -0
lessPy/x_console.py ADDED
@@ -0,0 +1,763 @@
1
+ # x_console.py — the console report of X(), ports of lessR's printers
2
+ #
3
+ # R analogs: .ss.numeric() (brief, one variable), .bx.stats() (the
4
+ # outlier listing and the VBS statistics), .hst.stats() (the bin
5
+ # table and the histogram suggestions), dn.main.R (bandwidth,
6
+ # Shapiro-Wilk, the density suggestions), the grouped table that
7
+ # X.R prints through .ss.pivot(), and .param.VBS() (suggestions,
8
+ # duplicated values, parameter values) with .vbs_summary_table().
9
+ #
10
+ # Each function returns a list of lines, a component of R's
11
+ # print.out_all(); report() joins the components with one blank
12
+ # line between them, as print.out_all() does. Trailing blanks are
13
+ # dropped (R's cat(x, "\n") leaves one at the end of every line).
14
+ #
15
+ # The suggestions are written in Python syntax, naming the module
16
+ # and the DataFrame as the caller's own code names them.
17
+
18
+ import inspect
19
+ import sys
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from .stats_out import _fmtc, _fmti, _fmtr, _r_chr, _stat_by_levels
25
+ from .utils import get_option
26
+ from .vbs_plotly import _adj_fences, _medcouple, five_num
27
+
28
+
29
+ # ----- R number formatting --------------------------------------
30
+
31
+ def r_format1(v):
32
+ """R format() of one number: 7 significant digits, trailing
33
+ zeros dropped, fixed notation."""
34
+ s = f"{float(v):.7g}"
35
+ if "e" in s:
36
+ s = np.format_float_positional(float(s), trim="-")
37
+ return s
38
+
39
+
40
+ def r_chr(v):
41
+ """R as.character() of one number: 15 significant digits."""
42
+ s = f"{float(v):.15g}"
43
+ if "e" in s:
44
+ s = np.format_float_positional(float(s), trim="-")
45
+ return s
46
+
47
+
48
+ def _n_dec(s):
49
+ return len(s) - s.index(".") - 1 if "." in s else 0
50
+
51
+
52
+ def max_dd(x):
53
+ """Decimal digits of the most precise of the first 200 values.
54
+ R analog: .max.dd()"""
55
+ m = 0
56
+ for v in list(np.asarray(x, dtype=float))[:200]:
57
+ if np.isfinite(v):
58
+ m = max(m, _n_dec(r_format1(v)))
59
+ return m
60
+
61
+
62
+ def r_format_vec(vals):
63
+ """R format() of a numeric vector: the decimals that show each
64
+ value to 7 significant digits, the most of them for all, then
65
+ one common width, right-justified."""
66
+ v = np.asarray(vals, dtype=float)
67
+ fin = v[np.isfinite(v)]
68
+ rgt = max((_n_dec(r_format1(z)) for z in fin), default=0)
69
+ out = [f"{z:.{rgt}f}" if np.isfinite(z) else "NA" for z in v]
70
+ w = max((len(s) for s in out), default=0)
71
+ return [s.rjust(w) for s in out]
72
+
73
+
74
+ def decdig(vals):
75
+ """Round a column of statistics as lessR's pivot() does before
76
+ it prints: integers kept, otherwise 3 decimals, or one past the
77
+ leading zeros of values below 1. R analog: .decdig()"""
78
+ v = np.asarray(vals, dtype=float)
79
+ fin = v[np.isfinite(v)]
80
+ if len(fin) == 0 or all(float(z).is_integer() for z in fin):
81
+ return v
82
+ lead0 = _lead0(fin)
83
+ locs = [r_format1(z).find(".") + 1 or len(r_format1(z)) + 1
84
+ for z in fin]
85
+ dgs = lead0 + 1 if lead0 > 0 else 3
86
+ if min(locs) > 2:
87
+ dgs = 3
88
+ return np.round(v, dgs)
89
+
90
+
91
+ def _lead0(vals):
92
+ """Leading zeros after the decimal point of values below 1.
93
+ R analog: .lead0()"""
94
+ n_max = 0
95
+ for v in vals:
96
+ s = r_format1(v)
97
+ if "." in s and v < 1:
98
+ n = len(s.split(".")[1]) - len(s.split(".")[1].lstrip("0"))
99
+ n_max = max(n_max, n)
100
+ return n_max
101
+
102
+
103
+ def title(x_name, x_lbl=None):
104
+ """--- x --- heading, with the variable label. R .title2()"""
105
+ t = x_name if x_lbl is None else f"{x_name}: {x_lbl}"
106
+ return f"--- {t} ---"
107
+
108
+
109
+ # ----- the caller's names, for runnable suggestions ---------------
110
+
111
+ def call_names(data):
112
+ """The prefix that reaches lessPy in the caller's namespace
113
+ ("lp." for import lessPy as lp, "" for from lessPy import X)
114
+ and the caller's name of the DataFrame (default "d")."""
115
+ mod = sys.modules.get("lessPy")
116
+ pre, dname = "lp.", "d"
117
+ f = inspect.currentframe()
118
+ try:
119
+ while f is not None and str(
120
+ f.f_globals.get("__name__", "")).startswith("lessPy"):
121
+ f = f.f_back
122
+ if f is not None:
123
+ ns = dict(f.f_globals)
124
+ ns.update(f.f_locals)
125
+ mods = [k for k, v in ns.items()
126
+ if v is mod and not k.startswith("_")]
127
+ if mods:
128
+ pre = mods[0] + "."
129
+ elif "X" in ns:
130
+ pre = ""
131
+ names = [k for k, v in ns.items()
132
+ if v is data and not k.startswith("_")]
133
+ if names:
134
+ dname = names[0]
135
+ finally:
136
+ del f
137
+ return pre, dname
138
+
139
+
140
+ # ----- components ------------------------------------------------
141
+
142
+ def ss_numeric(xv, x_name, x_lbl=None, digits_d=None):
143
+ """n, miss, mean, sd, min, mdn, max of one variable, missing
144
+ values included in the count. R analog: .ss.numeric(x,
145
+ brief=TRUE) with one line"""
146
+ v = np.asarray(xv, dtype=float)
147
+ miss = int(np.isnan(v).sum())
148
+ v = v[~np.isnan(v)]
149
+ n = len(v)
150
+ d = digits_d
151
+ if d is None:
152
+ d = max_dd(v) + 1
153
+ if d == 1:
154
+ d = 2
155
+ max_lv = 3
156
+ max_n = len(str(n))
157
+ max_nm = len(str(miss))
158
+ m = v.mean() if n > 0 else np.nan
159
+ s = v.std(ddof=1) if n > 1 else np.nan
160
+ if np.isnan(s):
161
+ max_ln = len(_r_chr(m, d)) + d if n > 0 else 0
162
+ else:
163
+ max_ln = max(len(_r_chr(m, d)), len(_r_chr(s, d))) + d
164
+ for lim, add in ((5, 1), (10, 1), (4, 2), (8, 1)):
165
+ if max_ln < lim:
166
+ max_ln += add
167
+ lines = [title(x_name, x_lbl), ""]
168
+ lines.append(" ".join([
169
+ _fmtc("n", len(str(max_n)) + 3 + max_lv - 2),
170
+ _fmtc("miss", len(str(max_nm)) + 5),
171
+ _fmtc("mean", max_ln), _fmtc("sd", max_ln),
172
+ _fmtc("min", max_ln), _fmtc("mdn", max_ln),
173
+ _fmtc("max", max_ln)]))
174
+ row = [_fmtc("", max_lv), _fmti(n, max_n + 1),
175
+ _fmti(miss, max_nm + 5)]
176
+ if n == 1:
177
+ row.append(_fmtr(m, d, max_ln))
178
+ elif n > 1:
179
+ row += [_fmtr(z, d, max_ln) for z in
180
+ (m, s, v.min(), np.median(v), v.max())]
181
+ lines.append(" ".join(row))
182
+ lines.append("")
183
+ return lines
184
+
185
+
186
+ def _box_stats(x, k_iqr=1.5, box_adj=False, a=-4, b=3):
187
+ """Whiskers and outliers on Tukey hinges, as boxplot.stats(), or
188
+ medcouple-adjusted as robustbase::adjboxStats()"""
189
+ mn, q1, md, q3, mx = five_num(x)
190
+ iqr = q3 - q1
191
+ mcv = _medcouple(x) if box_adj else 0.0
192
+ (lo_f, hi_f), _ = _adj_fences(q1, q3, iqr, k_iqr, mcv, a, b)
193
+ out = (x < lo_f) | (x > hi_f)
194
+ inside = x[~out]
195
+ return (inside.min(), q1, md, q3, inside.max()), x[out]
196
+
197
+
198
+ def outliers(x, digits_d=2, ids=None, k_iqr=1.5, box_adj=False,
199
+ a=-4, b=3):
200
+ """The boxplot outliers, small and large, with their IDs.
201
+ R analog: .bx.stats()$txotl"""
202
+ x = np.asarray(x, dtype=float)
203
+ x = x[np.isfinite(x)]
204
+ if digits_d is None:
205
+ digits_d = max(max_dd(x) + 1, 2)
206
+ st, out = _box_stats(x, k_iqr, box_adj, a, b)
207
+ if len(out) == 0:
208
+ return ["No (Box plot) outliers"]
209
+ ids = (np.asarray(ids).astype(str) if ids is not None
210
+ else np.array([""] * len(x)))
211
+ d1 = digits_d - 1
212
+
213
+ def side(mask, decreasing):
214
+ xv, iv = x[mask], ids[mask]
215
+ o = np.argsort(-xv if decreasing else xv, kind="stable")
216
+ return ([f"{z:.{d1}f}" for z in xv[o]], [str(i) for i in iv[o]])
217
+
218
+ lo_mask, hi_mask = x < st[0], x > st[4]
219
+ if lo_mask.any():
220
+ xc_lo, id_lo = side(lo_mask, False)
221
+ lo_len = len(xc_lo)
222
+ max_lo = max(len(s) for s in xc_lo)
223
+ max_id_lo = max(len(s) for s in id_lo)
224
+ else:
225
+ xc_lo, id_lo, max_lo, max_id_lo, lo_len = [""], [" "], 0, 5, 0
226
+ if hi_mask.any():
227
+ xc_hi, id_hi = side(hi_mask, True)
228
+ hi_len = len(xc_hi)
229
+ max_hi = max(len(s) for s in xc_hi)
230
+ max_id_hi = max(len(s) for s in id_hi)
231
+ else:
232
+ xc_hi, id_hi, max_hi, max_id_hi, hi_len = [""], [" "], 0, 5, 0
233
+
234
+ max_list = 18
235
+ n_lines = min(max(lo_len, hi_len), max_list)
236
+ for i in range(1, n_lines + 1): # R grows the shorter side
237
+ if i > lo_len:
238
+ xc_lo.append("")
239
+ id_lo.append("")
240
+ if i > hi_len:
241
+ xc_hi.append("")
242
+ id_hi.append("")
243
+
244
+ def left(s, w):
245
+ return str(s).ljust(max(w, 0))
246
+
247
+ adj = len("Small") - max_id_lo
248
+ buf = 3 - adj if adj > 0 else 3
249
+ lines = [" ", f"--- Outliers --- from the boxplot: {len(out)}",
250
+ "",
251
+ " ".join([left("Small", max_id_lo), _fmtc("", max_lo),
252
+ _fmtc("", max(buf, 0)), left("Large", max_id_hi)]),
253
+ " ".join([left("-----", max_id_lo), _fmtc("", max_lo),
254
+ _fmtc("", max(buf, 0)), left("-----", max_id_hi)])]
255
+ for i in range(n_lines):
256
+ lines.append(" ".join([
257
+ left(id_lo[i], max_id_lo), _fmtc(xc_lo[i], max_lo), " ",
258
+ left(id_hi[i], max_id_hi), _fmtc(xc_hi[i], max_hi)]))
259
+ if max(lo_len, hi_len) > n_lines:
260
+ lines += ["", f"+ {len(out) - max_list} more outliers"]
261
+ return lines
262
+
263
+
264
+ def box_summary(x, x_name, x_lbl=None, digits_d=None, n_total=None,
265
+ k_iqr=1.5, box_adj=False, a=-4, b=3):
266
+ """The statistics of the VBS display, whiskers included.
267
+ R analog: .bx.stats()$txstat"""
268
+ x = np.asarray(x, dtype=float)
269
+ miss = int(np.isnan(x).sum())
270
+ v = x[~np.isnan(x)]
271
+ d = digits_d
272
+ if d is None:
273
+ d = max(max_dd(v) + 1, 2)
274
+ st, _ = _box_stats(v, k_iqr, box_adj, a, b)
275
+ q1, q3 = np.percentile(v, [25, 75])
276
+
277
+ def f(z):
278
+ return f"{z:.{d}f}"
279
+
280
+ return [title(x_name, x_lbl),
281
+ f"Present: {len(v)}",
282
+ f"Missing: {miss}",
283
+ f"Total : {len(x)}",
284
+ "",
285
+ f"Mean : {f(v.mean())}",
286
+ f"Stnd Dev : {f(v.std(ddof=1))}",
287
+ f"IQR : {f(q3 - q1)}",
288
+ f"Skew : {f(_medcouple(v))} [medcouple, -1 to 1]",
289
+ "",
290
+ f"Minimum : {f(v.min())}",
291
+ f"Lower Whisker: {f(st[0])}",
292
+ f"1st Quartile : {f(q1)}",
293
+ f"Median : {f(np.median(v))}",
294
+ f"3rd Quartile : {f(q3)}",
295
+ f"Upper Whisker: {f(st[4])}",
296
+ f"Maximum : {f(v.max())}"]
297
+
298
+
299
+ def bin_table(edges, counts, n, mx_dd):
300
+ """Bin width, number of bins, and the frequency distribution.
301
+ R analog: .hst.stats()$tx"""
302
+ br = np.asarray(edges, dtype=float)
303
+ cnt = np.asarray(counts, dtype=float)
304
+ mids = (br[:-1] + br[1:]) / 2
305
+ bw = float(f"{br[1] - br[0]:.10g}")
306
+ max_dg = max((len(r_chr(b)) for b in br if len(r_chr(b)) < 17),
307
+ default=0)
308
+ max_dg_mid = max((len(r_chr(m)) for m in mids
309
+ if len(r_chr(m)) < 19), default=0)
310
+ x_br = [s.rjust(max_dg) for s in r_format_vec(br)]
311
+ x_mid = [s.rjust(max_dg_mid) for s in r_format_vec(mids)]
312
+ if _lead0(mids) > mx_dd:
313
+ x_mid = [f"{m:.{mx_dd}f}" for m in mids]
314
+ bn = [f"{x_br[i]} > {x_br[i + 1]}" for i in range(len(br) - 1)]
315
+ prop = cnt / n
316
+ cols = [
317
+ ("Bin", bn), ("Midpnt", x_mid),
318
+ ("Count", [f"{c:.0f}" for c in cnt]),
319
+ (" Prop", [f"{p:.2f}" for p in prop]),
320
+ ("Cumul.c", [f"{c:.0f}" for c in np.cumsum(cnt)]),
321
+ ("Cumul.p", [f"{p:.2f}" for p in np.cumsum(prop)])]
322
+ widths = [max(len(nm) + 1, max(len(s) for s in vals)) + 1
323
+ for nm, vals in cols]
324
+ lines = ["", f"Bin Width: {r_chr(bw)}",
325
+ f"Number of Bins: {len(br) - 1}", "",
326
+ "".join(_fmtc(nm, w) for (nm, _), w in zip(cols, widths)),
327
+ "-" * sum(widths)]
328
+ for i in range(len(bn)):
329
+ lines.append("".join(_fmtc(vals[i], w)
330
+ for (_, vals), w in zip(cols, widths)))
331
+ lines.append("")
332
+ return lines
333
+
334
+
335
+ def _df_lines(cols, sep=" ", lead=" "):
336
+ """Columns (name, strings) right-justified to their widths,
337
+ as R prints a data frame without row names."""
338
+ widths = [max(len(nm), max((len(s) for s in vals), default=0))
339
+ for nm, vals in cols]
340
+ n_rows = len(cols[0][1]) if cols else 0
341
+ out = [lead + sep.join(nm.rjust(w)
342
+ for (nm, _), w in zip(cols, widths))]
343
+ for i in range(n_rows):
344
+ out.append(lead + sep.join(vals[i].rjust(w)
345
+ for (_, vals), w in zip(cols, widths)))
346
+ return out
347
+
348
+
349
+ def _group_stats(x, g, order=None):
350
+ """Per-group statistics, groups in the given order (the levels of
351
+ a categorical, as R's factor levels) or sorted."""
352
+ s = pd.Series(np.asarray(x, dtype=float))
353
+ gs = pd.Series(np.asarray(g)).astype(str)
354
+ lvls = ([str(v) for v in order] if order is not None
355
+ else sorted(gs.unique()))
356
+ rows = []
357
+ for lv in lvls:
358
+ z = s[(gs == lv).to_numpy()]
359
+ if len(z) == 0:
360
+ continue
361
+ zz = z.dropna()
362
+ q1, q3 = (np.percentile(zz, [25, 75]) if len(zz)
363
+ else (np.nan, np.nan))
364
+ rows.append(dict(lv=lv, n=len(zz), na=int(z.isna().sum()),
365
+ Mean=zz.mean(), Median=zz.median(),
366
+ SD=zz.std(ddof=1), IQR=q3 - q1,
367
+ Min=zz.min(), Max=zz.max()))
368
+ return rows
369
+
370
+
371
+ def by_table(x, by, x_name, by_name, pre="lp.", dname="d",
372
+ order=None):
373
+ """Statistics of x for each group of by, then the pointer to
374
+ the test of the mean difference for two groups. R analog: the
375
+ grouped-stats block of X.R through .ss.pivot()"""
376
+ rows = _group_stats(x, by, order)
377
+ cols = [(by_name, [r["lv"] for r in rows]),
378
+ ("n", [str(r["n"]) for r in rows]),
379
+ ("na", [str(r["na"]) for r in rows])]
380
+ for k in ("Mean", "Median", "SD", "IQR", "Min", "Max"):
381
+ cols.append((k, r_format_vec(decdig([r[k] for r in rows]))))
382
+ lines = [f"{x_name} by {by_name}", ""] + _df_lines(cols)
383
+ if len(rows) == 2:
384
+ lines += ["", "For inferential analysis of the mean difference:",
385
+ f'> {pre}ttest("{x_name} ~ {by_name}", data={dname})',
386
+ ""]
387
+ else:
388
+ lines += [""]
389
+ return lines
390
+
391
+
392
+ def facet_table(x, cells, x_name, cells_name, digits_d=2):
393
+ """Statistics of x in each panel, missing values counted per
394
+ panel. R analog: .ss.numeric(x, by=cells, brief=TRUE) as
395
+ .bar.lattice() calls it, at getOption("digits_d"): lessR's
396
+ default 2 for facet alone, the one-variable digits (.max.dd + 1)
397
+ left by the earlier summary when by= divides the panels"""
398
+ return _stat_by_levels(pd.Series(np.asarray(x, dtype=float)),
399
+ pd.Series(np.asarray(cells)).astype(str),
400
+ x_name, cells_name, digits_d)
401
+
402
+
403
+ def vbs_summary(x, g, label, digits_d, order=None):
404
+ """n, Mean, Median, SD, IQR, Min, Max of x, per group of g or
405
+ for all of x. R analog: .vbs_summary_table() with .df_char()"""
406
+ if g is None:
407
+ rows = _group_stats(x, np.repeat(label, len(x)))
408
+ else:
409
+ rows = _group_stats(x, g, order)
410
+ cols = [(label, [r["lv"] for r in rows]),
411
+ ("n", [str(r["n"]) for r in rows])]
412
+ for k in ("Mean", "Median", "SD", "IQR", "Min", "Max"):
413
+ cols.append((k, [f"{r[k]:.{digits_d}f}" for r in rows]))
414
+ return _df_lines(cols, sep=" ", lead="")
415
+
416
+
417
+ def dup_values(x, grp=None, grp_order=None):
418
+ """The most repeated value(s) of x, overall or per group.
419
+ R analog: .get.dup() of .param.VBS()"""
420
+ x = np.asarray(x, dtype=float)
421
+ ux = np.unique(x)
422
+ if len(ux) >= 1001:
423
+ return []
424
+ if grp is None:
425
+ cnt = pd.Series(x).value_counts()
426
+ mx_c = 0 if len(ux) == len(x) else int(cnt.max())
427
+ if mx_c <= 1:
428
+ return ["Number of duplicated values: 0"]
429
+ lvls, cols = [""], [cnt]
430
+ else:
431
+ g = np.asarray(grp).astype(str)
432
+ lvls = [str(lv) for lv in grp_order]
433
+ cols = [pd.Series(x[g == lv]).value_counts() for lv in lvls]
434
+ max_lvl = max((len(lv) for lv in lvls), default=0)
435
+ buf = max_lvl - 4 if max_lvl - 5 >= 0 else 0
436
+ # R's for (i in 1:buf) runs twice when buf is 0
437
+ h1 = "Level" + " " * (buf if buf > 0 else 2)
438
+ h0 = h1.replace("Level", " ")
439
+ lines = ["", f"{h0} Max Dupli-", f"{h1} cations Values", "-" * 30]
440
+ for lv, cnt in zip(lvls, cols):
441
+ mx = int(cnt.max()) if len(cnt) else 0
442
+ lvl_c = "" if grp is None else lv.ljust(max(max_lvl, 6))
443
+ if mx > 1:
444
+ vals = sorted(cnt[cnt == mx].index)
445
+ more = " ..." if len(vals) > 8 else ""
446
+ vals = " ".join(r_chr(v) for v in vals[:8])
447
+ lines.append(f"{lvl_c} {mx:3d} {vals}{more}")
448
+ else:
449
+ lines.append(f"{lvl_c} 0")
450
+ return lines
451
+
452
+
453
+ def vbs_params(vbs_plot, prm, show_bw=True):
454
+ """Point size, outlier size, jitter, and bandwidth as plotted.
455
+ R analog: .get.param() of .param.VBS()"""
456
+ if "s" not in vbs_plot and "v" not in vbs_plot:
457
+ return []
458
+ lines = ["", "---------- Parameter values (can be manually set)", ""]
459
+ if "s" in vbs_plot:
460
+ lines += [
461
+ f"pt_size: {prm['pt_size']:.2f} size of plotted points",
462
+ f"out_size: {prm['out_size']:.2f} size of plotted "
463
+ "outlier points",
464
+ f"jitter_y: {prm['jitter_y']:.2f} random vertical movement "
465
+ "of points",
466
+ f"jitter_x: {prm['jitter_x']:.2f} random horizontal "
467
+ "movement of points"]
468
+ if "v" in vbs_plot and show_bw:
469
+ lines.append(f"bw: {prm['bw']:.2f} set bandwidth higher "
470
+ "for smoother edges")
471
+ return lines
472
+
473
+
474
+ # ----- suggestions ------------------------------------------------
475
+
476
+ def _x_call(pre, x_name, dname, args):
477
+ return f'{pre}X("{x_name}", data={dname}{args})'
478
+
479
+
480
+ def suggest_hist(x_name, pre, dname, given):
481
+ if not get_option("suggest", True):
482
+ return []
483
+ lines = [">>> Suggestions"]
484
+ for p, what in (("bin_width", "set the width of each bin"),
485
+ ("bin_start", "set the start of the first bin"),
486
+ ("bin_end", "set the end of the last bin")):
487
+ if p not in given:
488
+ lines.append(f"{p}: {what}")
489
+ lines.append(_x_call(pre, x_name, dname, ', form="density"')
490
+ + " # smoothed curve + histogram")
491
+ lines.append(_x_call(pre, x_name, dname, ', form="vbs"')
492
+ + " # Violin/Box/Scatterplot (VBS) plot")
493
+ return lines
494
+
495
+
496
+ def suggest_density(x_name, pre, dname):
497
+ if not get_option("suggest", True):
498
+ return []
499
+ return [">>> Suggestions",
500
+ "bin_width: set the width of each bin",
501
+ _x_call(pre, x_name, dname, ', form="histogram"')
502
+ + " # histogram only",
503
+ _x_call(pre, x_name, dname, ', form="vbs"')
504
+ + " # Violin/Box/Scatterplot (VBS) plot"]
505
+
506
+
507
+ def suggest_freq_poly(x_name, pre, dname, given):
508
+ if not get_option("suggest", True):
509
+ return []
510
+ lines = [">>> Suggestions"]
511
+ if "bin_width" not in given:
512
+ lines.append("bin_width: set the width of each bin")
513
+ lines.append(_x_call(pre, x_name, dname, ', form="histogram"')
514
+ + " # histogram")
515
+ lines.append(_x_call(pre, x_name, dname,
516
+ ', form="freq_poly", area_fill="off"')
517
+ + " # polygon outline only")
518
+ return lines
519
+
520
+
521
+ def suggest_vbs(x_name, pre, dname, given, n_by=0, by_name=None,
522
+ n_facet=0, facet_name=None):
523
+ """R analog: the suggestions of .param.VBS()"""
524
+ if not get_option("suggest", True):
525
+ return []
526
+ lines = [">>> Suggestions"]
527
+ t = ["", "", ""]
528
+ c = ["", "", ""]
529
+ if "out_cut" not in given:
530
+ t[0], c[0] = ", out_cut=2", "Label two outliers ..."
531
+ if "fences" not in given:
532
+ t[1] = ", fences=True"
533
+ if c[0] == "":
534
+ c[1] = "Show inner fences"
535
+ if "vbs_mean" not in given:
536
+ t[2] = ", vbs_mean=True"
537
+ if c[1] == "":
538
+ c[2] = " Show mean"
539
+ if any(t):
540
+ lines.append(_x_call(pre, x_name, dname,
541
+ "".join(t) + ', form="vbs"')
542
+ + " # " + "".join(c))
543
+ if "box_adj" not in given:
544
+ lines.append(_x_call(pre, x_name, dname,
545
+ ', box_adj=True, form="vbs"')
546
+ + " # Adjust whiskers for asymmetry")
547
+ nm = (facet_name if n_facet == 2 else by_name if n_by == 2
548
+ else None)
549
+ if nm is not None:
550
+ lines.append(f'{pre}ttest("{x_name} ~ {nm}", data={dname})')
551
+ nm = (facet_name if n_facet > 2 else by_name if n_by > 2
552
+ else None)
553
+ if nm is not None:
554
+ lines.append(f'{pre}ANOVA("{x_name} ~ {nm}", data={dname})')
555
+ return lines
556
+
557
+
558
+ def report(*components):
559
+ """Join the components with one blank line between them, as
560
+ print.out_all() does, skipping empty ones."""
561
+ lines = []
562
+ for comp in components:
563
+ if not comp:
564
+ continue
565
+ if lines:
566
+ lines.append("")
567
+ lines += comp
568
+ return "\n".join(s.rstrip() for s in lines)
569
+
570
+
571
+ # ===== XY() ======================================================
572
+ # R analogs: .cr.main(brief=TRUE) (the correlation), the fit text and
573
+ # suggestions of .plt.txt(), .plt.MD(), and the run analysis.
574
+
575
+ def xy_digits(yv, digits_d=None):
576
+ """.plt.txt(): one past the decimals of y, at least 3"""
577
+ if digits_d is None:
578
+ digits_d = max_dd(yv) + 1
579
+ return max(digits_d, 3)
580
+
581
+
582
+ def cor_block(xv, yv, x_name, y_name, x_lbl=None, y_lbl=None):
583
+ """Pearson correlation, its t test and 95% confidence interval.
584
+ R analog: .cr.main(brief=TRUE) through cor.test()"""
585
+ from scipy import stats as sps
586
+ x = np.asarray(xv, dtype=float)
587
+ y = np.asarray(yv, dtype=float)
588
+ ok = np.isfinite(x) & np.isfinite(y)
589
+ x, y = x[ok], y[ok]
590
+ n = len(x)
591
+ lines = ["", ">>> Pearson's product-moment correlation"]
592
+ if x_lbl is not None or y_lbl is not None:
593
+ lines += ["", x_name if x_lbl is None else f"{x_name}: {x_lbl}",
594
+ y_name if y_lbl is None else f"{y_name}: {y_lbl}"]
595
+ lines += ["", "Number of paired values with neither missing, "
596
+ f"n = {n}"]
597
+ if n < 4:
598
+ return lines
599
+ r = float(np.corrcoef(x, y)[0, 1])
600
+ df = n - 2
601
+ t = r * np.sqrt(df / max(1 - r * r, 1e-300))
602
+ p = 2 * sps.t.sf(abs(t), df)
603
+ z, se = np.arctanh(r), 1 / np.sqrt(n - 3)
604
+ q = sps.norm.ppf(0.975)
605
+ lb, ub = np.tanh(z - q * se), np.tanh(z + q * se)
606
+
607
+ def f3(v):
608
+ return f"{round(float(v), 3):.3f}"
609
+
610
+ lines += [f"Sample Correlation of {x_name} and {y_name}: r = {r:.3f}",
611
+ "",
612
+ f"Hypothesis Test of 0 Correlation: t = {f3(t)}, "
613
+ f"df = {df}, p-value = {f3(p)}",
614
+ f"95% Confidence Interval for Correlation: {f3(lb)} to "
615
+ f"{f3(ub)}", ""]
616
+ return lines
617
+
618
+
619
+ def _lin_fit(xs, ys):
620
+ b1, b0 = np.polyfit(xs, ys, 1)
621
+ e = ys - (b0 + b1 * xs)
622
+ tss = ((ys - ys.mean()) ** 2).sum()
623
+ rsq = 1 - (e @ e) / tss if tss > 0 else np.nan
624
+ return b0, b1, (e @ e) / (len(ys) - 2), rsq
625
+
626
+
627
+ # the linearizing transform of y for each curved fit, as .plt.fit()
628
+ _FIT_OPS = {"quad": ("sqrt()", "square"),
629
+ "power": ("the root of the\n reciprocal of the power",
630
+ "of the power"),
631
+ "exp": ("log()", "exp()"),
632
+ "log": ("exp()", "log()")}
633
+
634
+
635
+ def fit_text(fit_stats, fit, fit_power, y_name, digits_d,
636
+ by_name=None, var_label=False):
637
+ """The fit line(s) and their MSE. lm and the curved fits report
638
+ the line of the (linearized) regression; loess its MSE alone.
639
+ R analog: the out_reg text of .plt.txt()"""
640
+ d = digits_d
641
+ p = 2 if fit == "quad" else fit_power
642
+ lines = []
643
+ for i, fs in enumerate(fit_stats):
644
+ nm, ys, f = fs[0], np.asarray(fs[2], float), np.asarray(fs[3], float)
645
+ xs = np.asarray(fs[4], float) if len(fs) > 4 else None
646
+ msg = ""
647
+ if fit in _FIT_OPS and (i == 0 or nm is not None):
648
+ msg = ("Regressed linearized data of transformed data "
649
+ f"values of {y_name} with {_FIT_OPS[fit][0]}")
650
+ # R: paste(msg, by.name, ": ", by.cat, " ") or by.cat ("")
651
+ head = (" " if nm is None
652
+ else f"Variable: {nm} " if var_label
653
+ else f"{by_name}: {nm} ")
654
+ if msg:
655
+ lines += msg.split("\n")
656
+ if fit == "loess" or xs is None:
657
+ mse = ((ys - f) ** 2).sum() / (len(ys) - 2)
658
+ md = "Loess" if fit == "loess" else "Linear"
659
+ lines += [f"{head} {md} Model MSE = {mse:,.{d}f}", ""]
660
+ continue
661
+ yt = ys
662
+ if fit in ("quad", "power"):
663
+ yt = ys ** (1 / p)
664
+ elif fit == "exp":
665
+ yt = np.log(ys if p == 1 else ys ** (1 / p))
666
+ elif fit == "log":
667
+ yt = np.exp(ys if p == 1 else ys ** (1 / p))
668
+ ok = np.isfinite(yt)
669
+ b0, b1, mse, rsq = _lin_fit(xs[ok], yt[ok])
670
+ lines += [f"{head}Line: b0 = {b0:.{d}f} b1 = {b1:.{d}f}",
671
+ f" Linear Model MSE = {mse:,.{d}f} Rsq = {rsq:.3f}",
672
+ ""]
673
+ if fit in _FIT_OPS:
674
+ mse_nl = ((ys - f) ** 2).sum() / (len(ys) - 2)
675
+ lines[-1:] = ["", "Fit to the data with back transform "
676
+ f"{_FIT_OPS[fit][1]} of linear regression model",
677
+ f"Model MSE = {mse_nl:,.{d}f}", "", ""]
678
+ return lines
679
+
680
+
681
+ def _xy_call(pre, x, y, dname, extra=""):
682
+ def q(v):
683
+ if isinstance(v, (list, tuple)):
684
+ return "[" + ", ".join(f'"{s}"' for s in v) + "]"
685
+ return f'"{v}"'
686
+ return f"{pre}XY({q(x)}, {q(y)}, data={dname}{extra}"
687
+
688
+
689
+ def suggest_scatter(pre, x, y, dname, given, by=None, rng=None):
690
+ """The scatterplot suggestions, R's alternates drawn at random as
691
+ .plt.txt() does with runif(). R analog: .plt.txt() contcont"""
692
+ if not get_option("suggest", True):
693
+ return []
694
+ rng = np.random.default_rng() if rng is None else rng
695
+ fc = _xy_call(pre, x, y, dname,
696
+ f', by="{by}"' if by is not None else "")
697
+ lines = [">>> Suggestions or enter: style(suggest=False)"]
698
+ if "enhance" not in given:
699
+ lines.append(f"{fc}, enhance=True) # many options")
700
+ if rng.random() > 0.5:
701
+ if "fill" not in given:
702
+ lines.append(f'{fc}, fill="skyblue") # interior fill color '
703
+ "of points")
704
+ elif "color" not in given:
705
+ lines.append(f'{fc}, color="red") # exterior edge color of '
706
+ "points")
707
+ if "fit" not in given:
708
+ lines.append(f'{fc}, fit="lm", fit_se=[.90, .99]) # fit line, '
709
+ "stnd errors")
710
+ if rng.random() > 0.5:
711
+ if "out_cut" not in given:
712
+ lines.append(f"{fc}, out_cut=.10) # label top 10% from "
713
+ "center as outliers")
714
+ elif "MD_cut" not in given:
715
+ lines.append(f"{fc}, MD_cut=6) # Mahalanobis distance from "
716
+ "center > 6 is an outlier")
717
+ if by is not None and "pt_shape" not in given:
718
+ lines.append(f'{fc}, pt_shape="diamond") # diamond for points')
719
+ return lines
720
+
721
+
722
+ def suggest_series(pre, x, y, dname, given, extra="", run=False,
723
+ pt_size=1):
724
+ """Time-series and run-chart suggestions. R analog: .plt.txt()
725
+ date.var and run blocks"""
726
+ if not get_option("suggest", True):
727
+ return []
728
+ lines = [">>> Suggestions or enter: style(suggest=False)"]
729
+ fc = _xy_call(pre, x, y, dname)
730
+ full = _xy_call(pre, x, y, dname, extra)
731
+ if not run:
732
+ if "ts_ahead" not in given:
733
+ lines.append(f"{fc}, ts_ahead=4) # exponential smoothing "
734
+ "forecast 4 time units")
735
+ if "ts_unit" not in given:
736
+ lines.append(f'{fc}, ts_unit="years") # aggregate time by '
737
+ "yearly sum")
738
+ if "ts_agg" not in given:
739
+ lines.append(f'{fc}, ts_unit="years", ts_agg="mean") '
740
+ "# aggregate by yearly mean")
741
+ if "ts_ahead" in given and "ts_seasons" not in given:
742
+ lines.append(f"{fc}, ts_ahead=4, ts_seasons=False) # turn "
743
+ "off exponential smoothing seasonal effect")
744
+ if "pt_size" not in given and pt_size > 0:
745
+ lines.append(f"{full}, pt_size=0) # just line segments, "
746
+ "no points")
747
+ if "line_width" not in given:
748
+ if pt_size > 0:
749
+ lines.append(f"{full}, line_width=0) # just points, no "
750
+ "line segments")
751
+ else:
752
+ lines.append(f'{full}, line_width=0, ts_area_fill="on") '
753
+ "# just area")
754
+ if "ts_area_fill" not in given and "ts_stack" not in given:
755
+ lines.append(f'{full}, ts_area_fill="on") # default color fill')
756
+ return lines
757
+
758
+
759
+ def run_summary(yv, digits_d):
760
+ """The summary of y above a run analysis, untitled.
761
+ R analog: .ss.numeric(y, x.name="*NONE*", brief=TRUE)"""
762
+ lines = ss_numeric(yv, "", None, digits_d)
763
+ return [" "] + lines[2:]