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/stats_out.py CHANGED
@@ -32,11 +32,17 @@ def x_stats(xv, x_name, digits_d=2):
32
32
  lines = [f"--- {x_name} ---",
33
33
  f"n: {n} missing: {miss}"]
34
34
  if n < 2:
35
+ record_stats(n=n, n_miss=miss)
35
36
  return lines
36
37
  q1, med, q3 = np.percentile(v, [25, 50, 75])
37
38
  iqr = q3 - q1
38
39
  out_v = np.sort(v[(v < q1 - 1.5 * iqr)
39
40
  | (v > q3 + 1.5 * iqr)])
41
+ record_stats(n=n, n_miss=miss, mean=float(v.mean()),
42
+ sd=float(v.std(ddof=1)), min=float(v.min()),
43
+ q1=float(q1), median=float(med), q3=float(q3),
44
+ max=float(v.max()), iqr=float(iqr),
45
+ outliers=out_v.tolist())
40
46
  lines += [
41
47
  f"mean: {fmt(v.mean(), d)} sd: {fmt(v.std(ddof=1), d)}",
42
48
  f"min: {fmt(v.min(), d)} 1st Qu: {fmt(q1, d)} "
@@ -94,29 +100,70 @@ def _cor_block(xg, yg, label, d):
94
100
 
95
101
 
96
102
  def xy_stats(groups, x_name, y_name, fit_stats=None,
97
- digits_d=2):
103
+ digits_d=2, by_name=None):
98
104
  """Correlation (t test, Fisher-z CI) for the relationship
99
105
  XY() displays, overall or per by= group, plus fit-line MSE
100
106
  when a fit line is drawn."""
101
107
  lines = [f"--- {x_name} and {y_name} ---"]
108
+ cors = {}
102
109
  for nm, xg, yg in groups:
103
110
  label = "" if nm is None else f" ({nm})"
104
- lines += _cor_block(np.asarray(xg, float),
105
- np.asarray(yg, float), label,
106
- digits_d)
111
+ xg, yg = np.asarray(xg, float), np.asarray(yg, float)
112
+ lines += _cor_block(xg, yg, label, digits_d)
113
+ cors[nm] = _cor_values(xg, yg)
114
+ fits = []
107
115
  for fs in (fit_stats or []):
108
- nm, fit, ys, f = fs
116
+ nm, fit, ys, f = fs[:4]
117
+ xs = fs[4] if len(fs) > 4 else None
109
118
  label = "" if nm is None else f" ({nm})"
110
- mse = float(np.mean((ys - f) ** 2))
119
+ # the error variance of one predictor, n - 2 degrees of
120
+ # freedom, as R's .plt.fit() computes it for every fit
121
+ sse = float(((ys - f) ** 2).sum())
122
+ mse = sse / (len(ys) - 2) if len(ys) > 2 else np.nan
111
123
  tss = float(((ys - ys.mean()) ** 2).sum())
112
124
  rsq = 1 - float(((ys - f) ** 2).sum()) / tss \
113
125
  if tss > 0 else np.nan
114
- lines.append(
115
- f'fit="{fit}"{label}: MSE: {fmt(mse, digits_d + 1)}'
116
- f" R-squared: {fmt(rsq, 3)}")
126
+ entry = dict(group=nm, fit=fit, mse=mse, rsq=rsq)
127
+ if fit == "lm" and xs is not None and len(xs) > 1:
128
+ # the line and its fit as R's .plt.fit() reports them
129
+ b1, b0 = np.polyfit(np.asarray(xs, float),
130
+ np.asarray(ys, float), 1)
131
+ entry.update(b0=float(b0), b1=float(b1))
132
+ head = (" " if nm is None
133
+ else f"{by_name}: {nm} " if by_name
134
+ else f"{nm}: ")
135
+ lines += [f"{head}Line: b0 = {b0:.3f} b1 = {b1:.3f}",
136
+ f" Linear Model MSE = {mse:,.3f} "
137
+ f"Rsq = {rsq:.3f}"]
138
+ else:
139
+ lines.append(
140
+ f'fit="{fit}"{label}: MSE: {fmt(mse, digits_d + 1)}'
141
+ f" R-squared: {fmt(rsq, 3)}")
142
+ fits.append(entry)
143
+ # one relationship reads as its own entries; groups by name
144
+ if list(cors) == [None]:
145
+ record_stats(**cors[None])
146
+ else:
147
+ record_stats(cor=cors)
148
+ if fits:
149
+ record_stats(fit=fits if len(fits) > 1 else fits[0])
117
150
  return lines
