diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb740fa..7881e29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,29 @@
Full parity with the R `moderndive` and `infer` packages.
+- **Parity with R `moderndive` PR #144**:
+ - `get_correlation()` now accepts multiple right-hand-side predictors
+ (`"y ~ x1 + x2"`) — long output by default, `wide=True` for one column per
+ predictor; a suppressible (`quiet=True`) note points to a full pairwise matrix.
+ - `get_regression_table()`, `get_regression_points()`, and
+ `get_regression_summaries()` now accept fitted **`glm()`** models. GLM points
+ are on the response scale (e.g. probabilities), summaries are GLM-shaped
+ (`deviance`, `null_deviance`, `aic`, `bic`, `log_lik`, …), and the table gains
+ an `exponentiate=` argument for odds/rate ratios.
+ - `get_regression_points()` handles **in-formula transformations**: a
+ transformed outcome like `np.log(mpg)` is shown on the model scale as
+ `log_mpg`/`log_mpg_hat`, and transformed predictors (`poly()`, `scale()`,
+ `I()`) are shown as their original columns rather than leaking basis matrices.
+ - New **`plot_3d_regression(data, "z ~ x + y")`**: interactive 3D scatter with a
+ fitted regression plane (plotly).
+ - New **`View()`**: renders a data frame as an interactive, searchable table via
+ the optional `itables` package (`pip install "moderndive[view]"`) — the Python
+ counterpart of R's `DT::datatable()` — with a graceful fallback when itables
+ isn't installed.
+ - `geom_categorical_model()` is available as an alias of `gg_categorical_model()`.
+ - Messages and errors are now beginner-friendly, in the `infer` style (a short
+ summary line followed by `→` hint bullets); informational notes use a
+ dedicated, suppressible `ModernDiveMessage` category.
- **`count_missing()`**: a beginner-friendly helper that counts `null` values per
column and returns a tidy `column`/`n_missing` data frame sorted from most to
fewest missing — a gentler alternative to `df.select(pl.all().is_null().sum())`.
diff --git a/doc/_build/html/.doctrees/api.doctree b/doc/_build/html/.doctrees/api.doctree
index 4ee6fb2..ffe85ed 100644
Binary files a/doc/_build/html/.doctrees/api.doctree and b/doc/_build/html/.doctrees/api.doctree differ
diff --git a/doc/_build/html/.doctrees/environment.pickle b/doc/_build/html/.doctrees/environment.pickle
index 32d544f..b1654b5 100644
Binary files a/doc/_build/html/.doctrees/environment.pickle and b/doc/_build/html/.doctrees/environment.pickle differ
diff --git a/doc/_build/html/.doctrees/getting-started.doctree b/doc/_build/html/.doctrees/getting-started.doctree
index 86761dc..858009c 100644
Binary files a/doc/_build/html/.doctrees/getting-started.doctree and b/doc/_build/html/.doctrees/getting-started.doctree differ
diff --git a/doc/_build/html/.doctrees/guides/confidence-intervals.doctree b/doc/_build/html/.doctrees/guides/confidence-intervals.doctree
index 09c464e..5f734c9 100644
Binary files a/doc/_build/html/.doctrees/guides/confidence-intervals.doctree and b/doc/_build/html/.doctrees/guides/confidence-intervals.doctree differ
diff --git a/doc/_build/html/.doctrees/guides/hypothesis-testing.doctree b/doc/_build/html/.doctrees/guides/hypothesis-testing.doctree
index efd13ea..a31e2c0 100644
Binary files a/doc/_build/html/.doctrees/guides/hypothesis-testing.doctree and b/doc/_build/html/.doctrees/guides/hypothesis-testing.doctree differ
diff --git a/doc/_build/html/.doctrees/guides/messages.doctree b/doc/_build/html/.doctrees/guides/messages.doctree
new file mode 100644
index 0000000..858f6a4
Binary files /dev/null and b/doc/_build/html/.doctrees/guides/messages.doctree differ
diff --git a/doc/_build/html/.doctrees/guides/plotting.doctree b/doc/_build/html/.doctrees/guides/plotting.doctree
index 5b08f33..27a2be7 100644
Binary files a/doc/_build/html/.doctrees/guides/plotting.doctree and b/doc/_build/html/.doctrees/guides/plotting.doctree differ
diff --git a/doc/_build/html/.doctrees/guides/regression.doctree b/doc/_build/html/.doctrees/guides/regression.doctree
index 97b30c5..82a5b83 100644
Binary files a/doc/_build/html/.doctrees/guides/regression.doctree and b/doc/_build/html/.doctrees/guides/regression.doctree differ
diff --git a/doc/_build/html/.doctrees/guides/theory-based.doctree b/doc/_build/html/.doctrees/guides/theory-based.doctree
index 98e6f69..44d4dab 100644
Binary files a/doc/_build/html/.doctrees/guides/theory-based.doctree and b/doc/_build/html/.doctrees/guides/theory-based.doctree differ
diff --git a/doc/_build/html/.doctrees/index.doctree b/doc/_build/html/.doctrees/index.doctree
index 73bb499..da8fc55 100644
Binary files a/doc/_build/html/.doctrees/index.doctree and b/doc/_build/html/.doctrees/index.doctree differ
diff --git a/doc/_build/html/_images/8b54ea7be43ae3d659ee4cbf06d3ee5f39423bcfb5a220bab5f8e47a400c71ef.png b/doc/_build/html/_images/8b54ea7be43ae3d659ee4cbf06d3ee5f39423bcfb5a220bab5f8e47a400c71ef.png
new file mode 100644
index 0000000..6498fed
Binary files /dev/null and b/doc/_build/html/_images/8b54ea7be43ae3d659ee4cbf06d3ee5f39423bcfb5a220bab5f8e47a400c71ef.png differ
diff --git a/doc/_build/html/_modules/index.html b/doc/_build/html/_modules/index.html
index 3da3475..357cc75 100644
--- a/doc/_build/html/_modules/index.html
+++ b/doc/_build/html/_modules/index.html
@@ -209,6 +209,7 @@
"""Correlation and population-spread helpers mirroring the R ``moderndive`` package.
-- :func:`get_correlation` ~ ``moderndive::get_correlation`` (tidy 1-row ``cor`` frame)
+- :func:`get_correlation` ~ ``moderndive::get_correlation`` (one or more predictors)- :func:`pop_sd` ~ ``moderndive::pop_sd`` (population standard deviation)"""
@@ -257,23 +258,30 @@
Source code for moderndive.correlation
importnumpyasnpimportpolarsaspl
+from._messagingimporthelpful_error,inform
+
__all__=["get_correlation","pop_sd"]
-def_parse_pair(formula:str|None,x:str|None,y:str|None)->tuple[str,str]:
-"""Resolve the (y, x) column pair from a ``"y ~ x"`` formula or x=/y= kwargs."""
- ifformulaisnotNone:
- ifxisnotNoneoryisnotNone:
- raiseValueError("Pass either formula or x=/y=, not both.")
- if"~"notinformula:
- raiseValueError(f"formula must look like 'y ~ x', got {formula!r}.")
- lhs,rhs=(part.strip()forpartinformula.split("~",1))
- ifnotlhsornotrhs:
- raiseValueError(f"formula must look like 'y ~ x', got {formula!r}.")
- returnlhs,rhs
- ifxisNoneoryisNone:
- raiseValueError("Provide a formula 'y ~ x' or both x= and y=.")
- returny,x
+def_parse_formula(formula:str)->tuple[str,list[str]]:
+"""Resolve ``"y ~ x1 + x2"`` into the outcome name and a list of predictors."""
+ if"~"notinformula:
+ raiseValueError(
+ helpful_error(
+ f"formula must look like 'y ~ x' (or 'y ~ x1 + x2'), got {formula!r}.",
+ "Put the outcome on the left of ~ and one or more predictors on the right.",
+ )
+ )
+ lhs,rhs=(part.strip()forpartinformula.split("~",1))
+ predictors=[v.strip()forvinrhs.split("+")ifv.strip()]
+ ifnotlhsornotpredictors:
+ raiseValueError(
+ helpful_error(
+ f"formula must name an outcome and at least one predictor, got {formula!r}.",
+ "Example: 'mpg ~ wt' or 'mpg ~ wt + hp'.",
+ )
+ )
+ returnlhs,predictors
@@ -284,21 +292,76 @@
Source code for moderndive.correlation
*,x:str|None=None,y:str|None=None,
+ wide:bool=False,
+ quiet:bool=False,)->pl.DataFrame:
-"""Pearson correlation as a tidy 1-row frame with a ``cor`` column.
+"""Pearson correlation between an outcome and one or more predictors.
+
+ Mirrors ``moderndive::get_correlation``. Give the variables either as a
+ formula (``"y ~ x"`` or ``"y ~ x1 + x2 + x3"``) or, for a single predictor,
+ via ``x=`` and ``y=``.
- Mirrors ``moderndive::get_correlation(data, y ~ x)``. Specify the variable
- pair either as a formula string (``"y ~ x"``) or via the ``x=`` and ``y=``
- keyword arguments. Rows with a null in either column are dropped.
+ With **one** predictor the result is a 1-row frame with a ``cor`` column.
+ With **multiple** predictors the result is long by default — columns
+ ``predictor`` and ``cor`` (one row each) — or pass ``wide=True`` for one
+ column per predictor. Rows with a null in either column are dropped per pair.
+
+ A short note points to a full pairwise correlation matrix when there are
+ multiple predictors; silence it with ``quiet=True``. """df=dataifisinstance(data,pl.DataFrame)elsepl.from_pandas(data)
- y_col,x_col=_parse_pair(formula,x,y)
- forcolin(y_col,x_col):
- ifcolnotindf.columns:
- raiseValueError(f"Column {col!r} is not in the data.")
- pair=df.select(x_col,y_col).drop_nulls()
- value=float(np.corrcoef(pair[x_col].to_numpy(),pair[y_col].to_numpy())[0,1])
- returnpl.DataFrame({"cor":[value]})
+
+ ifformulaisnotNone:
+ ifxisnotNoneoryisnotNone:
+ raiseValueError(
+ helpful_error(
+ "Pass either a formula or x=/y=, not both.",
+ "Use a formula ('y ~ x') for one or more predictors, or x=/y= for one.",
+ )
+ )
+ outcome,predictors=_parse_formula(formula)
+ else:
+ ifxisNoneoryisNone:
+ raiseValueError(
+ helpful_error(
+ "Provide a formula ('y ~ x') or both x= and y=.",
+ "For several predictors use a formula: 'y ~ x1 + x2'.",
+ )
+ )
+ outcome,predictors=y,[x]
+
+ missing=[cforcin[outcome,*predictors]ifcnotindf.columns]
+ ifmissing:
+ raiseValueError(
+ helpful_error(
+ f"Column(s) not found in the data: {', '.join(missing)}.",
+ f"Available columns: {', '.join(df.columns)}.",
+ )
+ )
+
+ cors:dict[str,float]={}
+ forpredictorinpredictors:
+ pair=df.select(predictor,outcome).drop_nulls()
+ cors[predictor]=float(
+ np.corrcoef(pair[predictor].to_numpy(),pair[outcome].to_numpy())[0,1]
+ )
+
+ iflen(predictors)==1:
+ returnpl.DataFrame({"cor":[cors[predictors[0]]]})
+
+ ifnotquiet:
+ inform(
+ f"Computing correlations of `{outcome}` against {len(predictors)} predictors.",
+ "For a full pairwise matrix (incl. predictor–predictor correlations), "
+ "use `df.to_pandas().corr()`.",
+ "Pass quiet=True to silence this message.",
+ )
+
+ ifwide:
+ returnpl.DataFrame({predictor:[cors[predictor]]forpredictorinpredictors})
+ returnpl.DataFrame(
+ {"predictor":predictors,"cor":[cors[predictor]forpredictorinpredictors]}
+ )
Built on ``statsmodels`` (which, unlike scikit-learn, reports standard errors,test statistics, p-values, and confidence intervals — the inferential output the
-book teaches). Inputs may be polars or pandas frames; outputs are polars frames.
+book teaches). Both ``ols()`` (linear) and ``glm()`` (e.g. logistic) fitted
+models are supported. Inputs may be polars or pandas frames; outputs are polars.- :func:`get_regression_table` ~ ``broom::tidy`` + CIs- :func:`get_regression_points` ~ ``broom::augment`` (fitted values + residuals)
+- :func:`get_regression_summaries` ~ ``broom::glance``- :func:`tidy_summary` ~ per-variable summary statistics"""
@@ -264,6 +267,8 @@
returndata
+def_check_model(model)->None:
+"""Beginner-friendly guard: ``model`` must be a fitted statsmodels result."""
+ ifnot(hasattr(model,"params")andhasattr(model,"model")):
+ raiseTypeError(
+ helpful_error(
+ f"Expected a fitted statsmodels model, got {type(model).__name__}.",
+ 'Fit one first, e.g. smf.ols("y ~ x", data).fit() or smf.glm(...).fit().',
+ )
+ )
+
+
+def_is_glm(model)->bool:
+"""A fitted statsmodels GLM result (logistic, Poisson, …) exposes ``family``."""
+ returnhasattr(model,"family")
+
+
def_clean_term(term:str)->str:"""Turn patsy term labels into the tidy names moderndive uses. Examples:
- ``Intercept`` -> ``intercept``
+ ``Intercept`` / ``const`` -> ``intercept`` ``income[T.Lower middle income]`` -> ``income: Lower middle income`` ``life_exp:income[T.High income]`` -> ``life_exp:income: High income`` ``C(income, levels=[...])[T.High income]`` -> ``income: High income`` """
+ ifterm=="const":# the constant column from sm.add_constant (array API)
+ return"intercept"term=term.replace("Intercept","intercept")# Drop any C(col, ...) wrapper down to just the column name.term=re.sub(r"C\(\s*([A-Za-z_]\w*)[^)]*\)",r"\1",term)
@@ -296,25 +319,94 @@
Source code for moderndive.modeling
returnterm
+def_term_names(model)->list[str]:
+"""Coefficient names for the table. Works for formula/pandas (``params`` is a
+ Series with an index) and array-API numpy fits (``params`` is a bare array,
+ so fall back to ``exog_names``)."""
+ index=getattr(model.params,"index",None)
+ returnlist(index)ifindexisnotNoneelselist(model.model.exog_names)
+
+
+def_has_formula_frame(model)->bool:
+"""Whether the model carries the original data + formula (the formula API).
+
+ True for ``smf.ols("y ~ x", data).fit()``; False for an array-API fit like
+ ``sm.OLS(y, X).fit()``, which exposes only the design matrix.
+ """
+ frame=getattr(getattr(model.model,"data",None),"frame",None)
+ returnframeisnotNoneandgetattr(model.model,"formula",None)isnotNone
+
+
+def_clean_name(expr:str)->str:
+"""Sanitize a transformed term into a tidy column name.
+
+ ``np.log(mpg)`` -> ``log_mpg``; ``np.sqrt(price)`` -> ``sqrt_price``.
+ """
+ expr=expr.replace("np.","")
+ returnre.sub(r"[^0-9A-Za-z]+","_",expr).strip("_").lower()
+
+
+def_outcome_info(model)->tuple[str,str,bool]:
+"""Resolve the outcome's tidy column name (and whether the LHS is transformed).
+
+ An untransformed LHS like ``mpg`` keeps its name; a transformed LHS like
+ ``np.log(mpg)`` becomes ``log_mpg`` (with ``log_mpg_hat`` for fitted values).
+ """
+ endog=model.model.endog_names
+ ifendoginmodel.model.data.frame.columns:
+ returnendog,f"{endog}_hat",False
+ name=_clean_name(endog)
+ returnname,f"{name}_hat",True
+
+
+def_predictor_vars(model)->list[str]:
+"""Original predictor variable names from the RHS, in formula order.
+
+ For ``y ~ poly(hp, 2) + wt`` this returns ``["hp", "wt"]`` — the original
+ columns, never the patsy basis/transform columns.
+ """
+ frame_cols=list(model.model.data.frame.columns)
+ rhs=model.model.formula.split("~",1)[1]
+ ordered:list[str]=[]
+ fortokeninre.findall(r"[A-Za-z_]\w*",rhs):
+ iftokeninframe_colsandtokennotinordered:
+ ordered.append(token)
+ returnordered
+
+
[docs]
-defget_regression_table(model,digits:int=3,conf_level:float=0.95)->pl.DataFrame:
+defget_regression_table(
+ model,
+ digits:int=3,
+ conf_level:float=0.95,
+ exponentiate:bool=False,
+)->pl.DataFrame:"""Tidy regression table: term, estimate, std_error, statistic, p_value, lower/upper_ci.
- ``model`` is a fitted ``statsmodels`` results object (e.g. from
- ``statsmodels.formula.api.ols("y ~ x", data).fit()``).
+ ``model`` is a fitted ``statsmodels`` results object — either OLS
+ (``smf.ols("y ~ x", data).fit()``) or GLM (``smf.glm(...).fit()``).
+
+ For GLMs with a log or logit link, pass ``exponentiate=True`` to report the
+ coefficient estimate and its confidence interval as rate / odds ratios
+ (``std_error``, ``statistic``, and ``p_value`` stay on the model's link scale,
+ matching ``broom::tidy``). """
- conf=model.conf_int(alpha=1-conf_level)
- terms=[_clean_term(t)fortinmodel.params.index]
+ _check_model(model)
+ conf=np.asarray(model.conf_int(alpha=1-conf_level),dtype=float)
+ estimate=np.asarray(model.params,dtype=float)
+ lower,upper=conf[:,0],conf[:,1]
+ ifexponentiate:
+ estimate,lower,upper=np.exp(estimate),np.exp(lower),np.exp(upper)table=pl.DataFrame({
- "term":terms,
- "estimate":model.params.to_numpy(),
- "std_error":model.bse.to_numpy(),
- "statistic":model.tvalues.to_numpy(),
- "p_value":model.pvalues.to_numpy(),
- "lower_ci":np.asarray(conf)[:,0],
- "upper_ci":np.asarray(conf)[:,1],
+ "term":[_clean_term(t)fortin_term_names(model)],
+ "estimate":estimate,
+ "std_error":np.asarray(model.bse,dtype=float),
+ "statistic":np.asarray(model.tvalues,dtype=float),
+ "p_value":np.asarray(model.pvalues,dtype=float),
+ "lower_ci":lower,
+ "upper_ci":upper,})numeric=[cforcintable.columnsifc!="term"]
@@ -327,58 +419,115 @@
Source code for moderndive.modeling
defget_regression_points(model,digits:int=3)->pl.DataFrame:"""Fitted values + residuals per observation (~ ``broom::augment``).
- Columns: ``ID``, the response, each explanatory term, ``<response>_hat``,
- ``residual``.
+ Columns: ``ID``, the outcome, each original predictor, ``<outcome>_hat``,
+ ``residual``. In-formula transformations are handled gracefully: a
+ transformed outcome (``np.log(mpg)``) is shown on the model's scale under a
+ sanitized name (``log_mpg`` / ``log_mpg_hat``), and transformed predictors
+ (``poly()``, ``scale()``, ``I()``) are shown as their original columns rather
+ than leaking basis matrices. For GLMs, fitted values and residuals are on the
+ response scale (e.g. probabilities for logistic regression). """
- endog_name=model.model.endog_names
- exog_names=[nforninmodel.model.exog_namesifn!="Intercept"]
+ _check_model(model)
+ name,name_hat,out=(
+ _points_columns_formula(model)
+ if_has_formula_frame(model)
+ else_points_columns_array(model)
+ )
+ fitted=np.asarray(model.fittedvalues,dtype=float)
+ if_is_glm(model):
+ residual=np.asarray(model.resid_response,dtype=float)
+ else:
+ residual=np.asarray(out[name],dtype=float)-fitted
+ out[name_hat]=fitted
+ out["residual"]=residual
+
+ df=pl.DataFrame(out).drop_nulls().with_row_index("ID",offset=1)
+ df=df.with_columns(pl.col("ID").cast(pl.Int64))
+ float_cols=[cforc,dtindf.schema.items()ifdt.is_float()]
+ returndf.with_columns(pl.col(float_cols).round(digits))
+def_points_columns_array(model):
+"""Outcome + predictor columns for an array-API model (e.g. ``sm.OLS(y, X)``).
+ There are no in-formula transforms here, so each non-constant design-matrix
+ column is a predictor and the outcome is the endog array.
+ """
+ name=model.model.endog_namesor"y"
+ out={name:np.asarray(model.model.endog,dtype=float)}
+ exog=np.asarray(model.model.exog,dtype=float)
+ forj,rawinenumerate(model.model.exog_names):
+ ifrawin("const","Intercept"):
+ continue
+ out[raw]=exog[:,j]
+ returnname,f"{name}_hat",out
[docs]defget_regression_summaries(model,digits:int=3)->pl.DataFrame:
-"""Model-fit summaries as a tidy 1-row frame (~ ``moderndive::get_regression_summaries``).
+"""Model-fit summaries as a tidy 1-row frame (~ ``broom::glance``).
- Columns: ``r_squared``, ``adj_r_squared``, ``mse``, ``rmse``, ``sigma``,
- ``statistic`` (overall F), ``p_value``, ``df`` (model degrees of freedom),
- ``nobs``. ``model`` is a fitted ``statsmodels`` results object.
+ For an **OLS** model: ``r_squared``, ``adj_r_squared``, ``mse``, ``rmse``,
+ ``sigma``, ``statistic`` (overall F), ``p_value``, ``df``, ``nobs``.
+
+ For a **GLM** (no R² applies): ``mse``, ``rmse``, ``deviance``,
+ ``null_deviance``, ``aic``, ``bic``, ``log_lik``, ``df_residual``,
+ ``df_null``, ``nobs``. ``mse``/``rmse`` use response-scale residuals. ``mse`` is the mean squared residual using ``n`` in the denominator (so
- ``rmse = sqrt(mse)``), while ``sigma`` is the residual standard error using
- ``n - p`` — matching the R package.
+ ``rmse = sqrt(mse)``); for OLS ``sigma`` is the residual standard error
+ using ``n - p`` — matching the R package. """
+ _check_model(model)nobs=int(model.nobs)
- mse=float(model.ssr)/nobs
- table=pl.DataFrame(
- {
- "r_squared":[float(model.rsquared)],
- "adj_r_squared":[float(model.rsquared_adj)],
- "mse":[mse],
- "rmse":[float(np.sqrt(mse))],
- "sigma":[float(np.sqrt(model.mse_resid))],
- "statistic":[float(model.fvalue)],
- "p_value":[float(model.f_pvalue)],
- "df":[int(model.df_model)],
- "nobs":[nobs],
- }
- )
+
+ if_is_glm(model):
+ res=np.asarray(model.resid_response,dtype=float)
+ mse=float(np.mean(res**2))
+ table=pl.DataFrame(
+ {
+ "mse":[mse],
+ "rmse":[float(np.sqrt(mse))],
+ "deviance":[float(model.deviance)],
+ "null_deviance":[float(model.null_deviance)],
+ "aic":[float(model.aic)],
+ "bic":[float(model.bic_llf)],
+ "log_lik":[float(model.llf)],
+ "df_residual":[int(model.df_resid)],
+ "df_null":[nobs-1],
+ "nobs":[nobs],
+ }
+ )
+ else:
+ mse=float(model.ssr)/nobs
+ table=pl.DataFrame(
+ {
+ "r_squared":[float(model.rsquared)],
+ "adj_r_squared":[float(model.rsquared_adj)],
+ "mse":[mse],
+ "rmse":[float(np.sqrt(mse))],
+ "sigma":[float(np.sqrt(model.mse_resid))],
+ "statistic":[float(model.fvalue)],
+ "p_value":[float(model.f_pvalue)],
+ "df":[int(model.df_model)],
+ "nobs":[nobs],
+ }
+ )float_cols=[cforc,dtintable.schema.items()ifdt.is_float()]returntable.with_columns(pl.col(float_cols).round(digits))
# R-parity alias: R's helper is named geom_categorical_model(); expose both names.geom_categorical_model=gg_categorical_model
+
+
+_IDENTIFIER=re.compile(r"^[A-Za-z_]\w*$")
+
+
+
+[docs]
+defplot_3d_regression(data,formula:str,n:int=25):
+"""Interactive 3D scatterplot with a fitted regression plane.
+
+ Mirrors ``moderndive::plot_3d_regression``. Pass a formula ``z ~ x + y`` —
+ one numeric outcome and exactly two numeric predictors — and get a plotly
+ ``go.Figure`` with the data points and the fitted ``lm`` plane.
+
+ In-formula transformations (e.g. ``log(z) ~ x + y``) are **not** supported,
+ since the plane and the raw points would be on different scales; transform
+ the columns of ``data`` first and pass plain names. ``n`` sets the plane's
+ grid resolution per axis.
+ """
+ importnumpyasnp
+ importpandasaspd
+ importplotly.graph_objectsasgo
+ importstatsmodels.formula.apiassmf
+
+ df=dataifisinstance(data,pl.DataFrame)elsepl.from_pandas(data)
+ ifnotisinstance(n,int)orn<2:
+ raiseValueError(
+ helpful_error(f"`n` must be an integer ≥ 2, got {n!r}.","Try n=25 (the default).")
+ )
+ if"~"notinformula:
+ raiseValueError(
+ helpful_error(
+ f"formula must look like 'z ~ x + y', got {formula!r}.",
+ "Put the outcome on the left of ~ and exactly two predictors on the right.",
+ )
+ )
+ lhs,rhs=(part.strip()forpartinformula.split("~",1))
+ predictors=[v.strip()forvinrhs.split("+")ifv.strip()]
+ ifnot_IDENTIFIER.match(lhs)ornotall(_IDENTIFIER.match(p)forpinpredictors):
+ raiseValueError(
+ helpful_error(
+ "plot_3d_regression() does not support in-formula transformations "
+ "(e.g. 'log(z) ~ x + y').",
+ "Transform the columns of `data` first, then pass plain names like 'z ~ x + y'.",
+ )
+ )
+ iflen(predictors)!=2:
+ raiseValueError(
+ helpful_error(
+ f"The right-hand side must name exactly two predictors (got {len(predictors)}).",
+ "Use a formula like 'z ~ x + y'.",
+ )
+ )
+ missing=[cforcin[lhs,*predictors]ifcnotindf.columns]
+ ifmissing:
+ raiseValueError(
+ helpful_error(
+ f"Column(s) not found in the data: {', '.join(missing)}.",
+ f"Available columns: {', '.join(df.columns)}.",
+ )
+ )
+ forvarin[lhs,*predictors]:
+ ifnotdf.schema[var].is_numeric():
+ raiseValueError(
+ helpful_error(
+ f"`{var}` must be numeric — a regression plane needs three continuous "
+ "variables.",
+ "For a categorical predictor, see gg_categorical_model().",
+ )
+ )
+
+ x_var,y_var=predictors
+ pdf=df.select(lhs,x_var,y_var).drop_nulls().to_pandas()
+ model=smf.ols(f"{lhs} ~ {x_var} + {y_var}",data=pdf).fit()
+
+ x_seq=np.linspace(pdf[x_var].min(),pdf[x_var].max(),n)
+ y_seq=np.linspace(pdf[y_var].min(),pdf[y_var].max(),n)
+ grid_x,grid_y=np.meshgrid(x_seq,y_seq)
+ grid=pd.DataFrame({x_var:grid_x.ravel(),y_var:grid_y.ravel()})
+ z_pred=np.asarray(model.predict(grid)).reshape(grid_x.shape)
+
+ fig=go.Figure(
+ go.Scatter3d(
+ x=pdf[x_var],
+ y=pdf[y_var],
+ z=pdf[lhs],
+ mode="markers",
+ marker={"size":4},
+ name="data",
+ )
+ )
+ fig.add_surface(
+ x=x_seq,y=y_seq,z=z_pred,opacity=0.6,showscale=False,name="regression plane"
+ )
+ fig.update_layout(
+ template="plotly_white",
+ scene={"xaxis_title":x_var,"yaxis_title":y_var,"zaxis_title":lhs},
+ )
+ returnfig
+"""``View()``: render a data frame as an interactive table.
+
+The Python analog of R ``moderndive::View()``. In a notebook or Quarto document
+it renders an interactive DataTables widget (search / sort / paginate) via the
+optional ``itables`` package — the Python counterpart of R's ``DT::datatable()``
+(both wrap the same DataTables.net library). If ``itables`` isn't installed it
+falls back to returning the data frame for normal display.
+"""
+
+from__future__importannotations
+
+importimportlib.util
+
+importpolarsaspl
+
+from._messagingimportinform
+
+__all__=["View"]
+
+
+def_itables_available()->bool:
+"""Whether the optional ``itables`` dependency is importable (wrapped for testing)."""
+ returnimportlib.util.find_spec("itables")isnotNone
+
+
+def_render_datatable(frame,title):# pragma: no cover - exercised via View() in tests
+"""Render ``frame`` as an interactive DataTables widget via itables."""
+ fromitablesimportshow
+
+ returnshow(frame,caption=title)
+
+
+def_as_frame(x):
+"""Coerce ``x`` to a polars/pandas frame for display (leave frames untouched)."""
+ ifisinstance(x,pl.DataFrame):
+ returnx
+ iftype(x).__module__.startswith("pandas")andtype(x).__name__=="DataFrame":
+ returnx
+ returnpl.DataFrame(x)
+
+
+
+[docs]
+defView(x,title:str|None=None):
+"""Display a data frame as an interactive table (search, sort, paginate).
+
+ In a notebook / Quarto context this renders an interactive table via the
+ optional ``itables`` package (install with ``pip install "moderndive[view]"``).
+ Without ``itables`` it returns the data frame so it still displays. Accepts a
+ polars or pandas DataFrame (or anything coercible to one). ``title`` is shown
+ as the table caption.
+ """
+ frame=_as_frame(x)
+ if_itables_available():
+ return_render_datatable(frame,title)
+ inform(
+ "itables isn't installed, so View() can't render an interactive table.",
+ 'Install it with: pip install "moderndive[view]".',
+ "Returning the data frame so it still displays.",
+ )
+ returnframe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/doc/_build/html/_sources/api.md.txt b/doc/_build/html/_sources/api.md.txt
index 82eaee5..de0dc0a 100644
--- a/doc/_build/html/_sources/api.md.txt
+++ b/doc/_build/html/_sources/api.md.txt
@@ -65,6 +65,14 @@ All plotting helpers accept ``engine="plotly"`` (default) or ``engine="plotnine"
.. autofunction:: moderndive.gg_parallel_slopes
.. autofunction:: moderndive.geom_parallel_slopes
.. autofunction:: moderndive.gg_categorical_model
+.. autofunction:: moderndive.geom_categorical_model
+.. autofunction:: moderndive.plot_3d_regression
+```
+
+## Viewing data
+
+```{eval-rst}
+.. autofunction:: moderndive.View
```
## Datasets
diff --git a/doc/_build/html/_sources/getting-started.md.txt b/doc/_build/html/_sources/getting-started.md.txt
index b58aced..fc37789 100644
--- a/doc/_build/html/_sources/getting-started.md.txt
+++ b/doc/_build/html/_sources/getting-started.md.txt
@@ -45,6 +45,13 @@ yawn.head()
List everything that's available with `md.available_datasets()` (58 datasets), and
see {doc}`datasets` for a thematic tour.
+To explore a dataset in an interactive, searchable/sortable table, use `View()`
+(install the optional extra with `pip install "moderndive[view]"`):
+
+```python
+md.View(yawn)
+```
+
## A first summary
`tidy_summary` gives a per-variable five-number summary (numeric) or counts
diff --git a/doc/_build/html/_sources/guides/messages.md.txt b/doc/_build/html/_sources/guides/messages.md.txt
new file mode 100644
index 0000000..9d43dfc
--- /dev/null
+++ b/doc/_build/html/_sources/guides/messages.md.txt
@@ -0,0 +1,113 @@
+---
+jupytext:
+ text_representation:
+ extension: .md
+ format_name: myst
+kernelspec:
+ display_name: Python 3
+ name: python3
+---
+
+```{code-cell} python
+:tags: [remove-input]
+import matplotlib
+matplotlib.use("Agg")
+```
+
+# Helpful messages & errors
+
+`moderndive` is built for people *learning* statistics, so its messages and errors
+are written to teach, not just to report. They follow the same style as the R
+`infer` package: a short **summary line**, then one or more `→` **hint bullets**
+telling you what to do next.
+
+There are two kinds:
+
+- **Errors** (`ValueError` / `TypeError`) — something can't proceed, raised with a
+ fix-it hint.
+- **Informational notes** — a heads-up that doesn't stop your code, emitted as a
+ suppressible `ModernDiveMessage` warning.
+
+## Errors that tell you how to fix them
+
+Mistype a column name and you get the available columns back, not a cryptic
+`KeyError`:
+
+```{code-cell} python
+import moderndive as md
+from moderndive import get_correlation
+
+houses = md.load_saratoga_houses()
+try:
+ get_correlation(houses, "price ~ livng_area") # typo
+except ValueError as e:
+ print(e)
+```
+
+Pass the wrong kind of object and the error names the fix:
+
+```{code-cell} python
+from moderndive import get_regression_table
+
+try:
+ get_regression_table("not a fitted model")
+except TypeError as e:
+ print(e)
+```
+
+```{code-cell} python
+from moderndive import plot_3d_regression
+
+# plot_3d_regression needs exactly two numeric predictors
+try:
+ plot_3d_regression(houses, "price ~ living_area")
+except ValueError as e:
+ print(e)
+```
+
+## Informational notes (and how to silence them)
+
+Some functions point out something useful without stopping. For example, asking
+for correlations against several predictors notes that you're getting
+predictor-vs-outcome correlations only (not a full pairwise matrix):
+
+```{code-cell} python
+import warnings
+from moderndive._messaging import ModernDiveMessage
+
+with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ get_correlation(houses, "price ~ living_area + bedrooms + bathrooms")
+
+for w in caught:
+ if issubclass(w.category, ModernDiveMessage):
+ print(w.message)
+```
+
+These notes are designed to be **easy to turn off** once you know what they mean.
+Many functions take a `quiet=True` flag:
+
+```{code-cell} python
+with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ get_correlation(houses, "price ~ living_area + bedrooms", quiet=True)
+
+print("messages emitted:", sum(issubclass(w.category, ModernDiveMessage) for w in caught))
+```
+
+Or silence every moderndive note globally with a standard `warnings` filter:
+
+```python
+import warnings
+from moderndive._messaging import ModernDiveMessage
+
+warnings.filterwarnings("ignore", category=ModernDiveMessage)
+```
+
+## Why this matters
+
+For a newcomer, the difference between `KeyError: 'livng_area'` and "*Column(s)
+not found in the data: livng_area. → Available columns: …*" is the difference
+between getting stuck and fixing it in seconds. Because the informational notes
+are their own warning category, they're equally easy to **see** while learning and
+to **mute** once you're comfortable.
diff --git a/doc/_build/html/_sources/guides/regression.md.txt b/doc/_build/html/_sources/guides/regression.md.txt
index 2d40648..e81bf96 100644
--- a/doc/_build/html/_sources/guides/regression.md.txt
+++ b/doc/_build/html/_sources/guides/regression.md.txt
@@ -25,6 +25,7 @@ model into tidy polars tables — the analog of R `moderndive`'s
Fit models with statsmodels' formula API, then tidy them:
```{code-cell} python
+import polars as pl
import statsmodels.formula.api as smf
import moderndive as md
from moderndive import (
@@ -65,6 +66,44 @@ get_correlation(houses, "price ~ living_area") # ≈ 0.759
# or: get_correlation(houses, x="living_area", y="price")
```
+Pass several predictors on the right-hand side to get one correlation per
+predictor (long by default; `wide=True` for one column each):
+
+```{code-cell} python
+get_correlation(houses, "price ~ living_area + bedrooms + bathrooms", quiet=True)
+```
+
+## Logistic & other GLMs
+
+The regression helpers also accept a fitted `glm()` model. For a logistic
+regression, `get_regression_points()` returns fitted **probabilities** and
+`get_regression_summaries()` returns a GLM-shaped summary (deviance, AIC, BIC, …).
+Pass `exponentiate=True` to `get_regression_table()` for **odds ratios**:
+
+```{code-cell} python
+import statsmodels.api as sm
+
+evals = md.load_evals().with_columns((pl.col("gender") == "male").cast(int).alias("is_male"))
+logit = smf.glm("is_male ~ score + age", data=evals.to_pandas(),
+ family=sm.families.Binomial()).fit()
+get_regression_table(logit, exponentiate=True)
+```
+
+```{code-cell} python
+get_regression_summaries(logit)
+```
+
+## 3D regression plane
+
+`plot_3d_regression()` draws an interactive 3D scatter of an outcome against two
+numeric predictors, with the fitted regression plane overlaid (a plotly figure):
+
+```{code-cell} python
+from moderndive import plot_3d_regression
+
+plot_3d_regression(houses, "price ~ living_area + bedrooms")
+```
+
## Visualizing models
Two ports of R `moderndive`'s ggplot helpers, both dual-engine:
diff --git a/doc/_build/html/_sources/index.md.txt b/doc/_build/html/_sources/index.md.txt
index fdde1ba..e2ccb2e 100644
--- a/doc/_build/html/_sources/index.md.txt
+++ b/doc/_build/html/_sources/index.md.txt
@@ -119,6 +119,7 @@ guides/hypothesis-testing
guides/regression
guides/theory-based
guides/plotting
+guides/messages
```
```{toctree}
diff --git a/doc/_build/html/api.html b/doc/_build/html/api.html
index f3f87fe..a884744 100644
--- a/doc/_build/html/api.html
+++ b/doc/_build/html/api.html
@@ -3,7 +3,7 @@
-
+
@@ -210,6 +210,7 @@
model is a fitted statsmodels results object (e.g. from
-statsmodels.formula.api.ols("y~x",data).fit()).
+
model is a fitted statsmodels results object — either OLS
+(smf.ols("y~x",data).fit()) or GLM (smf.glm(...).fit()).
+
For GLMs with a log or logit link, pass exponentiate=True to report the
+coefficient estimate and its confidence interval as rate / odds ratios
+(std_error, statistic, and p_value stay on the model’s link scale,
+matching broom::tidy).
Fitted values + residuals per observation (~ broom::augment).
-
Columns: ID, the response, each explanatory term, <response>_hat,
-residual.
+
Columns: ID, the outcome, each original predictor, <outcome>_hat,
+residual. In-formula transformations are handled gracefully: a
+transformed outcome (np.log(mpg)) is shown on the model’s scale under a
+sanitized name (log_mpg / log_mpg_hat), and transformed predictors
+(poly(), scale(), I()) are shown as their original columns rather
+than leaking basis matrices. For GLMs, fitted values and residuals are on the
+response scale (e.g. probabilities for logistic regression).
Model-fit summaries as a tidy 1-row frame (~ moderndive::get_regression_summaries).
-
Columns: r_squared, adj_r_squared, mse, rmse, sigma,
-statistic (overall F), p_value, df (model degrees of freedom),
-nobs. model is a fitted statsmodels results object.
+
Model-fit summaries as a tidy 1-row frame (~ broom::glance).
+
For an OLS model: r_squared, adj_r_squared, mse, rmse,
+sigma, statistic (overall F), p_value, df, nobs.
+
For a GLM (no R² applies): mse, rmse, deviance,
+null_deviance, aic, bic, log_lik, df_residual,
+df_null, nobs. mse/rmse use response-scale residuals.
mse is the mean squared residual using n in the denominator (so
-rmse=sqrt(mse)), while sigma is the residual standard error using
-n-p — matching the R package.
+rmse=sqrt(mse)); for OLS sigma is the residual standard error
+using n-p — matching the R package.
Pearson correlation as a tidy 1-row frame with a cor column.
-
Mirrors moderndive::get_correlation(data,y~x). Specify the variable
-pair either as a formula string ("y~x") or via the x= and y=
-keyword arguments. Rows with a null in either column are dropped.
Pearson correlation between an outcome and one or more predictors.
+
Mirrors moderndive::get_correlation. Give the variables either as a
+formula ("y~x" or "y~x1+x2+x3") or, for a single predictor,
+via x= and y=.
+
With one predictor the result is a 1-row frame with a cor column.
+With multiple predictors the result is long by default — columns
+predictor and cor (one row each) — or pass wide=True for one
+column per predictor. Rows with a null in either column are dropped per pair.
+
A short note points to a full pairwise correlation matrix when there are
+multiple predictors; silence it with quiet=True.
Interactive 3D scatterplot with a fitted regression plane.
+
Mirrors moderndive::plot_3d_regression. Pass a formula z~x+y —
+one numeric outcome and exactly two numeric predictors — and get a plotly
+go.Figure with the data points and the fitted lm plane.
+
In-formula transformations (e.g. log(z)~x+y) are not supported,
+since the plane and the raw points would be on different scales; transform
+the columns of data first and pass plain names. n sets the plane’s
+grid resolution per axis.
Display a data frame as an interactive table (search, sort, paginate).
+
In a notebook / Quarto context this renders an interactive table via the
+optional itables package (install with pipinstall"moderndive[view]").
+Without itables it returns the data frame so it still displays. Accepts a
+polars or pandas DataFrame (or anything coercible to one). title is shown
+as the table caption.
moderndive is built for people learning statistics, so its messages and errors
+are written to teach, not just to report. They follow the same style as the R
+infer package: a short summary line, then one or more →hint bullets
+telling you what to do next.
+
There are two kinds:
+
+
Errors (ValueError / TypeError) — something can’t proceed, raised with a
+fix-it hint.
+
Informational notes — a heads-up that doesn’t stop your code, emitted as a
+suppressible ModernDiveMessage warning.
Some functions point out something useful without stopping. For example, asking
+for correlations against several predictors notes that you’re getting
+predictor-vs-outcome correlations only (not a full pairwise matrix):
Computing correlations of `price` against 3 predictors.
+ → For a full pairwise matrix (incl. predictor–predictor correlations), use `df.to_pandas().corr()`.
+ → Pass quiet=True to silence this message.
+
+
+
+
+
These notes are designed to be easy to turn off once you know what they mean.
+Many functions take a quiet=True flag:
For a newcomer, the difference between KeyError:'livng_area' and “Column(s)
+not found in the data: livng_area. → Available columns: …” is the difference
+between getting stuck and fixing it in seconds. Because the informational notes
+are their own warning category, they’re equally easy to see while learning and
+to mute once you’re comfortable.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/doc/_build/html/guides/plotting.html b/doc/_build/html/guides/plotting.html
index fe3d372..0ece954 100644
--- a/doc/_build/html/guides/plotting.html
+++ b/doc/_build/html/guides/plotting.html
@@ -3,7 +3,7 @@
-
+
@@ -210,6 +210,7 @@
The regression helpers also accept a fitted glm() model. For a logistic
+regression, get_regression_points() returns fitted probabilities and
+get_regression_summaries() returns a GLM-shaped summary (deviance, AIC, BIC, …).
+Pass exponentiate=True to get_regression_table() for odds ratios:
plot_3d_regression() draws an interactive 3D scatter of an outcome against two
+numeric predictors, with the fitted regression plane overlaid (a plotly figure):