118
151
 
119
152
 
153
+ def _cor_values(xg, yg):
154
+ """The correlation of _cor_block as numbers."""
155
+ n = len(xg)
156
+ if n < 4:
157
+ return dict(n=n)
158
+ r = float(np.corrcoef(xg, yg)[0, 1])
159
+ tval = r * np.sqrt((n - 2) / max(1 - r * r, 1e-12))
160
+ p = 2 * sps.t.sf(abs(tval), n - 2)
161
+ zlo = np.arctanh(r) - 1.959964 / np.sqrt(n - 3)
162
+ zhi = np.arctanh(r) + 1.959964 / np.sqrt(n - 3)
163
+ return dict(n=n, r=r, t=float(tval), df=n - 2, p_value=float(p),
164
+ ci_lb=float(np.tanh(zlo)), ci_ub=float(np.tanh(zhi)))
165
+
166
+
120
167
  def md_outliers(xv, yv, ids, MD_cut, out_cut):
121
168
  """Bivariate outliers by Mahalanobis distance from the
122
169
  centroid: MD_cut an absolute threshold, out_cut a proportion
@@ -144,7 +191,7 @@ def md_outliers(xv, yv, ids, MD_cut, out_cut):
144
191
  n_lines = min(len(out_idx) + 3, len(dst))
145
192
  w_id = max(len(s) for s in ids)
146
193
  w_md = max(len(fmt(d, 2)) for d in dst)
147
- lines = [">>> Outlier analysis with Mahalanobis Distance",
194
+ lines = [">>> Outlier analysis with squared Mahalanobis Distance",
148
195
  "",
149
196
  f"{'MD':>{w_md}} {'ID':>{w_id + 1}}",
150
197
  f"{'-----':>{w_md}} {'-----':>{w_id + 1}}"]
@@ -155,7 +202,7 @@ def md_outliers(xv, yv, ids, MD_cut, out_cut):
155
202
  lines.append(f"{fmt(dst[j], 2):>{w_md}} "
156
203
  f"{ids[j]:>{w_id + 1}}")
157
204
  if n_lines < len(dst):
158
- lines.append(f"{'...':>{w_md}} {'...':>{w_id + 1}}")
205
+ lines.append(f"{'...':>{w_md - 1}} {'...':>{w_id + 2}}")
159
206
  return out_idx, lines
160
207
 
161
208
 
@@ -542,3 +589,202 @@ def nested_stats(cols, names, y_ser, stat, y_name, digits_d=2):
542
589
  title = (f"{STAT_LBL.get(stat, stat)} of {y_name} by "
543
590
  + ", ".join(names))
544
591
  return [title, "-" * len(title), ""] + body.split("\n") + [""]
592
+
593
+
594
+ # ----- the statistics returned with a Chart() figure ----------------
595
+
596
+ class ChartStats:
597
+ """The statistics behind a Chart(), attached to the returned figure
598
+ as fig.stats. Each entry reads as an attribute, fig.stats.freq, or
599
+ by name, fig.stats["freq"], named as R's Chart() names its returned
600
+ list where it has a counterpart: freq, prop, values, p_value,
601
+ n_miss. text holds the console report, kept when quiet=True
602
+ suppresses its display. Not a dict subclass, so that an entry such
603
+ as values is not shadowed by a dict method. R analog: the list
604
+ that Chart() returns invisibly"""
605
+
606
+ def __init__(self, *args, **kw):
607
+ for k, v in dict(*args, **kw).items():
608
+ setattr(self, k, v)
609
+
610
+ def __getitem__(self, name):
611
+ try:
612
+ return self.__dict__[name]
613
+ except KeyError:
614
+ raise KeyError(name) from None
615
+
616
+ def __setitem__(self, name, value):
617
+ self.__dict__[name] = value
618
+
619
+ def __contains__(self, name):
620
+ return name in self.__dict__
621
+
622
+ def __iter__(self):
623
+ return iter(self.__dict__)
624
+
625
+ def __len__(self):
626
+ return len(self.__dict__)
627
+
628
+ def keys(self):
629
+ return self.__dict__.keys()
630
+
631
+ def get(self, name, default=None):
632
+ return self.__dict__.get(name, default)
633
+
634
+ def update(self, *args, **kw):
635
+ for k, v in dict(*args, **kw).items():
636
+ self.__dict__[k] = v
637
+
638
+ def __repr__(self):
639
+ keys = ", ".join(k for k in self.__dict__ if k != "text")
640
+ return (self.get("text", "").rstrip() + "\n\n"
641
+ f"[ChartStats: {keys}]")
642
+
643
+ __str__ = __repr__
644
+
645
+
646
+ def _ordered_counts(s):
647
+ t = s.groupby(s, observed=True).size()
648
+ return t[t > 0]
649
+
650
+
651
+ def _chisq_1d(cnt):
652
+ if len(cnt) < 2:
653
+ return {}
654
+ chi, p = sps.chisquare(cnt.to_numpy())
655
+ return dict(chisq=float(chi), df=len(cnt) - 1, p_value=float(p))
656
+
657
+
658
+ def _xtab_stats(ct):
659
+ obs = ct.to_numpy()
660
+ out = dict(freq=ct, prop=ct / obs.sum(),
661
+ prop_col=ct / ct.sum(axis=0),
662
+ prop_row=ct.div(ct.sum(axis=1), axis=0))
663
+ chi, p, dof, _ = sps.chi2_contingency(obs, correction=False)
664
+ if np.isfinite(chi):
665
+ out.update(chisq=float(chi), df=int(dof), p_value=float(p),
666
+ cramer_v=float(np.sqrt(chi / ((min(obs.shape) - 1)
667
+ * obs.sum()))))
668
+ return out
669
+
670
+
671
+ def chart_stats_data(x_ser, by_ser, y_ser, stat, facet_ser=None,
672
+ n_miss=None):
673
+ """The numbers of the Chart() report as pandas objects: counts
674
+ with proportions and the chi-square test of equal probabilities;
675
+ a cross-tabulation with cell, column, and row proportions,
676
+ Cramer's V, and the test of independence; or the summary of y
677
+ over the levels of x and the plotted values, or the table of the
678
+ statistic over the grouping"""
679
+ st = ChartStats()
680
+ if y_ser is None:
681
+ grp = by_ser if by_ser is not None else facet_ser
682
+ if grp is None:
683
+ cnt = _ordered_counts(x_ser)
684
+ st.update(n_dim=1, freq=cnt, prop=cnt / cnt.sum(),
685
+ n_miss=n_miss, **_chisq_1d(cnt))
686
+ elif by_ser is not None and facet_ser is not None:
687
+ # one cross-tabulation per panel
688
+ panels = {}
689
+ for lv in _category_order_sorted(facet_ser):
690
+ m = facet_ser == lv
691
+ panels[lv] = ChartStats(
692
+ _xtab_stats(pd.crosstab(by_ser[m], x_ser[m])))
693
+ st.update(n_dim=3, panels=panels)
694
+ else:
695
+ st.update(n_dim=2, **_xtab_stats(pd.crosstab(grp, x_ser)))
696
+ return st
697
+
698
+ fun = STAT_FUN[stat]
699
+ if by_ser is None and facet_ser is None:
700
+ y = pd.to_numeric(y_ser)
701
+ g = y.groupby(x_ser, observed=True, sort=True)
702
+ summary = pd.DataFrame({
703
+ "n": g.count(), "miss": g.size() - g.count(),
704
+ "mean": g.mean(), "sd": g.std(ddof=1), "min": g.min(),
705
+ "mdn": g.median(), "max": g.max()})
706
+ st.update(n_dim=1, summary=summary, values=fun(g))
707
+ return st
708
+ grp = by_ser if by_ser is not None else facet_ser
709
+ df = pd.DataFrame({"g": grp, "x": x_ser, "y": pd.to_numeric(y_ser)})
710
+ tbl = (df.groupby(["g", "x"], observed=True)["y"].agg(fun)
711
+ .unstack("x"))
712
+ st.update(n_dim=2, summary=tbl)
713
+ return st
714
+
715
+
716
+
717
+ # ----- statistics of the current call, returned with its figure -----
718
+
719
+ import contextvars as _contextvars
720
+
721
+ CALL_STATS = _contextvars.ContextVar("lessPy_call_stats", default=None)
722
+
723
+ # the caller's output while a report is captured, for advisories
724
+ _REAL_OUT = _contextvars.ContextVar("lessPy_real_stdout", default=None)
725
+
726
+
727
+ def advise(msg):
728
+ """An advisory, shown whether or not the report is quiet, as R's
729
+ message() is: written past a captured report to the caller's
730
+ output."""
731
+ out = _REAL_OUT.get()
732
+ if out is None:
733
+ print(msg)
734
+ else:
735
+ print(msg, file=out)
736
+
737
+
738
+ def record_stats(**kw):
739
+ """Add entries to the statistics of the current Chart(), X(), or
740
+ XY() call; a no-op outside one."""
741
+ st = CALL_STATS.get()
742
+ if st is not None:
743
+ st.update(kw)
744
+
745
+
746
+ def attach_stats(func, capture=False):
747
+ """Return the figure with the call's statistics as fig.stats. A
748
+ plotly Figure accepts no new attributes by assignment, so it is
749
+ set past plotly's validation; it survives update_layout() and
750
+ stays out of the figure's JSON, though go.Figure(fig) copies the
751
+ figure without it.
752
+
753
+ capture: the function prints its report from many places, so run
754
+ it with quiet=False and its output captured as stats.text, then
755
+ display that text unless the caller asked for quiet."""
756
+ import functools
757
+ import io
758
+ import sys
759
+ from contextlib import redirect_stdout
760
+
761
+ @functools.wraps(func)
762
+ def wrapper(*args, **kwargs):
763
+ token = CALL_STATS.set(ChartStats() if capture else None)
764
+ try:
765
+ if capture:
766
+ quiet = resolve_quiet(kwargs.get("quiet"))
767
+ kwargs["quiet"] = False
768
+ buf = io.StringIO()
769
+ real = _REAL_OUT.set(sys.stdout)
770
+ try:
771
+ with redirect_stdout(buf):
772
+ fig = func(*args, **kwargs)
773
+ finally:
774
+ _REAL_OUT.reset(real)
775
+ out = buf.getvalue()
776
+ if not quiet:
777
+ sys.stdout.write(out)
778
+ st = CALL_STATS.get()
779
+ st["text"] = out.rstrip("\n")
780
+ else:
781
+ fig = func(*args, **kwargs)
782
+ st = CALL_STATS.get()
783
+ finally:
784
+ CALL_STATS.reset(token)
785
+ if fig is not None and hasattr(fig, "layout"):
786
+ object.__setattr__(fig, "stats",
787
+ st if st is not None else ChartStats())
788
+ return fig
789
+
790
+ return wrapper
lessPy/style.py ADDED
@@ -0,0 +1,160 @@
1
+ # style.py — analog of lessR's style(): the color theme, a sub_theme,
2
+ # and the individual appearance settings that every visualization
3
+ # reads, Chart(), X(), and XY() alike, until changed.
4
+ #
5
+ # The settings of each theme come from lessR itself (_themes.py,
6
+ # generated from style(theme[, sub_theme])), so a theme draws the same
7
+ # colors in both packages. Text sizes are lessPy's own, tuned for
8
+ # plotly in notebooks, and naming a theme leaves them as they were.
9
+ # R analog: style.R
10
+
11
+ import copy
12
+
13
+ from . import utils
14
+ from ._themes import THEMES
15
+
16
+ # the text sizes, which a theme does not set
17
+ _SIZE_KEYS = ("main_size", "lab_size", "lab_x_size", "lab_y_size",
18
+ "axis_size", "axis_x_size", "axis_y_size", "sub_size",
19
+ "add_size")
20
+
21
+ # lessR style() parameters with no lessPy counterpart: R Markdown output
22
+ # and console settings, and parameters that lessPy takes in the call
23
+ # of the visualization function instead
24
+ _R_ONLY = ("results", "explain", "interpret", "document", "code",
25
+ "width", "show", "lessR.use_plotly", "notes",
26
+ "brief", "offset", "rotate_x", "rotate_y", "labels",
27
+ "labels_size", "labels_digits", "labels_position")
28
+
29
+ # start-up settings that a theme does not reach: lessPy's own options,
30
+ # captured once at import, before any style() call
31
+ _BASELINE = copy.deepcopy(utils._OPTIONS)
32
+
33
+
34
+ def _style_keys():
35
+ keys = set(THEMES["colors"]["none"]) | set(_SIZE_KEYS)
36
+ keys |= {"quiet", "suggest", "digits_d", "n_cat", "grid_col",
37
+ "legend_border"}
38
+ return keys
39
+
40
+
41
+ def _theme_settings(theme, sub_theme):
42
+ if theme not in THEMES:
43
+ raise ValueError(
44
+ f"theme='{theme}' is not a theme. Themes: "
45
+ + ", ".join(THEMES))
46
+ variant = "none" if sub_theme is None else sub_theme
47
+ if variant not in THEMES[theme]:
48
+ raise ValueError(
49
+ f"sub_theme='{sub_theme}' is not a sub_theme. Sub-themes: "
50
+ "default, black, wsj")
51
+ return THEMES[theme][variant]
52
+
53
+
54
+ def style(theme=None, sub_theme=None, set=None, get=False, reset=True,
55
+ **settings):
56
+ """Set the appearance of every subsequent visualization.
57
+
58
+ style("darkred") applies a color theme, resetting every other
59
+ setting to the theme's own; sub_theme="black" draws on a black
60
+ background, "default" on a gray panel with white grid lines, as in
61
+ lessR. Name any setting to change only that one, as in
62
+ style(panel_fill="gray95", axis_size=1.1), with or without a theme.
63
+ style() alone restores the default theme. get=True returns the
64
+ current settings as a dict, which set= restores. A visualization
65
+ function's own theme= applies a theme for that call only.
66
+
67
+ trans sets the transparency of both the bar and the point fills.
68
+ Settings that lessR's style() takes for R Markdown or the console,
69
+ or that lessPy takes in the visualization call (rotate_x, labels,
70
+ ...), are not available here. R analog: style()"""
71
+ keys = _style_keys()
72
+
73
+ if set is not None:
74
+ for k, v in set.items():
75
+ utils._OPTIONS[k] = copy.deepcopy(v)
76
+ return None
77
+
78
+ # transparency names both fills at once, as in lessR
79
+ for alias in ("trans", "transparency"):
80
+ if alias in settings:
81
+ t = settings.pop(alias)
82
+ settings.setdefault("trans_bar_fill", t)
83
+ settings.setdefault("trans_pt_fill", t)
84
+
85
+ bad = [k for k in settings if k not in keys]
86
+ if bad:
87
+ r_only = [k for k in bad if k in _R_ONLY]
88
+ other = [k for k in bad if k not in _R_ONLY]
89
+ msg = []
90
+ if r_only:
91
+ msg.append(
92
+ f"{', '.join(r_only)}: lessR style() settings with no "
93
+ "lessPy counterpart (R Markdown and console output, or "
94
+ "set in the call of the visualization function)")
95
+ if other:
96
+ msg.append(f"{', '.join(other)}: not a style() setting")
97
+ raise ValueError(".\n".join(msg))
98
+
99
+ nothing = (theme is None and sub_theme is None and not settings
100
+ and not get)
101
+ if nothing:
102
+ theme = "colors" # style() restores the default
103
+
104
+ if (theme is not None or sub_theme is not None) and reset:
105
+ th = theme if theme is not None else utils.get_option(
106
+ "theme", "colors")
107
+ st = (sub_theme if sub_theme is not None
108
+ else None if theme is not None
109
+ else utils.get_option("sub_theme"))
110
+ base = copy.deepcopy(_BASELINE)
111
+ base.update(copy.deepcopy(_theme_settings(th, st)))
112
+ utils._OPTIONS.clear()
113
+ utils._OPTIONS.update(base)
114
+ utils._OPTIONS["theme"] = th
115
+ utils._OPTIONS["sub_theme"] = st
116
+
117
+ for k, v in settings.items():
118
+ utils._OPTIONS[k] = v
119
+
120
+ if get:
121
+ return {k: copy.deepcopy(utils._OPTIONS.get(k))
122
+ for k in sorted(keys | {"theme", "sub_theme"})}
123
+ return None
124
+
125
+
126
+ class _AppliedTheme:
127
+ """A theme for the duration of one call, the settings restored
128
+ after it. R analog: the theme= of Chart(), X(), and XY(), which
129
+ call style(theme) with on.exit(style(old_theme))"""
130
+
131
+ def __init__(self, theme):
132
+ self.theme = theme
133
+
134
+ def __enter__(self):
135
+ self.saved = copy.deepcopy(utils._OPTIONS)
136
+ if self.theme is not None and \
137
+ self.theme != utils.get_option("theme"):
138
+ style(self.theme)
139
+ return self
140
+
141
+ def __exit__(self, *exc):
142
+ utils._OPTIONS.clear()
143
+ utils._OPTIONS.update(self.saved)
144
+ return False
145
+
146
+
147
+ def with_theme(func, own_param=False):
148
+ """Give a visualization function the theme= of its lessR analog:
149
+ the theme applies to that call only. own_param: the function takes
150
+ theme itself (Chart), so it is passed on as well."""
151
+ import functools
152
+
153
+ @functools.wraps(func)
154
+ def wrapper(*args, theme=None, **kwargs):
155
+ if own_param:
156
+ kwargs["theme"] = theme
157
+ with _AppliedTheme(theme):
158
+ return func(*args, **kwargs)
159
+
160
+ return wrapper
@@ -0,0 +1,140 @@
1
+ # sunflower_plotly.py — XY(form="sunflower")
2
+ #
3
+ # A DESIGN, not a translation: lessR draws the sunflower only with
4
+ # base R's sunflowerplot() and, faceted, a lattice panel of its own
5
+ # (.plt.sunflower.facet), so there is no plotly source to port. As
6
+ # there: one dot at each distinct (x, y), and where k > 1
7
+ # observations coincide, k petals radiating from it, the first
8
+ # vertical, evenly spaced (sunflowerplot()'s angles), 1/8 inch long.
9
+ # Dots take bar_fill_cont, petals the point color, as .plt.main().
10
+ # Plotly has no petal symbol, so the petals are line segments in data
11
+ # units, sized from the plotting region of the default figure so that
12
+ # they read as equal-length spokes. facet= draws one panel per level,
13
+ # each with its own coordinates and so its own petal counts.
14
+
15
+ import numpy as np
16
+ import plotly.graph_objects as go
17
+
18
+ from .plotly_utils import (
19
+ axis_format, axis_num, facet_fig, facet_panels, finish_facet,
20
+ make_trans, plot_border, plotly_style, to_hex, x_grid,
21
+ )
22
+ from .utils import get_option, pretty
23
+
24
+ _PETAL_PX = 12 # 1/8 inch at 96 dpi
25
+ _PLOT_PX = (540, 330) # plotting region of the default figure
26
+
27
+
28
+ def _counts(x, y):
29
+ """Distinct coordinates and how many observations share each.
30
+ R analog: table(x, y) of .plt.main()"""
31
+ ok = np.isfinite(x) & np.isfinite(y)
32
+ pts, cnt = np.unique(np.column_stack((x[ok], y[ok])), axis=0,
33
+ return_counts=True)
34
+ return pts[:, 0], pts[:, 1], cnt
35
+
36
+
37
+ def _petals(xx, yy, cnt, dx, dy):
38
+ """Segments for every coordinate with more than one observation:
39
+ cnt petals, the first vertical. R analog: sunflowerplot()"""
40
+ sx, sy = [], []
41
+ for x0, y0, k in zip(xx, yy, cnt):
42
+ if k < 2:
43
+ continue
44
+ ang = np.pi / 2 + 2 * np.pi * np.arange(k) / k
45
+ for a in ang:
46
+ sx += [x0, x0 + dx * np.cos(a), None]
47
+ sy += [y0, y0 + dy * np.sin(a), None]
48
+ return sx, sy
49
+
50
+
51
+ def _panel_traces(x, y, xr, yr, w_px, h_px, fill, color, pt_size,
52
+ hover):
53
+ xx, yy, cnt = _counts(x, y)
54
+ dx = _PETAL_PX * (xr[1] - xr[0]) / w_px
55
+ dy = _PETAL_PX * (yr[1] - yr[0]) / h_px
56
+ sx, sy = _petals(xx, yy, cnt, dx, dy)
57
+ px = max(3.0, 5.5 * float(pt_size))
58
+ return [
59
+ go.Scatter(x=sx, y=sy, mode="lines",
60
+ line=dict(color=to_hex(color), width=1),
61
+ hoverinfo="skip", showlegend=False),
62
+ go.Scatter(x=xx, y=yy, mode="markers",
63
+ marker=dict(size=px, color=to_hex(fill),
64
+ line=dict(width=0)),
65
+ customdata=cnt, hovertemplate=hover,
66
+ showlegend=False)]
67
+
68
+
69
+ def _range(v):
70
+ t = pretty(float(np.nanmin(v)), float(np.nanmax(v)))
71
+ pad = 0.04 * (t[-1] - t[0])
72
+ return t, (t[0] - pad, t[-1] + pad)
73
+
74
+
75
+ def sunflower_plotly(x, y, x_lab, y_lab, main=None, facet=None,
76
+ facet_order=None, facet_name=None, facet2=None,
77
+ facet2_order=None, facet2_name=None, n_col=1,
78
+ pt_size=1, fill=None, color=None, overlay=None,
79
+ digits_d=2, axis_fmt="K", axis_x_pre="",
80
+ axis_y_pre=""):
81
+ """The sunflower plot of x and y, single panel or faceted.
82
+ overlay(fig, x, y, row, col) adds a fit line or an ellipse to a
83
+ panel. Returns a plotly Figure."""
84
+ x = np.asarray(x, dtype=float)
85
+ y = np.asarray(y, dtype=float)
86
+ fill = get_option("bar_fill_cont", "#96AAC3") if fill is None \
87
+ else fill
88
+ color = get_option("pt_color", "#324E5C") if color is None \
89
+ else color
90
+ style_opts = plotly_style()
91
+ axT1, xr = _range(x)
92
+ axT2, yr = _range(y)
93
+ hover = (f"{x_lab}: %{{x}}<br>{y_lab}: %{{y}}<br>"
94
+ "count: %{customdata}<extra></extra>")
95
+
96
+ if facet is None:
97
+ fig = go.Figure(_panel_traces(x, y, xr, yr, *_PLOT_PX, fill,
98
+ color, pt_size, hover))
99
+ if overlay is not None:
100
+ overlay(fig, x, y, None, None)
101
+ ax_x = axis_num(x_lab, axT1, axis_format(axT1, digits_d,
102
+ axis_fmt, axis_x_pre))
103
+ ax_y = axis_num(y_lab, axT2, axis_format(axT2, digits_d,
104
+ axis_fmt, axis_y_pre))
105
+ ax_x.update(range=list(xr))
106
+ ax_y.update(range=list(yr), showgrid=True,
107
+ gridcolor=to_hex(style_opts["grid_col"]),
108
+ gridwidth=1, griddash="dot")
109
+ fig.update_layout(
110
+ xaxis=ax_x, yaxis=ax_y, template=None,
111
+ shapes=x_grid(axT1) + plot_border(),
112
+ plot_bgcolor=to_hex(style_opts["panel_fill"]),
113
+ paper_bgcolor=to_hex(style_opts["window_fill"]))
114
+ else:
115
+ labels, pos, sel, n_row_g, n_col = facet_panels(
116
+ facet, facet_order, facet2, facet2_order, facet_name,
117
+ facet2_name, n_col)
118
+ fig = facet_fig(n_row_g, n_col)
119
+ w_px = _PLOT_PX[0] / n_col
120
+ h_px = _PLOT_PX[1] * 1.3 / n_row_g
121
+ for i, m in enumerate(sel):
122
+ row, col = pos[i]
123
+ for tr in _panel_traces(x[m], y[m], xr, yr, w_px, h_px,
124
+ fill, color, pt_size, hover):
125
+ fig.add_trace(tr, row=row, col=col)
126
+ if overlay is not None and m.sum() > 2:
127
+ overlay(fig, x[m], y[m], row, col)
128
+ ax = {"axT1": axT1,
129
+ "axL1": axis_format(axT1, digits_d, axis_fmt, axis_x_pre),
130
+ "axT2": axT2,
131
+ "axL2": axis_format(axT2, digits_d, axis_fmt, axis_y_pre)}
132
+ finish_facet(fig, labels, ax, x_lab, y_lab, gridT1=axT1,
133
+ style_opts=style_opts, n_col=n_col, pos=pos)
134
+ fig.update_xaxes(range=list(xr))
135
+ fig.update_yaxes(range=list(yr))
136
+ if main:
137
+ fig.update_layout(title=dict(
138
+ text=main, x=0.5, xanchor="center",
139
+ font=dict(size=round(16 * get_option("main_size", 1)))))
140
+ return fig
lessPy/ttest.py CHANGED
@@ -24,7 +24,7 @@ import numpy as np
24
24
  import pandas as pd
25
25
 
26
26
  from .Regression import _getdigits
27
- from .utils import fmt, get_column, get_option
27
+ from .utils import fmt, get_column, get_option, show_plots
28
28
 
29
29
 
30
30
  class ttestResults:
@@ -309,14 +309,14 @@ def _two_group(n1, m1, s1, n2, m2, s2, Ynm, Xnm, X1nm, X2nm,
309
309
  if graph and from_data:
310
310
  plots["two_group"] = _two_group_plot(
311
311
  YA, YB, Ynm, X1nm, X2nm, m1, m2, d)
312
- return ttestResults(
312
+ return show_plots(ttestResults(
313
313
  kind="two-group", n_total=n1 + n2, digits_d=d,
314
314
  group1={"name": X1nm, "n": n1, "mean": m1, "sd": s1},
315
315
  group2={"name": X2nm, "n": n2, "mean": m2, "sd": s2},
316
316
  mean_diff=m1 - m2, pooled_sd=sw, cohen_d=smd,
317
317
  equal_var={"t": tvalue, "df": df, "p_value": pvalue,
318
318
  "se": sterr, "lb": lb, "ub": ub},
319
- welch=welch, plots=plots)
319
+ welch=welch, plots=plots))
320
320
 
321
321
 
322
322
  def _assumptions(YA, YB, X1nm, X2nm, Ynm, n1, n2, v1, v2, df1,
@@ -529,12 +529,12 @@ def _one_group(Y, n, m, s, mu, Ynm, brief, conf_level,
529
529
  if graph and from_data:
530
530
  plots["one_group"] = _one_group_plot(
531
531
  Y, Ynm, m, lb, ub, d, paired)
532
- return ttestResults(
532
+ return show_plots(ttestResults(
533
533
  kind="paired" if paired else "one-group", n_total=n,
534
534
  digits_d=d, n=n, mean=m, sd=s, mu=mu,
535
535
  infer={"t": tvalue, "df": df, "p_value": pvalue,
536
536
  "se": sterr, "lb": lb, "ub": ub},
537
- cohen_d=cohen, plots=plots)
537
+ cohen_d=cohen, plots=plots))
538
538
 
539
539
 
540
540
  def _needed_1(Edesired, conf_level, s, E, n, d):