Conjugate Bayesian Models Python

This page presents Python code for implementing Bayesian conjugate models using standard prior–likelihood formulations and their associated posterior distributions.

  • Binomial Beta model
  • Poisson Gamma model
  • Normal Normal model
  • Normal Gamma model
  • Normal Inverse gamma model

The choice of parameters should always follow the native argument convention of the corresponding R function. Several probability distributions, such as the Gamma distribution, permit alternative but equivalent parameterizations (e.g., shape–scale or shape–rate). Consequently, we should exercise appropriate care while specifying parameters to ensure consistency with the underlying R implementation.

The implementations use closed-form analytical expressions for posterior summaries wherever available, thereby avoiding numerical approximation except where necessary (e.g., posterior quantiles).

Numerical output includes posterior summaries such as the mean, median, and mode (where defined and computable), with user-selectable posterior quantiles and credible intervals of any desired probability level. Appropriate safeguards have been incorporated for cases in which the posterior mode does not exist or cannot be determined analytically.

Graphical output includes the corresponding prior and posterior density curves to facilitate visual comparison. All prior distribution parameters are fully user-configurable, allowing the models to be adapted to different prior beliefs and applications.


Show code

# ============================================================= # Bayesian Conjugate Model – Binomial-Beta # Closed-form analytical solution (standalone script) # # Likelihood : X | theta ~ Binomial(n, theta) # Prior : theta ~ Beta(alpha, beta) # Posterior : theta | x ~ Beta(alpha + x, beta + n – x) # # Input : scalar / list / vector OR DataFrame + var + group_var # Output : clean console tables, Plotly HTML saved to disk # Plots : Plotly only — saved as HTML, never auto-rendered # ============================================================= import os import math import numpy as np import pandas as pd from scipy.stats import beta as sp_beta import plotly.graph_objects as go from plotly.subplots import make_subplots # ============================================================= # INTERNAL UTILITIES # ============================================================= def _print_table(title: str, df: pd.DataFrame) -> None: “””Plain text table to console — no color, no clutter.””” col_names = list(df.columns) col_widths = [ max(len(nm), df[nm].astype(str).map(len).max()) for nm in col_names ] col_widths = [max(w, 10) for w in col_widths] total_width = sum(col_widths) + 3 * (len(col_widths) – 1) + 2 divider = “=” * total_width thin_div = “-” * total_width header = ” “.join(nm.ljust(w) for nm, w in zip(col_names, col_widths)) lines = [] lines.append(divider) lines.append(f” {title}”) lines.append(divider) lines.append(f” {header}”) lines.append(f” {thin_div}”) for _, row in df.iterrows(): row_str = ” “.join(str(val).ljust(w) for val, w in zip(row, col_widths)) lines.append(f” {row_str}”) lines.append(divider) lines.append(“”) print(“\n”.join(lines)) def _fmt_mode(v) -> str: return “undefined” if v is None else f”{v:.6f}” def _expand_cri(cri_levels, quantiles): “””Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles.””” q_all = sorted(set(quantiles)) if quantiles else [] if cri_levels: for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo q_all = sorted(set(q_all + [lo, hi])) return q_all def _build_cri_table(cri_levels, all_q, q_vals, cri_type): “””Build CrI table from cri_levels only — symmetric pairs.””” if not cri_levels: return None rows = [] for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo idx_lo = next((i for i, q in enumerate(all_q) if abs(q – lo) < 1e-10), None) idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None) if idx_lo is not None and idx_hi is not None: rows.append({ "Level": f"{lev}% {cri_type}", "Lower": f"{q_vals[idx_lo]:.6f}", "Upper": f"{q_vals[idx_hi]:.6f}", }) return pd.DataFrame(rows) if rows else None def _build_quantile_table(quantiles, all_q, q_vals): """Build individual quantile table from quantiles arg only.""" if not quantiles: return None rows = [] for q in sorted(set(quantiles)): idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None) if idx is not None: rows.append({ "Quantile": f"Q({q:.4f})", "Value": f"{q_vals[idx]:.6f}", }) return pd.DataFrame(rows) if rows else None def _validate_df_var(data: pd.DataFrame, var: str) -> None: “””Validate DataFrame and column for the Binomial-Beta model.””” if not isinstance(data, pd.DataFrame): raise TypeError(“`data` must be a pandas DataFrame”) if not isinstance(var, str): raise TypeError(“`var` must be a single column name (string)”) if var not in data.columns: raise ValueError(f”Column ‘{var}’ not found in data”) col = data[var].dropna() if col.empty: raise ValueError(f”Column ‘{var}’ is entirely NA”) uvals = col.unique() if len(uvals) > 2: raise ValueError( f”Column ‘{var}’ has {len(uvals)} unique values. ” f”Binomial model requires a binary variable (exactly 2 levels).” ) if len(uvals) < 2: raise ValueError( f"Column '{var}' has only one unique non-NA value — cannot fit Binomial model" ) def _validate_group(data: pd.DataFrame, group_var) -> None: if group_var is not None and group_var not in data.columns: raise ValueError(f”group_var column ‘{group_var}’ not found in data”) def _split_entries(data: pd.DataFrame, var: str, group_var): “””Split data into labelled entries by group.””” if group_var is not None: entries = [] for g in data[group_var].unique(): sub = data[data[group_var] == g] entries.append({ “label”: f”{group_var} = {g}”, “col”: sub[var].dropna().values }) return entries else: return [{“label”: “All”, “col”: data[var].dropna().values}] def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str: “””Resolve final HTML save path.””” directory = plot_path if plot_path is not None else os.getcwd() filename = (plot_filename if plot_filename is not None else default_name) if not filename.endswith(“.html”): filename += “.html” return os.path.join(directory, filename) def _save_plot(fig, path: str) -> None: fig.write_html(path, auto_open=False) print(f” [Plot saved] {path}\n”) # ============================================================= # BINOMIAL – BETA # ============================================================= def conjugate_binomial_beta( data = None, var = None, group_var = None, success_level = None, x = None, n = None, alpha = None, beta = None, cri_levels = (90, 95), quantiles = None, cri_type = “CrI”, plot_path = None, plot_filename = None, ): cri_levels = list(cri_levels) if cri_levels else [] quantiles = list(quantiles) if quantiles else [] all_q = _expand_cri(cri_levels, quantiles) # — Build entries — if data is not None: _validate_df_var(data, var) _validate_group(data, group_var) entries_raw = _split_entries(data, var, group_var) entries = [] for e in entries_raw: col = e[“col”] uvals = sorted(set(col.astype(str))) sl = str(success_level) if success_level is not None else uvals[0] if sl not in [str(v) for v in col]: raise ValueError(f”success_level ‘{sl}’ not found in column ‘{var}'”) xv = int(np.sum(col.astype(str) == sl)) nv = len(col) entries.append({“label”: e[“label”], “x”: xv, “n”: nv, “sl”: sl}) else: if x is None or n is None: raise ValueError(“Provide `data` + `var` OR both `x` and `n`”) x_arr = np.atleast_1d(np.asarray(x, dtype=float)) n_arr = np.atleast_1d(np.asarray(n, dtype=float)) if len(n_arr) == 1: n_arr = np.repeat(n_arr, len(x_arr)) if len(x_arr) != len(n_arr): raise ValueError(“`x` and `n` must be the same length”) entries = [ {“label”: str(i + 1), “x”: x_arr[i], “n”: n_arr[i], “sl”: “1”} for i in range(len(x_arr)) ] results = [] for e in entries: xv, nv = e[“x”], e[“n”] a_post = alpha + xv b_post = beta + (nv – xv) pr_mean = alpha / (alpha + beta) pr_var = (alpha * beta) / ((alpha + beta)**2 * (alpha + beta + 1)) pr_mode = (alpha – 1) / (alpha + beta – 2) if (alpha > 1 and beta > 1) else None pr_median = sp_beta.ppf(0.5, alpha, beta) po_mean = a_post / (a_post + b_post) po_var = (a_post * b_post) / ((a_post + b_post)**2 * (a_post + b_post + 1)) po_mode = (a_post – 1) / (a_post + b_post – 2) if (a_post > 1 and b_post > 1) else None po_median = sp_beta.ppf(0.5, a_post, b_post) all_q_int = sorted(set(all_q + [0.5])) q_vals = [sp_beta.ppf(q, a_post, b_post) for q in all_q_int] _print_table( f”Binomial-Beta | {e[‘label’]} | Data Summary”, pd.DataFrame([{ “Successes”: int(xv), “Trials”: int(nv), “Observed Prop”: f”{xv/nv:.6f}”, “Success Level”: str(e[“sl”]), }]) ) _print_table( f”Binomial-Beta | {e[‘label’]} | Parameters & Moments”, pd.DataFrame({ “Quantity”: [“alpha”, “beta”, “Mean”, “Median”, “Mode”, “Variance”, “SD”], “Prior”: [f”{alpha:.6f}”, f”{beta:.6f}”, f”{pr_mean:.6f}”, f”{pr_median:.6f}”, _fmt_mode(pr_mode), f”{pr_var:.6f}”, f”{math.sqrt(pr_var):.6f}”], “Posterior”: [f”{a_post:.6f}”, f”{b_post:.6f}”, f”{po_mean:.6f}”, f”{po_median:.6f}”, _fmt_mode(po_mode), f”{po_var:.6f}”, f”{math.sqrt(po_var):.6f}”], }) ) cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type) if cri_df is not None: _print_table(f”Binomial-Beta | {e[‘label’]} | Credible Intervals ({cri_type})”, cri_df) q_df = _build_quantile_table(quantiles, all_q_int, q_vals) if q_df is not None: _print_table(f”Binomial-Beta | {e[‘label’]} | Posterior Quantiles”, q_df) # — Plot — th_lo = 1e-4 if min(alpha, beta, a_post, b_post) < 1 else 0.0 th_hi = 1 - th_lo th = np.linspace(th_lo, th_hi, 1000) fig = make_subplots(rows=1, cols=2, subplot_titles=["Prior", "Posterior"]) fig.add_trace(go.Scatter( x=th, y=sp_beta.pdf(th, alpha, beta), mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False ), row=1, col=1) fig.add_trace(go.Scatter( x=th, y=sp_beta.pdf(th, a_post, b_post), mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False ), row=1, col=2) fig.update_xaxes(title_text="θ", row=1, col=1) fig.update_xaxes(title_text="θ", row=1, col=2) fig.update_yaxes(title_text="Density", row=1, col=1) fig.update_layout( title_text=( f"Binomial-Beta [{e['label']}]
” f”Prior: Beta({alpha:.2f}, {beta:.2f}) | ” f”Posterior: Beta({a_post:.2f}, {b_post:.2f})” ), template=”plotly_white”, ) default_name = f”binomial_beta_{e[‘label’].replace(‘ ‘, ‘_’)}” fpath = _resolve_plot_path(plot_path, plot_filename, default_name) _save_plot(fig, fpath) results.append({ “label”: e[“label”], “data”: {“x”: xv, “n”: nv, “success_level”: e[“sl”]}, “prior”: {“alpha”: alpha, “beta”: beta, “mean”: pr_mean, “median”: pr_median, “mode”: pr_mode, “var”: pr_var, “sd”: math.sqrt(pr_var)}, “posterior”: {“alpha”: a_post, “beta”: b_post, “mean”: po_mean, “median”: po_median, “mode”: po_mode, “var”: po_var, “sd”: math.sqrt(po_var), “quantiles”: dict(zip(all_q_int, q_vals))}, “plot”: fig, }) return results[0] if len(results) == 1 else results # ============================================================= # USAGE EXAMPLES # ============================================================= if __name__ == “__main__”: # scalar # res = conjugate_binomial_beta(x=14, n=20, alpha=2, beta=0.2, # cri_levels=(80, 90, 95, 99), quantiles=(0.75,)) # df + var (binary 0/1) # df = pd.DataFrame({“response”: [1,0,1,1,0,1,0,0,1,1]}) # res = conjugate_binomial_beta(data=df, var=”response”, # alpha=2, beta=2, cri_levels=(90, 95)) # df + var (character binary) + group + success_level # df = pd.DataFrame({ # “response”: [“Yes”,”No”,”Yes”,”Yes”,”No”,”Yes”,”No”,”No”,”Yes”,”Yes”], # “group”: [“A”]*5 + [“B”]*5 # }) # res = conjugate_binomial_beta(data=df, var=”response”, group_var=”group”, # success_level=”Yes”, alpha=2, beta=2, # cri_levels=(90, 95)) # —- access plot object and enhance —- # res = conjugate_binomial_beta(x=14, n=20, alpha=2, beta=2) # fig = res[“plot”] # fig.update_layout(title_text=”My Custom Title”) # fig.write_html(“my_custom_plot.html”, auto_open=False) # —- custom plot path and filename —- res = conjugate_binomial_beta( x=14, n=20, alpha=2, beta=2, cri_levels=[80, 90, 95, 99], quantiles=[0.2, 0.75], plot_path=”.”, plot_filename=”binom_result” )

# =============================================================
# Bayesian Conjugate Model - Binomial-Beta
# Closed-form analytical solution (standalone script)
#
# Likelihood : X | theta ~ Binomial(n, theta)
# Prior      : theta ~ Beta(alpha, beta)
# Posterior  : theta | x ~ Beta(alpha + x, beta + n - x)
#
# Input  : scalar / list / vector  OR  DataFrame + var + group_var
# Output : clean console tables, Plotly HTML saved to disk
# Plots  : Plotly only — saved as HTML, never auto-rendered
# =============================================================

import os
import math
import numpy as np
import pandas as pd
from scipy.stats import beta as sp_beta
import plotly.graph_objects as go
from plotly.subplots import make_subplots


# =============================================================
# INTERNAL UTILITIES
# =============================================================

def _print_table(title: str, df: pd.DataFrame) -> None:
    """Plain text table to console — no color, no clutter."""
    col_names  = list(df.columns)
    col_widths = [
        max(len(nm), df[nm].astype(str).map(len).max())
        for nm in col_names
    ]
    col_widths = [max(w, 10) for w in col_widths]

    total_width = sum(col_widths) + 3 * (len(col_widths) - 1) + 2
    divider     = "=" * total_width
    thin_div    = "-" * total_width

    header = "   ".join(nm.ljust(w) for nm, w in zip(col_names, col_widths))

    lines = []
    lines.append(divider)
    lines.append(f"  {title}")
    lines.append(divider)
    lines.append(f"  {header}")
    lines.append(f"  {thin_div}")
    for _, row in df.iterrows():
        row_str = "   ".join(str(val).ljust(w) for val, w in zip(row, col_widths))
        lines.append(f"  {row_str}")
    lines.append(divider)
    lines.append("")
    print("\n".join(lines))


def _fmt_mode(v) -> str:
    return "undefined" if v is None else f"{v:.6f}"


def _expand_cri(cri_levels, quantiles):
    """Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles."""
    q_all = sorted(set(quantiles)) if quantiles else []
    if cri_levels:
        for lev in sorted(set(cri_levels)):
            lo = (1 - lev / 100) / 2
            hi = 1 - lo
            q_all = sorted(set(q_all + [lo, hi]))
    return q_all


def _build_cri_table(cri_levels, all_q, q_vals, cri_type):
    """Build CrI table from cri_levels only — symmetric pairs."""
    if not cri_levels:
        return None
    rows = []
    for lev in sorted(set(cri_levels)):
        lo = (1 - lev / 100) / 2
        hi = 1 - lo
        idx_lo = next((i for i, q in enumerate(all_q) if abs(q - lo) < 1e-10), None)
        idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None)
        if idx_lo is not None and idx_hi is not None:
            rows.append({
                "Level": f"{lev}% {cri_type}",
                "Lower": f"{q_vals[idx_lo]:.6f}",
                "Upper": f"{q_vals[idx_hi]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _build_quantile_table(quantiles, all_q, q_vals):
    """Build individual quantile table from quantiles arg only."""
    if not quantiles:
        return None
    rows = []
    for q in sorted(set(quantiles)):
        idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None)
        if idx is not None:
            rows.append({
                "Quantile": f"Q({q:.4f})",
                "Value":    f"{q_vals[idx]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _validate_df_var(data: pd.DataFrame, var: str) -> None:
    """Validate DataFrame and column for the Binomial-Beta model."""
    if not isinstance(data, pd.DataFrame):
        raise TypeError("`data` must be a pandas DataFrame")
    if not isinstance(var, str):
        raise TypeError("`var` must be a single column name (string)")
    if var not in data.columns:
        raise ValueError(f"Column '{var}' not found in data")

    col = data[var].dropna()
    if col.empty:
        raise ValueError(f"Column '{var}' is entirely NA")

    uvals = col.unique()
    if len(uvals) > 2:
        raise ValueError(
            f"Column '{var}' has {len(uvals)} unique values. "
            f"Binomial model requires a binary variable (exactly 2 levels)."
        )
    if len(uvals) < 2:
        raise ValueError(
            f"Column '{var}' has only one unique non-NA value — cannot fit Binomial model"
        )


def _validate_group(data: pd.DataFrame, group_var) -> None:
    if group_var is not None and group_var not in data.columns:
        raise ValueError(f"group_var column '{group_var}' not found in data")


def _split_entries(data: pd.DataFrame, var: str, group_var):
    """Split data into labelled entries by group."""
    if group_var is not None:
        entries = []
        for g in data[group_var].unique():
            sub = data[data[group_var] == g]
            entries.append({
                "label": f"{group_var} = {g}",
                "col":   sub[var].dropna().values
            })
        return entries
    else:
        return [{"label": "All", "col": data[var].dropna().values}]


def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str:
    """Resolve final HTML save path."""
    directory = plot_path if plot_path is not None else os.getcwd()
    filename  = (plot_filename if plot_filename is not None else default_name)
    if not filename.endswith(".html"):
        filename += ".html"
    return os.path.join(directory, filename)


def _save_plot(fig, path: str) -> None:
    fig.write_html(path, auto_open=False)
    print(f"  [Plot saved] {path}\n")


# =============================================================
# BINOMIAL - BETA
# =============================================================

def conjugate_binomial_beta(
    data          = None,
    var           = None,
    group_var     = None,
    success_level = None,
    x             = None,
    n             = None,
    alpha         = None,
    beta          = None,
    cri_levels    = (90, 95),
    quantiles     = None,
    cri_type      = "CrI",
    plot_path     = None,
    plot_filename = None,
):
    cri_levels = list(cri_levels) if cri_levels else []
    quantiles  = list(quantiles)  if quantiles  else []
    all_q      = _expand_cri(cri_levels, quantiles)

    # --- Build entries ---
    if data is not None:
        _validate_df_var(data, var)
        _validate_group(data, group_var)
        entries_raw = _split_entries(data, var, group_var)
        entries = []
        for e in entries_raw:
            col   = e["col"]
            uvals = sorted(set(col.astype(str)))
            sl    = str(success_level) if success_level is not None else uvals[0]
            if sl not in [str(v) for v in col]:
                raise ValueError(f"success_level '{sl}' not found in column '{var}'")
            xv = int(np.sum(col.astype(str) == sl))
            nv = len(col)
            entries.append({"label": e["label"], "x": xv, "n": nv, "sl": sl})
    else:
        if x is None or n is None:
            raise ValueError("Provide `data` + `var`  OR  both `x` and `n`")
        x_arr = np.atleast_1d(np.asarray(x, dtype=float))
        n_arr = np.atleast_1d(np.asarray(n, dtype=float))
        if len(n_arr) == 1:
            n_arr = np.repeat(n_arr, len(x_arr))
        if len(x_arr) != len(n_arr):
            raise ValueError("`x` and `n` must be the same length")
        entries = [
            {"label": str(i + 1), "x": x_arr[i], "n": n_arr[i], "sl": "1"}
            for i in range(len(x_arr))
        ]

    results = []
    for e in entries:
        xv, nv = e["x"], e["n"]

        a_post = alpha + xv
        b_post = beta  + (nv - xv)

        pr_mean   = alpha / (alpha + beta)
        pr_var    = (alpha * beta) / ((alpha + beta)**2 * (alpha + beta + 1))
        pr_mode   = (alpha - 1) / (alpha + beta - 2) if (alpha > 1 and beta > 1) else None
        pr_median = sp_beta.ppf(0.5, alpha, beta)

        po_mean   = a_post / (a_post + b_post)
        po_var    = (a_post * b_post) / ((a_post + b_post)**2 * (a_post + b_post + 1))
        po_mode   = (a_post - 1) / (a_post + b_post - 2) if (a_post > 1 and b_post > 1) else None
        po_median = sp_beta.ppf(0.5, a_post, b_post)

        all_q_int = sorted(set(all_q + [0.5]))
        q_vals    = [sp_beta.ppf(q, a_post, b_post) for q in all_q_int]

        _print_table(
            f"Binomial-Beta  |  {e['label']}  |  Data Summary",
            pd.DataFrame([{
                "Successes":      int(xv),
                "Trials":         int(nv),
                "Observed Prop":  f"{xv/nv:.6f}",
                "Success Level":  str(e["sl"]),
            }])
        )

        _print_table(
            f"Binomial-Beta  |  {e['label']}  |  Parameters & Moments",
            pd.DataFrame({
                "Quantity":  ["alpha", "beta", "Mean", "Median", "Mode", "Variance", "SD"],
                "Prior":     [f"{alpha:.6f}", f"{beta:.6f}", f"{pr_mean:.6f}",
                              f"{pr_median:.6f}", _fmt_mode(pr_mode),
                              f"{pr_var:.6f}", f"{math.sqrt(pr_var):.6f}"],
                "Posterior": [f"{a_post:.6f}", f"{b_post:.6f}", f"{po_mean:.6f}",
                              f"{po_median:.6f}", _fmt_mode(po_mode),
                              f"{po_var:.6f}", f"{math.sqrt(po_var):.6f}"],
            })
        )

        cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type)
        if cri_df is not None:
            _print_table(f"Binomial-Beta  |  {e['label']}  |  Credible Intervals ({cri_type})", cri_df)

        q_df = _build_quantile_table(quantiles, all_q_int, q_vals)
        if q_df is not None:
            _print_table(f"Binomial-Beta  |  {e['label']}  |  Posterior Quantiles", q_df)

        # --- Plot ---
        th_lo = 1e-4 if min(alpha, beta, a_post, b_post) < 1 else 0.0
        th_hi = 1 - th_lo
        th    = np.linspace(th_lo, th_hi, 1000)

        fig = make_subplots(rows=1, cols=2, subplot_titles=["Prior", "Posterior"])
        fig.add_trace(go.Scatter(
            x=th, y=sp_beta.pdf(th, alpha, beta),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=1)
        fig.add_trace(go.Scatter(
            x=th, y=sp_beta.pdf(th, a_post, b_post),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=2)
        fig.update_xaxes(title_text="θ", row=1, col=1)
        fig.update_xaxes(title_text="θ", row=1, col=2)
        fig.update_yaxes(title_text="Density", row=1, col=1)
        fig.update_layout(
            title_text=(
                f"Binomial-Beta  [{e['label']}]<br>"
                f"<sup>Prior: Beta({alpha:.2f}, {beta:.2f})  |  "
                f"Posterior: Beta({a_post:.2f}, {b_post:.2f})</sup>"
            ),
            template="plotly_white",
        )

        default_name = f"binomial_beta_{e['label'].replace(' ', '_')}"
        fpath = _resolve_plot_path(plot_path, plot_filename, default_name)
        _save_plot(fig, fpath)

        results.append({
            "label":     e["label"],
            "data":      {"x": xv, "n": nv, "success_level": e["sl"]},
            "prior":     {"alpha": alpha, "beta": beta,
                          "mean": pr_mean, "median": pr_median, "mode": pr_mode,
                          "var": pr_var, "sd": math.sqrt(pr_var)},
            "posterior": {"alpha": a_post, "beta": b_post,
                          "mean": po_mean, "median": po_median, "mode": po_mode,
                          "var": po_var, "sd": math.sqrt(po_var),
                          "quantiles": dict(zip(all_q_int, q_vals))},
            "plot":      fig,
        })

    return results[0] if len(results) == 1 else results


# =============================================================
# USAGE EXAMPLES
# =============================================================
if __name__ == "__main__":
    # scalar
    # res = conjugate_binomial_beta(x=14, n=20, alpha=2, beta=0.2,
    #                               cri_levels=(80, 90, 95, 99), quantiles=(0.75,))

    # df + var (binary 0/1)
    # df = pd.DataFrame({"response": [1,0,1,1,0,1,0,0,1,1]})
    # res = conjugate_binomial_beta(data=df, var="response",
    #                               alpha=2, beta=2, cri_levels=(90, 95))

    # df + var (character binary) + group + success_level
    # df = pd.DataFrame({
    #     "response": ["Yes","No","Yes","Yes","No","Yes","No","No","Yes","Yes"],
    #     "group":    ["A"]*5 + ["B"]*5
    # })
    # res = conjugate_binomial_beta(data=df, var="response", group_var="group",
    #                               success_level="Yes", alpha=2, beta=2,
    #                               cri_levels=(90, 95))

    # ---- access plot object and enhance ----
    # res = conjugate_binomial_beta(x=14, n=20, alpha=2, beta=2)
    # fig = res["plot"]
    # fig.update_layout(title_text="My Custom Title")
    # fig.write_html("my_custom_plot.html", auto_open=False)

    # ---- custom plot path and filename ----
    res = conjugate_binomial_beta(
        x=14, n=20, alpha=2, beta=2,
        cri_levels=[80, 90, 95, 99], quantiles=[0.2, 0.75],
        plot_path=".", plot_filename="binom_result"
    )
Show code

# ============================================================= # Bayesian Conjugate Model – Poisson-Gamma # Closed-form analytical solution (standalone script) # # Likelihood : X_i ~ Poisson(lambda) # Prior : lambda ~ Gamma(alpha, rate = beta) # Posterior : lambda | x ~ Gamma(alpha + sum(x), rate = beta + n) # # Input : scalar / list / vector OR DataFrame + var + group_var # Output : clean console tables, Plotly HTML saved to disk # Plots : Plotly only — saved as HTML, never auto-rendered # ============================================================= import os import math import numpy as np import pandas as pd from scipy.stats import gamma as sp_gamma import plotly.graph_objects as go from plotly.subplots import make_subplots # ============================================================= # INTERNAL UTILITIES # ============================================================= def _print_table(title: str, df: pd.DataFrame) -> None: “””Plain text table to console — no color, no clutter.””” col_names = list(df.columns) col_widths = [ max(len(nm), df[nm].astype(str).map(len).max()) for nm in col_names ] col_widths = [max(w, 10) for w in col_widths] total_width = sum(col_widths) + 3 * (len(col_widths) – 1) + 2 divider = “=” * total_width thin_div = “-” * total_width header = ” “.join(nm.ljust(w) for nm, w in zip(col_names, col_widths)) lines = [] lines.append(divider) lines.append(f” {title}”) lines.append(divider) lines.append(f” {header}”) lines.append(f” {thin_div}”) for _, row in df.iterrows(): row_str = ” “.join(str(val).ljust(w) for val, w in zip(row, col_widths)) lines.append(f” {row_str}”) lines.append(divider) lines.append(“”) print(“\n”.join(lines)) def _fmt_mode(v) -> str: return “undefined” if v is None else f”{v:.6f}” def _expand_cri(cri_levels, quantiles): “””Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles.””” q_all = sorted(set(quantiles)) if quantiles else [] if cri_levels: for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo q_all = sorted(set(q_all + [lo, hi])) return q_all def _build_cri_table(cri_levels, all_q, q_vals, cri_type): “””Build CrI table from cri_levels only — symmetric pairs.””” if not cri_levels: return None rows = [] for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo idx_lo = next((i for i, q in enumerate(all_q) if abs(q – lo) < 1e-10), None) idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None) if idx_lo is not None and idx_hi is not None: rows.append({ "Level": f"{lev}% {cri_type}", "Lower": f"{q_vals[idx_lo]:.6f}", "Upper": f"{q_vals[idx_hi]:.6f}", }) return pd.DataFrame(rows) if rows else None def _build_quantile_table(quantiles, all_q, q_vals): """Build individual quantile table from quantiles arg only.""" if not quantiles: return None rows = [] for q in sorted(set(quantiles)): idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None) if idx is not None: rows.append({ "Quantile": f"Q({q:.4f})", "Value": f"{q_vals[idx]:.6f}", }) return pd.DataFrame(rows) if rows else None def _validate_df_var(data: pd.DataFrame, var: str) -> None: “””Validate DataFrame and column for the Poisson-Gamma model.””” if not isinstance(data, pd.DataFrame): raise TypeError(“`data` must be a pandas DataFrame”) if not isinstance(var, str): raise TypeError(“`var` must be a single column name (string)”) if var not in data.columns: raise ValueError(f”Column ‘{var}’ not found in data”) col = data[var].dropna() if col.empty: raise ValueError(f”Column ‘{var}’ is entirely NA”) if not pd.api.types.is_numeric_dtype(col): raise TypeError(f”Column ‘{var}’ must be numeric for Poisson-Gamma model”) if (col < 0).any(): raise ValueError( f"Column '{var}' contains negative values — Poisson model requires non-negative counts" ) if not (col == col.apply(math.floor)).all(): raise ValueError( f"Column '{var}' contains non-integer values — Poisson model requires integer counts" ) def _validate_group(data: pd.DataFrame, group_var) -> None: if group_var is not None and group_var not in data.columns: raise ValueError(f”group_var column ‘{group_var}’ not found in data”) def _split_entries(data: pd.DataFrame, var: str, group_var): “””Split data into labelled entries by group.””” if group_var is not None: entries = [] for g in data[group_var].unique(): sub = data[data[group_var] == g] entries.append({ “label”: f”{group_var} = {g}”, “col”: sub[var].dropna().values }) return entries else: return [{“label”: “All”, “col”: data[var].dropna().values}] def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str: “””Resolve final HTML save path.””” directory = plot_path if plot_path is not None else os.getcwd() filename = (plot_filename if plot_filename is not None else default_name) if not filename.endswith(“.html”): filename += “.html” return os.path.join(directory, filename) def _save_plot(fig, path: str) -> None: fig.write_html(path, auto_open=False) print(f” [Plot saved] {path}\n”) # ============================================================= # POISSON – GAMMA # ============================================================= def conjugate_poisson_gamma( data = None, var = None, group_var = None, x = None, alpha = None, beta = None, cri_levels = (90, 95), quantiles = None, cri_type = “CrI”, plot_path = None, plot_filename = None, ): cri_levels = list(cri_levels) if cri_levels else [] quantiles = list(quantiles) if quantiles else [] all_q = _expand_cri(cri_levels, quantiles) if data is not None: _validate_df_var(data, var) _validate_group(data, group_var) entries_raw = _split_entries(data, var, group_var) entries = [{“label”: e[“label”], “x”: e[“col”]} for e in entries_raw] else: if x is None: raise ValueError(“Provide `data` + `var` OR `x`”) entries = [{“label”: “All”, “x”: np.atleast_1d(np.asarray(x, dtype=float))}] results = [] for e in entries: xv = np.asarray(e[“x”], dtype=float) nv = len(xv) sum_x = float(np.sum(xv)) a_post = alpha + sum_x b_post = beta + nv pr_mean = alpha / beta pr_var = alpha / beta**2 pr_mode = (alpha – 1) / beta if alpha >= 1 else None pr_median = sp_gamma.ppf(0.5, a=alpha, scale=1/beta) po_mean = a_post / b_post po_var = a_post / b_post**2 po_mode = (a_post – 1) / b_post if a_post >= 1 else None po_median = sp_gamma.ppf(0.5, a=a_post, scale=1/b_post) all_q_int = sorted(set(all_q + [0.5])) q_vals = [sp_gamma.ppf(q, a=a_post, scale=1/b_post) for q in all_q_int] _print_table( f”Poisson-Gamma | {e[‘label’]} | Data Summary”, pd.DataFrame([{ “n”: nv, “sum(x)”: int(sum_x), “mean(x)”: f”{float(np.mean(xv)):.6f}”, }]) ) _print_table( f”Poisson-Gamma | {e[‘label’]} | Parameters & Moments”, pd.DataFrame({ “Quantity”: [“alpha (shape)”, “beta (rate)”, “Mean”, “Median”, “Mode”, “Variance”, “SD”], “Prior”: [f”{alpha:.6f}”, f”{beta:.6f}”, f”{pr_mean:.6f}”, f”{pr_median:.6f}”, _fmt_mode(pr_mode), f”{pr_var:.6f}”, f”{math.sqrt(pr_var):.6f}”], “Posterior”: [f”{a_post:.6f}”, f”{b_post:.6f}”, f”{po_mean:.6f}”, f”{po_median:.6f}”, _fmt_mode(po_mode), f”{po_var:.6f}”, f”{math.sqrt(po_var):.6f}”], }) ) cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type) if cri_df is not None: _print_table(f”Poisson-Gamma | {e[‘label’]} | Credible Intervals ({cri_type})”, cri_df) q_df = _build_quantile_table(quantiles, all_q_int, q_vals) if q_df is not None: _print_table(f”Poisson-Gamma | {e[‘label’]} | Posterior Quantiles”, q_df) # — Plot — pr_lo = max(1e-6, sp_gamma.ppf(0.001, a=alpha, scale=1/beta)) pr_hi = sp_gamma.ppf(0.999, a=alpha, scale=1/beta) po_lo = max(1e-6, sp_gamma.ppf(0.001, a=a_post, scale=1/b_post)) po_hi = sp_gamma.ppf(0.999, a=a_post, scale=1/b_post) lam_pr = np.linspace(pr_lo, pr_hi, 1000) lam_po = np.linspace(po_lo, po_hi, 1000) fig = make_subplots(rows=1, cols=2, subplot_titles=[“Prior”, “Posterior”]) fig.add_trace(go.Scatter( x=lam_pr, y=sp_gamma.pdf(lam_pr, a=alpha, scale=1/beta), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=1) fig.add_trace(go.Scatter( x=lam_po, y=sp_gamma.pdf(lam_po, a=a_post, scale=1/b_post), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=2) fig.update_xaxes(title_text=”λ”, row=1, col=1) fig.update_xaxes(title_text=”λ”, row=1, col=2) fig.update_yaxes(title_text=”Density”, row=1, col=1) fig.update_layout( title_text=( f”Poisson-Gamma [{e[‘label’]}]
” f”Prior: Gamma({alpha:.2f}, rate={beta:.2f}) | ” f”Posterior: Gamma({a_post:.2f}, rate={b_post:.2f})” ), template=”plotly_white”, ) default_name = f”poisson_gamma_{e[‘label’].replace(‘ ‘, ‘_’)}” fpath = _resolve_plot_path(plot_path, plot_filename, default_name) _save_plot(fig, fpath) results.append({ “label”: e[“label”], “data”: {“n”: nv, “sum_x”: sum_x}, “prior”: {“alpha”: alpha, “beta”: beta, “mean”: pr_mean, “median”: pr_median, “mode”: pr_mode, “var”: pr_var, “sd”: math.sqrt(pr_var)}, “posterior”: {“alpha”: a_post, “beta”: b_post, “mean”: po_mean, “median”: po_median, “mode”: po_mode, “var”: po_var, “sd”: math.sqrt(po_var), “quantiles”: dict(zip(all_q_int, q_vals))}, “plot”: fig, }) return results[0] if len(results) == 1 else results # ============================================================= # USAGE EXAMPLES # ============================================================= if __name__ == “__main__”: # scalar / list # res = conjugate_poisson_gamma(x=[3,5,2,4,6], alpha=2, beta=1, # cri_levels=(80, 90, 95, 99)) # df + var + group # df = pd.DataFrame({“counts”: [3,5,2,4,6,1,3,2,5,4], # “site”: [“S1”]*5 + [“S2″]*5}) # res = conjugate_poisson_gamma(data=df, var=”counts”, group_var=”site”, # alpha=2, beta=1, cri_levels=(90, 95)) res = conjugate_poisson_gamma( x=[3, 5, 2, 4, 6], alpha=2, beta=1, cri_levels=(80, 90, 95, 99), plot_path=”.”, plot_filename=”poisson_result” )

# =============================================================
# Bayesian Conjugate Model - Poisson-Gamma
# Closed-form analytical solution (standalone script)
#
# Likelihood : X_i ~ Poisson(lambda)
# Prior      : lambda ~ Gamma(alpha, rate = beta)
# Posterior  : lambda | x ~ Gamma(alpha + sum(x), rate = beta + n)
#
# Input  : scalar / list / vector  OR  DataFrame + var + group_var
# Output : clean console tables, Plotly HTML saved to disk
# Plots  : Plotly only — saved as HTML, never auto-rendered
# =============================================================

import os
import math
import numpy as np
import pandas as pd
from scipy.stats import gamma as sp_gamma
import plotly.graph_objects as go
from plotly.subplots import make_subplots


# =============================================================
# INTERNAL UTILITIES
# =============================================================

def _print_table(title: str, df: pd.DataFrame) -> None:
    """Plain text table to console — no color, no clutter."""
    col_names  = list(df.columns)
    col_widths = [
        max(len(nm), df[nm].astype(str).map(len).max())
        for nm in col_names
    ]
    col_widths = [max(w, 10) for w in col_widths]

    total_width = sum(col_widths) + 3 * (len(col_widths) - 1) + 2
    divider     = "=" * total_width
    thin_div    = "-" * total_width

    header = "   ".join(nm.ljust(w) for nm, w in zip(col_names, col_widths))

    lines = []
    lines.append(divider)
    lines.append(f"  {title}")
    lines.append(divider)
    lines.append(f"  {header}")
    lines.append(f"  {thin_div}")
    for _, row in df.iterrows():
        row_str = "   ".join(str(val).ljust(w) for val, w in zip(row, col_widths))
        lines.append(f"  {row_str}")
    lines.append(divider)
    lines.append("")
    print("\n".join(lines))


def _fmt_mode(v) -> str:
    return "undefined" if v is None else f"{v:.6f}"


def _expand_cri(cri_levels, quantiles):
    """Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles."""
    q_all = sorted(set(quantiles)) if quantiles else []
    if cri_levels:
        for lev in sorted(set(cri_levels)):
            lo = (1 - lev / 100) / 2
            hi = 1 - lo
            q_all = sorted(set(q_all + [lo, hi]))
    return q_all


def _build_cri_table(cri_levels, all_q, q_vals, cri_type):
    """Build CrI table from cri_levels only — symmetric pairs."""
    if not cri_levels:
        return None
    rows = []
    for lev in sorted(set(cri_levels)):
        lo = (1 - lev / 100) / 2
        hi = 1 - lo
        idx_lo = next((i for i, q in enumerate(all_q) if abs(q - lo) < 1e-10), None)
        idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None)
        if idx_lo is not None and idx_hi is not None:
            rows.append({
                "Level": f"{lev}% {cri_type}",
                "Lower": f"{q_vals[idx_lo]:.6f}",
                "Upper": f"{q_vals[idx_hi]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _build_quantile_table(quantiles, all_q, q_vals):
    """Build individual quantile table from quantiles arg only."""
    if not quantiles:
        return None
    rows = []
    for q in sorted(set(quantiles)):
        idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None)
        if idx is not None:
            rows.append({
                "Quantile": f"Q({q:.4f})",
                "Value":    f"{q_vals[idx]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _validate_df_var(data: pd.DataFrame, var: str) -> None:
    """Validate DataFrame and column for the Poisson-Gamma model."""
    if not isinstance(data, pd.DataFrame):
        raise TypeError("`data` must be a pandas DataFrame")
    if not isinstance(var, str):
        raise TypeError("`var` must be a single column name (string)")
    if var not in data.columns:
        raise ValueError(f"Column '{var}' not found in data")

    col = data[var].dropna()
    if col.empty:
        raise ValueError(f"Column '{var}' is entirely NA")

    if not pd.api.types.is_numeric_dtype(col):
        raise TypeError(f"Column '{var}' must be numeric for Poisson-Gamma model")
    if (col < 0).any():
        raise ValueError(
            f"Column '{var}' contains negative values — Poisson model requires non-negative counts"
        )
    if not (col == col.apply(math.floor)).all():
        raise ValueError(
            f"Column '{var}' contains non-integer values — Poisson model requires integer counts"
        )


def _validate_group(data: pd.DataFrame, group_var) -> None:
    if group_var is not None and group_var not in data.columns:
        raise ValueError(f"group_var column '{group_var}' not found in data")


def _split_entries(data: pd.DataFrame, var: str, group_var):
    """Split data into labelled entries by group."""
    if group_var is not None:
        entries = []
        for g in data[group_var].unique():
            sub = data[data[group_var] == g]
            entries.append({
                "label": f"{group_var} = {g}",
                "col":   sub[var].dropna().values
            })
        return entries
    else:
        return [{"label": "All", "col": data[var].dropna().values}]


def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str:
    """Resolve final HTML save path."""
    directory = plot_path if plot_path is not None else os.getcwd()
    filename  = (plot_filename if plot_filename is not None else default_name)
    if not filename.endswith(".html"):
        filename += ".html"
    return os.path.join(directory, filename)


def _save_plot(fig, path: str) -> None:
    fig.write_html(path, auto_open=False)
    print(f"  [Plot saved] {path}\n")


# =============================================================
# POISSON - GAMMA
# =============================================================

def conjugate_poisson_gamma(
    data          = None,
    var           = None,
    group_var     = None,
    x             = None,
    alpha         = None,
    beta          = None,
    cri_levels    = (90, 95),
    quantiles     = None,
    cri_type      = "CrI",
    plot_path     = None,
    plot_filename = None,
):
    cri_levels = list(cri_levels) if cri_levels else []
    quantiles  = list(quantiles)  if quantiles  else []
    all_q      = _expand_cri(cri_levels, quantiles)

    if data is not None:
        _validate_df_var(data, var)
        _validate_group(data, group_var)
        entries_raw = _split_entries(data, var, group_var)
        entries = [{"label": e["label"], "x": e["col"]} for e in entries_raw]
    else:
        if x is None:
            raise ValueError("Provide `data` + `var`  OR  `x`")
        entries = [{"label": "All", "x": np.atleast_1d(np.asarray(x, dtype=float))}]

    results = []
    for e in entries:
        xv    = np.asarray(e["x"], dtype=float)
        nv    = len(xv)
        sum_x = float(np.sum(xv))

        a_post = alpha + sum_x
        b_post = beta  + nv

        pr_mean   = alpha / beta
        pr_var    = alpha / beta**2
        pr_mode   = (alpha - 1) / beta if alpha >= 1 else None
        pr_median = sp_gamma.ppf(0.5, a=alpha, scale=1/beta)

        po_mean   = a_post / b_post
        po_var    = a_post / b_post**2
        po_mode   = (a_post - 1) / b_post if a_post >= 1 else None
        po_median = sp_gamma.ppf(0.5, a=a_post, scale=1/b_post)

        all_q_int = sorted(set(all_q + [0.5]))
        q_vals    = [sp_gamma.ppf(q, a=a_post, scale=1/b_post) for q in all_q_int]

        _print_table(
            f"Poisson-Gamma  |  {e['label']}  |  Data Summary",
            pd.DataFrame([{
                "n":       nv,
                "sum(x)":  int(sum_x),
                "mean(x)": f"{float(np.mean(xv)):.6f}",
            }])
        )

        _print_table(
            f"Poisson-Gamma  |  {e['label']}  |  Parameters & Moments",
            pd.DataFrame({
                "Quantity":  ["alpha (shape)", "beta (rate)", "Mean", "Median", "Mode", "Variance", "SD"],
                "Prior":     [f"{alpha:.6f}", f"{beta:.6f}", f"{pr_mean:.6f}",
                              f"{pr_median:.6f}", _fmt_mode(pr_mode),
                              f"{pr_var:.6f}", f"{math.sqrt(pr_var):.6f}"],
                "Posterior": [f"{a_post:.6f}", f"{b_post:.6f}", f"{po_mean:.6f}",
                              f"{po_median:.6f}", _fmt_mode(po_mode),
                              f"{po_var:.6f}", f"{math.sqrt(po_var):.6f}"],
            })
        )

        cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type)
        if cri_df is not None:
            _print_table(f"Poisson-Gamma  |  {e['label']}  |  Credible Intervals ({cri_type})", cri_df)

        q_df = _build_quantile_table(quantiles, all_q_int, q_vals)
        if q_df is not None:
            _print_table(f"Poisson-Gamma  |  {e['label']}  |  Posterior Quantiles", q_df)

        # --- Plot ---
        pr_lo  = max(1e-6, sp_gamma.ppf(0.001, a=alpha,  scale=1/beta))
        pr_hi  = sp_gamma.ppf(0.999, a=alpha,  scale=1/beta)
        po_lo  = max(1e-6, sp_gamma.ppf(0.001, a=a_post, scale=1/b_post))
        po_hi  = sp_gamma.ppf(0.999, a=a_post, scale=1/b_post)

        lam_pr = np.linspace(pr_lo, pr_hi, 1000)
        lam_po = np.linspace(po_lo, po_hi, 1000)

        fig = make_subplots(rows=1, cols=2, subplot_titles=["Prior", "Posterior"])
        fig.add_trace(go.Scatter(
            x=lam_pr, y=sp_gamma.pdf(lam_pr, a=alpha,  scale=1/beta),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=1)
        fig.add_trace(go.Scatter(
            x=lam_po, y=sp_gamma.pdf(lam_po, a=a_post, scale=1/b_post),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=2)
        fig.update_xaxes(title_text="λ", row=1, col=1)
        fig.update_xaxes(title_text="λ", row=1, col=2)
        fig.update_yaxes(title_text="Density", row=1, col=1)
        fig.update_layout(
            title_text=(
                f"Poisson-Gamma  [{e['label']}]<br>"
                f"<sup>Prior: Gamma({alpha:.2f}, rate={beta:.2f})  |  "
                f"Posterior: Gamma({a_post:.2f}, rate={b_post:.2f})</sup>"
            ),
            template="plotly_white",
        )

        default_name = f"poisson_gamma_{e['label'].replace(' ', '_')}"
        fpath = _resolve_plot_path(plot_path, plot_filename, default_name)
        _save_plot(fig, fpath)

        results.append({
            "label":     e["label"],
            "data":      {"n": nv, "sum_x": sum_x},
            "prior":     {"alpha": alpha, "beta": beta,
                          "mean": pr_mean, "median": pr_median, "mode": pr_mode,
                          "var": pr_var, "sd": math.sqrt(pr_var)},
            "posterior": {"alpha": a_post, "beta": b_post,
                          "mean": po_mean, "median": po_median, "mode": po_mode,
                          "var": po_var, "sd": math.sqrt(po_var),
                          "quantiles": dict(zip(all_q_int, q_vals))},
            "plot":      fig,
        })

    return results[0] if len(results) == 1 else results


# =============================================================
# USAGE EXAMPLES
# =============================================================
if __name__ == "__main__":
    # scalar / list
    # res = conjugate_poisson_gamma(x=[3,5,2,4,6], alpha=2, beta=1,
    #                               cri_levels=(80, 90, 95, 99))

    # df + var + group
    # df = pd.DataFrame({"counts": [3,5,2,4,6,1,3,2,5,4],
    #                    "site":   ["S1"]*5 + ["S2"]*5})
    # res = conjugate_poisson_gamma(data=df, var="counts", group_var="site",
    #                               alpha=2, beta=1, cri_levels=(90, 95))

    res = conjugate_poisson_gamma(
        x=[3, 5, 2, 4, 6], alpha=2, beta=1,
        cri_levels=(80, 90, 95, 99),
        plot_path=".", plot_filename="poisson_result"
    )
Show code

# ============================================================= # Bayesian Conjugate Model – Normal-Normal (Mean, variance known) # Closed-form analytical solution (standalone script) # # Likelihood : X_i ~ N(mu, sigma2) sigma2 known # Prior : mu ~ N(mu0, tau2) # Posterior : mu | x ~ N(mu_post, tau2_post) # tau2_post = 1 / (1/tau2 + n/sigma2) # mu_post = tau2_post * (mu0/tau2 + n*xbar/sigma2) # # Input : scalar / list / vector OR DataFrame + var + group_var # Output : clean console tables, Plotly HTML saved to disk # Plots : Plotly only — saved as HTML, never auto-rendered # ============================================================= import os import math import numpy as np import pandas as pd from scipy.stats import norm as sp_norm import plotly.graph_objects as go from plotly.subplots import make_subplots # ============================================================= # INTERNAL UTILITIES # ============================================================= def _print_table(title: str, df: pd.DataFrame) -> None: “””Plain text table to console — no color, no clutter.””” col_names = list(df.columns) col_widths = [ max(len(nm), df[nm].astype(str).map(len).max()) for nm in col_names ] col_widths = [max(w, 10) for w in col_widths] total_width = sum(col_widths) + 3 * (len(col_widths) – 1) + 2 divider = “=” * total_width thin_div = “-” * total_width header = ” “.join(nm.ljust(w) for nm, w in zip(col_names, col_widths)) lines = [] lines.append(divider) lines.append(f” {title}”) lines.append(divider) lines.append(f” {header}”) lines.append(f” {thin_div}”) for _, row in df.iterrows(): row_str = ” “.join(str(val).ljust(w) for val, w in zip(row, col_widths)) lines.append(f” {row_str}”) lines.append(divider) lines.append(“”) print(“\n”.join(lines)) def _expand_cri(cri_levels, quantiles): “””Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles.””” q_all = sorted(set(quantiles)) if quantiles else [] if cri_levels: for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo q_all = sorted(set(q_all + [lo, hi])) return q_all def _build_cri_table(cri_levels, all_q, q_vals, cri_type): “””Build CrI table from cri_levels only — symmetric pairs.””” if not cri_levels: return None rows = [] for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo idx_lo = next((i for i, q in enumerate(all_q) if abs(q – lo) < 1e-10), None) idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None) if idx_lo is not None and idx_hi is not None: rows.append({ "Level": f"{lev}% {cri_type}", "Lower": f"{q_vals[idx_lo]:.6f}", "Upper": f"{q_vals[idx_hi]:.6f}", }) return pd.DataFrame(rows) if rows else None def _build_quantile_table(quantiles, all_q, q_vals): """Build individual quantile table from quantiles arg only.""" if not quantiles: return None rows = [] for q in sorted(set(quantiles)): idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None) if idx is not None: rows.append({ "Quantile": f"Q({q:.4f})", "Value": f"{q_vals[idx]:.6f}", }) return pd.DataFrame(rows) if rows else None def _validate_df_var(data: pd.DataFrame, var: str) -> None: “””Validate DataFrame and column for the Normal-Normal model.””” if not isinstance(data, pd.DataFrame): raise TypeError(“`data` must be a pandas DataFrame”) if not isinstance(var, str): raise TypeError(“`var` must be a single column name (string)”) if var not in data.columns: raise ValueError(f”Column ‘{var}’ not found in data”) col = data[var].dropna() if col.empty: raise ValueError(f”Column ‘{var}’ is entirely NA”) if not pd.api.types.is_numeric_dtype(col): raise TypeError(f”Column ‘{var}’ must be numeric for Normal conjugate models”) def _validate_group(data: pd.DataFrame, group_var) -> None: if group_var is not None and group_var not in data.columns: raise ValueError(f”group_var column ‘{group_var}’ not found in data”) def _split_entries(data: pd.DataFrame, var: str, group_var): “””Split data into labelled entries by group.””” if group_var is not None: entries = [] for g in data[group_var].unique(): sub = data[data[group_var] == g] entries.append({ “label”: f”{group_var} = {g}”, “col”: sub[var].dropna().values }) return entries else: return [{“label”: “All”, “col”: data[var].dropna().values}] def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str: “””Resolve final HTML save path.””” directory = plot_path if plot_path is not None else os.getcwd() filename = (plot_filename if plot_filename is not None else default_name) if not filename.endswith(“.html”): filename += “.html” return os.path.join(directory, filename) def _save_plot(fig, path: str) -> None: fig.write_html(path, auto_open=False) print(f” [Plot saved] {path}\n”) # ============================================================= # NORMAL – NORMAL (estimating MEAN | variance known) # ============================================================= def conjugate_normal_normal( data = None, var = None, group_var = None, x = None, mu0 = None, tau2 = None, sigma2 = None, cri_levels = (90, 95), quantiles = None, cri_type = “CrI”, plot_path = None, plot_filename = None, ): cri_levels = list(cri_levels) if cri_levels else [] quantiles = list(quantiles) if quantiles else [] all_q = _expand_cri(cri_levels, quantiles) if data is not None: _validate_df_var(data, var) _validate_group(data, group_var) entries_raw = _split_entries(data, var, group_var) entries = [{“label”: e[“label”], “x”: e[“col”]} for e in entries_raw] else: if x is None: raise ValueError(“Provide `data` + `var` OR `x`”) entries = [{“label”: “All”, “x”: np.atleast_1d(np.asarray(x, dtype=float))}] results = [] for e in entries: xv = np.asarray(e[“x”], dtype=float) nv = len(xv) xbar = float(np.mean(xv)) tau2_post = 1 / (1/tau2 + nv/sigma2) mu_post = tau2_post * (mu0/tau2 + nv*xbar/sigma2) # Normal is symmetric: mean = median = mode pr_median = mu0 pr_mode = mu0 po_median = mu_post po_mode = mu_post all_q_int = sorted(set(all_q + [0.5])) q_vals = [sp_norm.ppf(q, loc=mu_post, scale=math.sqrt(tau2_post)) for q in all_q_int] _print_table( f”Normal-Normal | {e[‘label’]} | Data Summary”, pd.DataFrame([{ “n”: nv, “xbar”: f”{xbar:.6f}”, “sigma2 (known)”: f”{sigma2:.6f}”, }]) ) _print_table( f”Normal-Normal | {e[‘label’]} | Parameters & Moments”, pd.DataFrame({ “Quantity”: [“mu (mean=median=mode)”, “tau2 (variance)”, “SD”], “Prior”: [f”{mu0:.6f}”, f”{tau2:.6f}”, f”{math.sqrt(tau2):.6f}”], “Posterior”: [f”{mu_post:.6f}”, f”{tau2_post:.6f}”, f”{math.sqrt(tau2_post):.6f}”], }) ) cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type) if cri_df is not None: _print_table(f”Normal-Normal | {e[‘label’]} | Credible Intervals ({cri_type})”, cri_df) q_df = _build_quantile_table(quantiles, all_q_int, q_vals) if q_df is not None: _print_table(f”Normal-Normal | {e[‘label’]} | Posterior Quantiles”, q_df) # — Plot — pr_lo = sp_norm.ppf(0.001, loc=mu0, scale=math.sqrt(tau2)) pr_hi = sp_norm.ppf(0.999, loc=mu0, scale=math.sqrt(tau2)) po_lo = sp_norm.ppf(0.001, loc=mu_post, scale=math.sqrt(tau2_post)) po_hi = sp_norm.ppf(0.999, loc=mu_post, scale=math.sqrt(tau2_post)) mu_pr = np.linspace(pr_lo, pr_hi, 1000) mu_po = np.linspace(po_lo, po_hi, 1000) fig = make_subplots(rows=1, cols=2, subplot_titles=[“Prior”, “Posterior”]) fig.add_trace(go.Scatter( x=mu_pr, y=sp_norm.pdf(mu_pr, loc=mu0, scale=math.sqrt(tau2)), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=1) fig.add_trace(go.Scatter( x=mu_po, y=sp_norm.pdf(mu_po, loc=mu_post, scale=math.sqrt(tau2_post)), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=2) fig.update_xaxes(title_text=”μ”, row=1, col=1) fig.update_xaxes(title_text=”μ”, row=1, col=2) fig.update_yaxes(title_text=”Density”, row=1, col=1) fig.update_layout( title_text=( f”Normal-Normal (Mean) [{e[‘label’]}]
” f”Prior: N({mu0:.4f}, {tau2:.4f}) | ” f”Posterior: N({mu_post:.4f}, {tau2_post:.4f})” ), template=”plotly_white”, ) default_name = f”normal_normal_{e[‘label’].replace(‘ ‘, ‘_’)}” fpath = _resolve_plot_path(plot_path, plot_filename, default_name) _save_plot(fig, fpath) results.append({ “label”: e[“label”], “data”: {“n”: nv, “xbar”: xbar, “sigma2”: sigma2}, “prior”: {“mu0”: mu0, “tau2”: tau2, “mean”: mu0, “median”: pr_median, “mode”: pr_mode, “sd”: math.sqrt(tau2)}, “posterior”: {“mu”: mu_post, “tau2”: tau2_post, “mean”: mu_post, “median”: po_median, “mode”: po_mode, “sd”: math.sqrt(tau2_post), “quantiles”: dict(zip(all_q_int, q_vals))}, “plot”: fig, }) return results[0] if len(results) == 1 else results # ============================================================= # USAGE EXAMPLES # ============================================================= if __name__ == “__main__”: # scalar / list # res = conjugate_normal_normal(x=[2.1,1.9,2.3,2.0], mu0=2, tau2=1, # sigma2=0.5, cri_levels=(90, 95)) # df + var + group # df = pd.DataFrame({“measure”: [2.1,1.9,2.3,2.0,2.5,2.2,1.8,2.4], # “batch”: [“B1”]*4 + [“B2″]*4}) # res = conjugate_normal_normal(data=df, var=”measure”, group_var=”batch”, # mu0=2, tau2=1, sigma2=0.5, # cri_levels=(90, 95), quantiles=(0.10, 0.75)) res = conjugate_normal_normal( x=[2.1, 1.9, 2.3, 2.0], mu0=2, tau2=1, sigma2=0.5, cri_levels=(90, 95), plot_path=”.”, plot_filename=”normal_normal_result” )

# =============================================================
# Bayesian Conjugate Model - Normal-Normal (Mean, variance known)
# Closed-form analytical solution (standalone script)
#
# Likelihood : X_i ~ N(mu, sigma2)   sigma2 known
# Prior      : mu ~ N(mu0, tau2)
# Posterior  : mu | x ~ N(mu_post, tau2_post)
#   tau2_post = 1 / (1/tau2 + n/sigma2)
#   mu_post   = tau2_post * (mu0/tau2 + n*xbar/sigma2)
#
# Input  : scalar / list / vector  OR  DataFrame + var + group_var
# Output : clean console tables, Plotly HTML saved to disk
# Plots  : Plotly only — saved as HTML, never auto-rendered
# =============================================================

import os
import math
import numpy as np
import pandas as pd
from scipy.stats import norm as sp_norm
import plotly.graph_objects as go
from plotly.subplots import make_subplots

# =============================================================
# INTERNAL UTILITIES
# =============================================================

def _print_table(title: str, df: pd.DataFrame) -> None:
    """Plain text table to console — no color, no clutter."""
    col_names  = list(df.columns)
    col_widths = [
        max(len(nm), df[nm].astype(str).map(len).max())
        for nm in col_names
    ]
    col_widths = [max(w, 10) for w in col_widths]

    total_width = sum(col_widths) + 3 * (len(col_widths) - 1) + 2
    divider     = "=" * total_width
    thin_div    = "-" * total_width

    header = "   ".join(nm.ljust(w) for nm, w in zip(col_names, col_widths))

    lines = []
    lines.append(divider)
    lines.append(f"  {title}")
    lines.append(divider)
    lines.append(f"  {header}")
    lines.append(f"  {thin_div}")
    for _, row in df.iterrows():
        row_str = "   ".join(str(val).ljust(w) for val, w in zip(row, col_widths))
        lines.append(f"  {row_str}")
    lines.append(divider)
    lines.append("")
    print("\n".join(lines))

def _expand_cri(cri_levels, quantiles):
    """Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles."""
    q_all = sorted(set(quantiles)) if quantiles else []
    if cri_levels:
        for lev in sorted(set(cri_levels)):
            lo = (1 - lev / 100) / 2
            hi = 1 - lo
            q_all = sorted(set(q_all + [lo, hi]))
    return q_all

def _build_cri_table(cri_levels, all_q, q_vals, cri_type):
    """Build CrI table from cri_levels only — symmetric pairs."""
    if not cri_levels:
        return None
    rows = []
    for lev in sorted(set(cri_levels)):
        lo = (1 - lev / 100) / 2
        hi = 1 - lo
        idx_lo = next((i for i, q in enumerate(all_q) if abs(q - lo) < 1e-10), None)
        idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None)
        if idx_lo is not None and idx_hi is not None:
            rows.append({
                "Level": f"{lev}% {cri_type}",
                "Lower": f"{q_vals[idx_lo]:.6f}",
                "Upper": f"{q_vals[idx_hi]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None

def _build_quantile_table(quantiles, all_q, q_vals):
    """Build individual quantile table from quantiles arg only."""
    if not quantiles:
        return None
    rows = []
    for q in sorted(set(quantiles)):
        idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None)
        if idx is not None:
            rows.append({
                "Quantile": f"Q({q:.4f})",
                "Value":    f"{q_vals[idx]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None

def _validate_df_var(data: pd.DataFrame, var: str) -> None:
    """Validate DataFrame and column for the Normal-Normal model."""
    if not isinstance(data, pd.DataFrame):
        raise TypeError("`data` must be a pandas DataFrame")
    if not isinstance(var, str):
        raise TypeError("`var` must be a single column name (string)")
    if var not in data.columns:
        raise ValueError(f"Column '{var}' not found in data")

    col = data[var].dropna()
    if col.empty:
        raise ValueError(f"Column '{var}' is entirely NA")
    if not pd.api.types.is_numeric_dtype(col):
        raise TypeError(f"Column '{var}' must be numeric for Normal conjugate models")

def _validate_group(data: pd.DataFrame, group_var) -> None:
    if group_var is not None and group_var not in data.columns:
        raise ValueError(f"group_var column '{group_var}' not found in data")

def _split_entries(data: pd.DataFrame, var: str, group_var):
    """Split data into labelled entries by group."""
    if group_var is not None:
        entries = []
        for g in data[group_var].unique():
            sub = data[data[group_var] == g]
            entries.append({
                "label": f"{group_var} = {g}",
                "col":   sub[var].dropna().values
            })
        return entries
    else:
        return [{"label": "All", "col": data[var].dropna().values}]

def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str:
    """Resolve final HTML save path."""
    directory = plot_path if plot_path is not None else os.getcwd()
    filename  = (plot_filename if plot_filename is not None else default_name)
    if not filename.endswith(".html"):
        filename += ".html"
    return os.path.join(directory, filename)

def _save_plot(fig, path: str) -> None:
    fig.write_html(path, auto_open=False)
    print(f"  [Plot saved] {path}\n")

# =============================================================
# NORMAL - NORMAL  (estimating MEAN | variance known)
# =============================================================

def conjugate_normal_normal(
    data          = None,
    var           = None,
    group_var     = None,
    x             = None,
    mu0           = None,
    tau2          = None,
    sigma2        = None,
    cri_levels    = (90, 95),
    quantiles     = None,
    cri_type      = "CrI",
    plot_path     = None,
    plot_filename = None,
):
    cri_levels = list(cri_levels) if cri_levels else []
    quantiles  = list(quantiles)  if quantiles  else []
    all_q      = _expand_cri(cri_levels, quantiles)

    if data is not None:
        _validate_df_var(data, var)
        _validate_group(data, group_var)
        entries_raw = _split_entries(data, var, group_var)
        entries = [{"label": e["label"], "x": e["col"]} for e in entries_raw]
    else:
        if x is None:
            raise ValueError("Provide `data` + `var`  OR  `x`")
        entries = [{"label": "All", "x": np.atleast_1d(np.asarray(x, dtype=float))}]

    results = []
    for e in entries:
        xv   = np.asarray(e["x"], dtype=float)
        nv   = len(xv)
        xbar = float(np.mean(xv))

        tau2_post = 1 / (1/tau2 + nv/sigma2)
        mu_post   = tau2_post * (mu0/tau2 + nv*xbar/sigma2)

        # Normal is symmetric: mean = median = mode
        pr_median = mu0
        pr_mode   = mu0
        po_median = mu_post
        po_mode   = mu_post

        all_q_int = sorted(set(all_q + [0.5]))
        q_vals    = [sp_norm.ppf(q, loc=mu_post, scale=math.sqrt(tau2_post)) for q in all_q_int]

        _print_table(
            f"Normal-Normal  |  {e['label']}  |  Data Summary",
            pd.DataFrame([{
                "n":              nv,
                "xbar":           f"{xbar:.6f}",
                "sigma2 (known)": f"{sigma2:.6f}",
            }])
        )

        _print_table(
            f"Normal-Normal  |  {e['label']}  |  Parameters & Moments",
            pd.DataFrame({
                "Quantity":  ["mu (mean=median=mode)", "tau2 (variance)", "SD"],
                "Prior":     [f"{mu0:.6f}",     f"{tau2:.6f}",      f"{math.sqrt(tau2):.6f}"],
                "Posterior": [f"{mu_post:.6f}", f"{tau2_post:.6f}", f"{math.sqrt(tau2_post):.6f}"],
            })
        )

        cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type)
        if cri_df is not None:
            _print_table(f"Normal-Normal  |  {e['label']}  |  Credible Intervals ({cri_type})", cri_df)

        q_df = _build_quantile_table(quantiles, all_q_int, q_vals)
        if q_df is not None:
            _print_table(f"Normal-Normal  |  {e['label']}  |  Posterior Quantiles", q_df)

        # --- Plot ---
        pr_lo = sp_norm.ppf(0.001, loc=mu0,     scale=math.sqrt(tau2))
        pr_hi = sp_norm.ppf(0.999, loc=mu0,     scale=math.sqrt(tau2))
        po_lo = sp_norm.ppf(0.001, loc=mu_post, scale=math.sqrt(tau2_post))
        po_hi = sp_norm.ppf(0.999, loc=mu_post, scale=math.sqrt(tau2_post))

        mu_pr = np.linspace(pr_lo, pr_hi, 1000)
        mu_po = np.linspace(po_lo, po_hi, 1000)

        fig = make_subplots(rows=1, cols=2, subplot_titles=["Prior", "Posterior"])
        fig.add_trace(go.Scatter(
            x=mu_pr, y=sp_norm.pdf(mu_pr, loc=mu0,     scale=math.sqrt(tau2)),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=1)
        fig.add_trace(go.Scatter(
            x=mu_po, y=sp_norm.pdf(mu_po, loc=mu_post, scale=math.sqrt(tau2_post)),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=2)
        fig.update_xaxes(title_text="μ", row=1, col=1)
        fig.update_xaxes(title_text="μ", row=1, col=2)
        fig.update_yaxes(title_text="Density", row=1, col=1)
        fig.update_layout(
            title_text=(
                f"Normal-Normal (Mean)  [{e['label']}]<br>"
                f"<sup>Prior: N({mu0:.4f}, {tau2:.4f})  |  "
                f"Posterior: N({mu_post:.4f}, {tau2_post:.4f})</sup>"
            ),
            template="plotly_white",
        )

        default_name = f"normal_normal_{e['label'].replace(' ', '_')}"
        fpath = _resolve_plot_path(plot_path, plot_filename, default_name)
        _save_plot(fig, fpath)

        results.append({
            "label":     e["label"],
            "data":      {"n": nv, "xbar": xbar, "sigma2": sigma2},
            "prior":     {"mu0": mu0, "tau2": tau2,
                          "mean": mu0, "median": pr_median, "mode": pr_mode,
                          "sd": math.sqrt(tau2)},
            "posterior": {"mu": mu_post, "tau2": tau2_post,
                          "mean": mu_post, "median": po_median, "mode": po_mode,
                          "sd": math.sqrt(tau2_post),
                          "quantiles": dict(zip(all_q_int, q_vals))},
            "plot":      fig,
        })

    return results[0] if len(results) == 1 else results

# =============================================================
# USAGE EXAMPLES
# =============================================================
if __name__ == "__main__":
    # scalar / list
    # res = conjugate_normal_normal(x=[2.1,1.9,2.3,2.0], mu0=2, tau2=1,
    #                               sigma2=0.5, cri_levels=(90, 95))

    # df + var + group
    # df = pd.DataFrame({"measure": [2.1,1.9,2.3,2.0,2.5,2.2,1.8,2.4],
    #                    "batch":   ["B1"]*4 + ["B2"]*4})
    # res = conjugate_normal_normal(data=df, var="measure", group_var="batch",
    #                               mu0=2, tau2=1, sigma2=0.5,
    #                               cri_levels=(90, 95), quantiles=(0.10, 0.75))

    res = conjugate_normal_normal(
        x=[2.1, 1.9, 2.3, 2.0], mu0=2, tau2=1, sigma2=0.5,
        cri_levels=(90, 95),
        plot_path=".", plot_filename="normal_normal_result"
    )
Show code

# ============================================================= # Bayesian Conjugate Model – Normal-Gamma (Precision, mean known) # Closed-form analytical solution (standalone script) # # Likelihood : X_i ~ N(mu_known, 1/phi) phi = precision # Prior : phi ~ Gamma(alpha, rate = beta) # Posterior : phi | x ~ Gamma(alpha + n/2, rate = beta + SS/2) # SS = sum((x – mu_known)^2) # # Input : scalar / list / vector OR DataFrame + var + group_var # Output : clean console tables, Plotly HTML saved to disk # Plots : Plotly only — saved as HTML, never auto-rendered # ============================================================= import os import math import numpy as np import pandas as pd from scipy.stats import gamma as sp_gamma import plotly.graph_objects as go from plotly.subplots import make_subplots # ============================================================= # INTERNAL UTILITIES # ============================================================= def _print_table(title: str, df: pd.DataFrame) -> None: “””Plain text table to console — no color, no clutter.””” col_names = list(df.columns) col_widths = [ max(len(nm), df[nm].astype(str).map(len).max()) for nm in col_names ] col_widths = [max(w, 10) for w in col_widths] total_width = sum(col_widths) + 3 * (len(col_widths) – 1) + 2 divider = “=” * total_width thin_div = “-” * total_width header = ” “.join(nm.ljust(w) for nm, w in zip(col_names, col_widths)) lines = [] lines.append(divider) lines.append(f” {title}”) lines.append(divider) lines.append(f” {header}”) lines.append(f” {thin_div}”) for _, row in df.iterrows(): row_str = ” “.join(str(val).ljust(w) for val, w in zip(row, col_widths)) lines.append(f” {row_str}”) lines.append(divider) lines.append(“”) print(“\n”.join(lines)) def _fmt_mode(v) -> str: return “undefined” if v is None else f”{v:.6f}” def _expand_cri(cri_levels, quantiles): “””Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles.””” q_all = sorted(set(quantiles)) if quantiles else [] if cri_levels: for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo q_all = sorted(set(q_all + [lo, hi])) return q_all def _build_cri_table(cri_levels, all_q, q_vals, cri_type): “””Build CrI table from cri_levels only — symmetric pairs.””” if not cri_levels: return None rows = [] for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo idx_lo = next((i for i, q in enumerate(all_q) if abs(q – lo) < 1e-10), None) idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None) if idx_lo is not None and idx_hi is not None: rows.append({ "Level": f"{lev}% {cri_type}", "Lower": f"{q_vals[idx_lo]:.6f}", "Upper": f"{q_vals[idx_hi]:.6f}", }) return pd.DataFrame(rows) if rows else None def _build_quantile_table(quantiles, all_q, q_vals): """Build individual quantile table from quantiles arg only.""" if not quantiles: return None rows = [] for q in sorted(set(quantiles)): idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None) if idx is not None: rows.append({ "Quantile": f"Q({q:.4f})", "Value": f"{q_vals[idx]:.6f}", }) return pd.DataFrame(rows) if rows else None def _validate_df_var(data: pd.DataFrame, var: str) -> None: “””Validate DataFrame and column for the Normal-Gamma model.””” if not isinstance(data, pd.DataFrame): raise TypeError(“`data` must be a pandas DataFrame”) if not isinstance(var, str): raise TypeError(“`var` must be a single column name (string)”) if var not in data.columns: raise ValueError(f”Column ‘{var}’ not found in data”) col = data[var].dropna() if col.empty: raise ValueError(f”Column ‘{var}’ is entirely NA”) if not pd.api.types.is_numeric_dtype(col): raise TypeError(f”Column ‘{var}’ must be numeric for Normal conjugate models”) def _validate_group(data: pd.DataFrame, group_var) -> None: if group_var is not None and group_var not in data.columns: raise ValueError(f”group_var column ‘{group_var}’ not found in data”) def _split_entries(data: pd.DataFrame, var: str, group_var): “””Split data into labelled entries by group.””” if group_var is not None: entries = [] for g in data[group_var].unique(): sub = data[data[group_var] == g] entries.append({ “label”: f”{group_var} = {g}”, “col”: sub[var].dropna().values }) return entries else: return [{“label”: “All”, “col”: data[var].dropna().values}] def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str: “””Resolve final HTML save path.””” directory = plot_path if plot_path is not None else os.getcwd() filename = (plot_filename if plot_filename is not None else default_name) if not filename.endswith(“.html”): filename += “.html” return os.path.join(directory, filename) def _save_plot(fig, path: str) -> None: fig.write_html(path, auto_open=False) print(f” [Plot saved] {path}\n”) # ============================================================= # NORMAL – GAMMA (estimating PRECISION | mean known) # ============================================================= def conjugate_normal_gamma( data = None, var = None, group_var = None, x = None, mu_known = None, alpha = None, beta = None, cri_levels = (90, 95), quantiles = None, cri_type = “CrI”, plot_path = None, plot_filename = None, ): cri_levels = list(cri_levels) if cri_levels else [] quantiles = list(quantiles) if quantiles else [] all_q = _expand_cri(cri_levels, quantiles) if data is not None: _validate_df_var(data, var) _validate_group(data, group_var) entries_raw = _split_entries(data, var, group_var) entries = [{“label”: e[“label”], “x”: e[“col”]} for e in entries_raw] else: if x is None: raise ValueError(“Provide `data` + `var` OR `x`”) entries = [{“label”: “All”, “x”: np.atleast_1d(np.asarray(x, dtype=float))}] results = [] for e in entries: xv = np.asarray(e[“x”], dtype=float) nv = len(xv) ss = float(np.sum((xv – mu_known)**2)) a_post = alpha + nv / 2 b_post = beta + ss / 2 pr_mean = alpha / beta pr_var = alpha / beta**2 pr_mode = (alpha – 1) / beta if alpha >= 1 else None pr_median = sp_gamma.ppf(0.5, a=alpha, scale=1/beta) po_mean = a_post / b_post po_var = a_post / b_post**2 po_mode = (a_post – 1) / b_post if a_post >= 1 else None po_median = sp_gamma.ppf(0.5, a=a_post, scale=1/b_post) all_q_int = sorted(set(all_q + [0.5])) q_vals = [sp_gamma.ppf(q, a=a_post, scale=1/b_post) for q in all_q_int] _print_table( f”Normal-Gamma | {e[‘label’]} | Data Summary”, pd.DataFrame([{ “n”: nv, “mu_known”: f”{mu_known:.6f}”, “SS = sum((x-mu)^2)”: f”{ss:.6f}”, }]) ) _print_table( f”Normal-Gamma | {e[‘label’]} | Parameters & Moments (Precision)”, pd.DataFrame({ “Quantity”: [“alpha (shape)”, “beta (rate)”, “Mean”, “Median”, “Mode”, “Variance”, “SD”], “Prior”: [f”{alpha:.6f}”, f”{beta:.6f}”, f”{pr_mean:.6f}”, f”{pr_median:.6f}”, _fmt_mode(pr_mode), f”{pr_var:.6f}”, f”{math.sqrt(pr_var):.6f}”], “Posterior”: [f”{a_post:.6f}”, f”{b_post:.6f}”, f”{po_mean:.6f}”, f”{po_median:.6f}”, _fmt_mode(po_mode), f”{po_var:.6f}”, f”{math.sqrt(po_var):.6f}”], }) ) cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type) if cri_df is not None: _print_table( f”Normal-Gamma | {e[‘label’]} | Credible Intervals ({cri_type}) [precision scale]”, cri_df ) q_df = _build_quantile_table(quantiles, all_q_int, q_vals) if q_df is not None: _print_table( f”Normal-Gamma | {e[‘label’]} | Posterior Quantiles [precision scale]”, q_df ) # — Plot — pr_lo = max(1e-6, sp_gamma.ppf(0.001, a=alpha, scale=1/beta)) pr_hi = sp_gamma.ppf(0.999, a=alpha, scale=1/beta) po_lo = max(1e-6, sp_gamma.ppf(0.001, a=a_post, scale=1/b_post)) po_hi = sp_gamma.ppf(0.999, a=a_post, scale=1/b_post) phi_pr = np.linspace(pr_lo, pr_hi, 1000) phi_po = np.linspace(po_lo, po_hi, 1000) fig = make_subplots(rows=1, cols=2, subplot_titles=[“Prior”, “Posterior”]) fig.add_trace(go.Scatter( x=phi_pr, y=sp_gamma.pdf(phi_pr, a=alpha, scale=1/beta), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=1) fig.add_trace(go.Scatter( x=phi_po, y=sp_gamma.pdf(phi_po, a=a_post, scale=1/b_post), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=2) fig.update_xaxes(title_text=”φ (precision)”, row=1, col=1) fig.update_xaxes(title_text=”φ (precision)”, row=1, col=2) fig.update_yaxes(title_text=”Density”, row=1, col=1) fig.update_layout( title_text=( f”Normal-Gamma (Precision) [{e[‘label’]}]
” f”Prior: Gamma({alpha:.2f}, rate={beta:.2f}) | ” f”Posterior: Gamma({a_post:.2f}, rate={b_post:.2f})” ), template=”plotly_white”, ) default_name = f”normal_gamma_{e[‘label’].replace(‘ ‘, ‘_’)}” fpath = _resolve_plot_path(plot_path, plot_filename, default_name) _save_plot(fig, fpath) results.append({ “label”: e[“label”], “data”: {“n”: nv, “mu_known”: mu_known, “ss”: ss}, “prior”: {“alpha”: alpha, “beta”: beta, “mean”: pr_mean, “median”: pr_median, “mode”: pr_mode, “var”: pr_var, “sd”: math.sqrt(pr_var)}, “posterior”: {“alpha”: a_post, “beta”: b_post, “mean”: po_mean, “median”: po_median, “mode”: po_mode, “var”: po_var, “sd”: math.sqrt(po_var), “quantiles”: dict(zip(all_q_int, q_vals))}, “plot”: fig, }) return results[0] if len(results) == 1 else results # ============================================================= # USAGE EXAMPLES # ============================================================= if __name__ == “__main__”: # scalar / list # res = conjugate_normal_gamma(x=[2.1,1.9,2.3,2.0], mu_known=2, # alpha=2, beta=1, cri_levels=(90, 95)) # df + var # df = pd.DataFrame({“measure”: [2.1,1.9,2.3,2.0,2.5,2.2,1.8,2.4]}) # res = conjugate_normal_gamma(data=df, var=”measure”, mu_known=2, # alpha=2, beta=1, cri_levels=(90, 95)) res = conjugate_normal_gamma( x=[2.1, 1.9, 2.3, 2.0], mu_known=2, alpha=2, beta=1, cri_levels=(90, 95), plot_path=”.”, plot_filename=”normal_gamma_result” )

# =============================================================
# Bayesian Conjugate Model - Normal-Gamma (Precision, mean known)
# Closed-form analytical solution (standalone script)
#
# Likelihood : X_i ~ N(mu_known, 1/phi)   phi = precision
# Prior      : phi ~ Gamma(alpha, rate = beta)
# Posterior  : phi | x ~ Gamma(alpha + n/2, rate = beta + SS/2)
#              SS = sum((x - mu_known)^2)
#
# Input  : scalar / list / vector  OR  DataFrame + var + group_var
# Output : clean console tables, Plotly HTML saved to disk
# Plots  : Plotly only — saved as HTML, never auto-rendered
# =============================================================

import os
import math
import numpy as np
import pandas as pd
from scipy.stats import gamma as sp_gamma
import plotly.graph_objects as go
from plotly.subplots import make_subplots


# =============================================================
# INTERNAL UTILITIES
# =============================================================

def _print_table(title: str, df: pd.DataFrame) -> None:
    """Plain text table to console — no color, no clutter."""
    col_names  = list(df.columns)
    col_widths = [
        max(len(nm), df[nm].astype(str).map(len).max())
        for nm in col_names
    ]
    col_widths = [max(w, 10) for w in col_widths]

    total_width = sum(col_widths) + 3 * (len(col_widths) - 1) + 2
    divider     = "=" * total_width
    thin_div    = "-" * total_width

    header = "   ".join(nm.ljust(w) for nm, w in zip(col_names, col_widths))

    lines = []
    lines.append(divider)
    lines.append(f"  {title}")
    lines.append(divider)
    lines.append(f"  {header}")
    lines.append(f"  {thin_div}")
    for _, row in df.iterrows():
        row_str = "   ".join(str(val).ljust(w) for val, w in zip(row, col_widths))
        lines.append(f"  {row_str}")
    lines.append(divider)
    lines.append("")
    print("\n".join(lines))


def _fmt_mode(v) -> str:
    return "undefined" if v is None else f"{v:.6f}"


def _expand_cri(cri_levels, quantiles):
    """Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles."""
    q_all = sorted(set(quantiles)) if quantiles else []
    if cri_levels:
        for lev in sorted(set(cri_levels)):
            lo = (1 - lev / 100) / 2
            hi = 1 - lo
            q_all = sorted(set(q_all + [lo, hi]))
    return q_all


def _build_cri_table(cri_levels, all_q, q_vals, cri_type):
    """Build CrI table from cri_levels only — symmetric pairs."""
    if not cri_levels:
        return None
    rows = []
    for lev in sorted(set(cri_levels)):
        lo = (1 - lev / 100) / 2
        hi = 1 - lo
        idx_lo = next((i for i, q in enumerate(all_q) if abs(q - lo) < 1e-10), None)
        idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None)
        if idx_lo is not None and idx_hi is not None:
            rows.append({
                "Level": f"{lev}% {cri_type}",
                "Lower": f"{q_vals[idx_lo]:.6f}",
                "Upper": f"{q_vals[idx_hi]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _build_quantile_table(quantiles, all_q, q_vals):
    """Build individual quantile table from quantiles arg only."""
    if not quantiles:
        return None
    rows = []
    for q in sorted(set(quantiles)):
        idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None)
        if idx is not None:
            rows.append({
                "Quantile": f"Q({q:.4f})",
                "Value":    f"{q_vals[idx]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _validate_df_var(data: pd.DataFrame, var: str) -> None:
    """Validate DataFrame and column for the Normal-Gamma model."""
    if not isinstance(data, pd.DataFrame):
        raise TypeError("`data` must be a pandas DataFrame")
    if not isinstance(var, str):
        raise TypeError("`var` must be a single column name (string)")
    if var not in data.columns:
        raise ValueError(f"Column '{var}' not found in data")

    col = data[var].dropna()
    if col.empty:
        raise ValueError(f"Column '{var}' is entirely NA")
    if not pd.api.types.is_numeric_dtype(col):
        raise TypeError(f"Column '{var}' must be numeric for Normal conjugate models")


def _validate_group(data: pd.DataFrame, group_var) -> None:
    if group_var is not None and group_var not in data.columns:
        raise ValueError(f"group_var column '{group_var}' not found in data")


def _split_entries(data: pd.DataFrame, var: str, group_var):
    """Split data into labelled entries by group."""
    if group_var is not None:
        entries = []
        for g in data[group_var].unique():
            sub = data[data[group_var] == g]
            entries.append({
                "label": f"{group_var} = {g}",
                "col":   sub[var].dropna().values
            })
        return entries
    else:
        return [{"label": "All", "col": data[var].dropna().values}]


def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str:
    """Resolve final HTML save path."""
    directory = plot_path if plot_path is not None else os.getcwd()
    filename  = (plot_filename if plot_filename is not None else default_name)
    if not filename.endswith(".html"):
        filename += ".html"
    return os.path.join(directory, filename)


def _save_plot(fig, path: str) -> None:
    fig.write_html(path, auto_open=False)
    print(f"  [Plot saved] {path}\n")


# =============================================================
# NORMAL - GAMMA  (estimating PRECISION | mean known)
# =============================================================

def conjugate_normal_gamma(
    data          = None,
    var           = None,
    group_var     = None,
    x             = None,
    mu_known      = None,
    alpha         = None,
    beta          = None,
    cri_levels    = (90, 95),
    quantiles     = None,
    cri_type      = "CrI",
    plot_path     = None,
    plot_filename = None,
):
    cri_levels = list(cri_levels) if cri_levels else []
    quantiles  = list(quantiles)  if quantiles  else []
    all_q      = _expand_cri(cri_levels, quantiles)

    if data is not None:
        _validate_df_var(data, var)
        _validate_group(data, group_var)
        entries_raw = _split_entries(data, var, group_var)
        entries = [{"label": e["label"], "x": e["col"]} for e in entries_raw]
    else:
        if x is None:
            raise ValueError("Provide `data` + `var`  OR  `x`")
        entries = [{"label": "All", "x": np.atleast_1d(np.asarray(x, dtype=float))}]

    results = []
    for e in entries:
        xv = np.asarray(e["x"], dtype=float)
        nv = len(xv)
        ss = float(np.sum((xv - mu_known)**2))

        a_post = alpha + nv / 2
        b_post = beta  + ss  / 2

        pr_mean   = alpha / beta
        pr_var    = alpha / beta**2
        pr_mode   = (alpha - 1) / beta if alpha >= 1 else None
        pr_median = sp_gamma.ppf(0.5, a=alpha, scale=1/beta)

        po_mean   = a_post / b_post
        po_var    = a_post / b_post**2
        po_mode   = (a_post - 1) / b_post if a_post >= 1 else None
        po_median = sp_gamma.ppf(0.5, a=a_post, scale=1/b_post)

        all_q_int = sorted(set(all_q + [0.5]))
        q_vals    = [sp_gamma.ppf(q, a=a_post, scale=1/b_post) for q in all_q_int]

        _print_table(
            f"Normal-Gamma  |  {e['label']}  |  Data Summary",
            pd.DataFrame([{
                "n":                  nv,
                "mu_known":           f"{mu_known:.6f}",
                "SS = sum((x-mu)^2)": f"{ss:.6f}",
            }])
        )

        _print_table(
            f"Normal-Gamma  |  {e['label']}  |  Parameters & Moments (Precision)",
            pd.DataFrame({
                "Quantity":  ["alpha (shape)", "beta (rate)", "Mean", "Median", "Mode", "Variance", "SD"],
                "Prior":     [f"{alpha:.6f}", f"{beta:.6f}", f"{pr_mean:.6f}",
                              f"{pr_median:.6f}", _fmt_mode(pr_mode),
                              f"{pr_var:.6f}", f"{math.sqrt(pr_var):.6f}"],
                "Posterior": [f"{a_post:.6f}", f"{b_post:.6f}", f"{po_mean:.6f}",
                              f"{po_median:.6f}", _fmt_mode(po_mode),
                              f"{po_var:.6f}", f"{math.sqrt(po_var):.6f}"],
            })
        )

        cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type)
        if cri_df is not None:
            _print_table(
                f"Normal-Gamma  |  {e['label']}  |  Credible Intervals ({cri_type}) [precision scale]",
                cri_df
            )

        q_df = _build_quantile_table(quantiles, all_q_int, q_vals)
        if q_df is not None:
            _print_table(
                f"Normal-Gamma  |  {e['label']}  |  Posterior Quantiles [precision scale]",
                q_df
            )

        # --- Plot ---
        pr_lo  = max(1e-6, sp_gamma.ppf(0.001, a=alpha,  scale=1/beta))
        pr_hi  = sp_gamma.ppf(0.999, a=alpha,  scale=1/beta)
        po_lo  = max(1e-6, sp_gamma.ppf(0.001, a=a_post, scale=1/b_post))
        po_hi  = sp_gamma.ppf(0.999, a=a_post, scale=1/b_post)

        phi_pr = np.linspace(pr_lo, pr_hi, 1000)
        phi_po = np.linspace(po_lo, po_hi, 1000)

        fig = make_subplots(rows=1, cols=2, subplot_titles=["Prior", "Posterior"])
        fig.add_trace(go.Scatter(
            x=phi_pr, y=sp_gamma.pdf(phi_pr, a=alpha,  scale=1/beta),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=1)
        fig.add_trace(go.Scatter(
            x=phi_po, y=sp_gamma.pdf(phi_po, a=a_post, scale=1/b_post),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=2)
        fig.update_xaxes(title_text="φ (precision)", row=1, col=1)
        fig.update_xaxes(title_text="φ (precision)", row=1, col=2)
        fig.update_yaxes(title_text="Density", row=1, col=1)
        fig.update_layout(
            title_text=(
                f"Normal-Gamma (Precision)  [{e['label']}]<br>"
                f"<sup>Prior: Gamma({alpha:.2f}, rate={beta:.2f})  |  "
                f"Posterior: Gamma({a_post:.2f}, rate={b_post:.2f})</sup>"
            ),
            template="plotly_white",
        )

        default_name = f"normal_gamma_{e['label'].replace(' ', '_')}"
        fpath = _resolve_plot_path(plot_path, plot_filename, default_name)
        _save_plot(fig, fpath)

        results.append({
            "label":     e["label"],
            "data":      {"n": nv, "mu_known": mu_known, "ss": ss},
            "prior":     {"alpha": alpha, "beta": beta,
                          "mean": pr_mean, "median": pr_median, "mode": pr_mode,
                          "var": pr_var, "sd": math.sqrt(pr_var)},
            "posterior": {"alpha": a_post, "beta": b_post,
                          "mean": po_mean, "median": po_median, "mode": po_mode,
                          "var": po_var, "sd": math.sqrt(po_var),
                          "quantiles": dict(zip(all_q_int, q_vals))},
            "plot":      fig,
        })

    return results[0] if len(results) == 1 else results


# =============================================================
# USAGE EXAMPLES
# =============================================================
if __name__ == "__main__":
    # scalar / list
    # res = conjugate_normal_gamma(x=[2.1,1.9,2.3,2.0], mu_known=2,
    #                              alpha=2, beta=1, cri_levels=(90, 95))

    # df + var
    # df = pd.DataFrame({"measure": [2.1,1.9,2.3,2.0,2.5,2.2,1.8,2.4]})
    # res = conjugate_normal_gamma(data=df, var="measure", mu_known=2,
    #                              alpha=2, beta=1, cri_levels=(90, 95))

    res = conjugate_normal_gamma(
        x=[2.1, 1.9, 2.3, 2.0], mu_known=2, alpha=2, beta=1,
        cri_levels=(90, 95),
        plot_path=".", plot_filename="normal_gamma_result"
    )
Show code

# ============================================================= # Bayesian Conjugate Model – Normal-Inverse-Gamma (Variance, mean known) # Closed-form analytical solution (standalone script) # # Likelihood : X_i ~ N(mu_known, sigma2) # Prior : sigma2 ~ InvGamma(alpha, scale = beta) # Posterior : sigma2 | x ~ InvGamma(alpha + n/2, scale = beta + SS/2) # # InvGamma(a, b) moments: # Mean = b / (a – 1) for a > 1 # Var = b^2 / ((a-1)^2 * (a-2)) for a > 2 # # Quantile: 1 / qgamma(1 – p, shape = a, rate = b) # = 1 / Gamma(a, scale=1/b).ppf(1 – p) # # Input : scalar / list / vector OR DataFrame + var + group_var # Output : clean console tables, Plotly HTML saved to disk # Plots : Plotly only — saved as HTML, never auto-rendered # ============================================================= import os import math import numpy as np import pandas as pd from scipy.stats import gamma as sp_gamma import plotly.graph_objects as go from plotly.subplots import make_subplots # ============================================================= # INTERNAL UTILITIES # ============================================================= def _print_table(title: str, df: pd.DataFrame) -> None: “””Plain text table to console — no color, no clutter.””” col_names = list(df.columns) col_widths = [ max(len(nm), df[nm].astype(str).map(len).max()) for nm in col_names ] col_widths = [max(w, 10) for w in col_widths] total_width = sum(col_widths) + 3 * (len(col_widths) – 1) + 2 divider = “=” * total_width thin_div = “-” * total_width header = ” “.join(nm.ljust(w) for nm, w in zip(col_names, col_widths)) lines = [] lines.append(divider) lines.append(f” {title}”) lines.append(divider) lines.append(f” {header}”) lines.append(f” {thin_div}”) for _, row in df.iterrows(): row_str = ” “.join(str(val).ljust(w) for val, w in zip(row, col_widths)) lines.append(f” {row_str}”) lines.append(divider) lines.append(“”) print(“\n”.join(lines)) def _fmt(v, digits: int = 6) -> str: “””Format float; return ‘undefined’ for None/NaN.””” if v is None or (isinstance(v, float) and math.isnan(v)): return “undefined” return f”{v:.{digits}f}” def _expand_cri(cri_levels, quantiles): “””Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles.””” q_all = sorted(set(quantiles)) if quantiles else [] if cri_levels: for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo q_all = sorted(set(q_all + [lo, hi])) return q_all def _build_cri_table(cri_levels, all_q, q_vals, cri_type): “””Build CrI table from cri_levels only — symmetric pairs.””” if not cri_levels: return None rows = [] for lev in sorted(set(cri_levels)): lo = (1 – lev / 100) / 2 hi = 1 – lo idx_lo = next((i for i, q in enumerate(all_q) if abs(q – lo) < 1e-10), None) idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None) if idx_lo is not None and idx_hi is not None: rows.append({ "Level": f"{lev}% {cri_type}", "Lower": f"{q_vals[idx_lo]:.6f}", "Upper": f"{q_vals[idx_hi]:.6f}", }) return pd.DataFrame(rows) if rows else None def _build_quantile_table(quantiles, all_q, q_vals): """Build individual quantile table from quantiles arg only.""" if not quantiles: return None rows = [] for q in sorted(set(quantiles)): idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None) if idx is not None: rows.append({ "Quantile": f"Q({q:.4f})", "Value": f"{q_vals[idx]:.6f}", }) return pd.DataFrame(rows) if rows else None def _validate_df_var(data: pd.DataFrame, var: str) -> None: “””Validate DataFrame and column for the Normal-InvGamma model.””” if not isinstance(data, pd.DataFrame): raise TypeError(“`data` must be a pandas DataFrame”) if not isinstance(var, str): raise TypeError(“`var` must be a single column name (string)”) if var not in data.columns: raise ValueError(f”Column ‘{var}’ not found in data”) col = data[var].dropna() if col.empty: raise ValueError(f”Column ‘{var}’ is entirely NA”) if not pd.api.types.is_numeric_dtype(col): raise TypeError(f”Column ‘{var}’ must be numeric for Normal conjugate models”) def _validate_group(data: pd.DataFrame, group_var) -> None: if group_var is not None and group_var not in data.columns: raise ValueError(f”group_var column ‘{group_var}’ not found in data”) def _split_entries(data: pd.DataFrame, var: str, group_var): “””Split data into labelled entries by group.””” if group_var is not None: entries = [] for g in data[group_var].unique(): sub = data[data[group_var] == g] entries.append({ “label”: f”{group_var} = {g}”, “col”: sub[var].dropna().values }) return entries else: return [{“label”: “All”, “col”: data[var].dropna().values}] def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str: “””Resolve final HTML save path.””” directory = plot_path if plot_path is not None else os.getcwd() filename = (plot_filename if plot_filename is not None else default_name) if not filename.endswith(“.html”): filename += “.html” return os.path.join(directory, filename) def _save_plot(fig, path: str) -> None: fig.write_html(path, auto_open=False) print(f” [Plot saved] {path}\n”) # ============================================================= # NORMAL – INVERSE GAMMA (estimating VARIANCE | mean known) # ============================================================= def conjugate_normal_igamma( data = None, var = None, group_var = None, x = None, mu_known = None, alpha = None, beta = None, cri_levels = (90, 95), quantiles = None, cri_type = “CrI”, plot_path = None, plot_filename = None, ): cri_levels = list(cri_levels) if cri_levels else [] quantiles = list(quantiles) if quantiles else [] all_q = _expand_cri(cri_levels, quantiles) def _ig_mean(a, b): return b / (a – 1) if a > 1 else None def _ig_var (a, b): return b**2 / ((a-1)**2 * (a-2)) if a > 2 else None def _ig_q (p, a, b): return 1 / sp_gamma.ppf(1 – p, a=a, scale=1/b) def _ig_pdf (s, a, b): return sp_gamma.pdf(1/s, a=a, scale=1/b) / s**2 if data is not None: _validate_df_var(data, var) _validate_group(data, group_var) entries_raw = _split_entries(data, var, group_var) entries = [{“label”: e[“label”], “x”: e[“col”]} for e in entries_raw] else: if x is None: raise ValueError(“Provide `data` + `var` OR `x`”) entries = [{“label”: “All”, “x”: np.atleast_1d(np.asarray(x, dtype=float))}] results = [] for e in entries: xv = np.asarray(e[“x”], dtype=float) nv = len(xv) ss = float(np.sum((xv – mu_known)**2)) a_post = alpha + nv / 2 b_post = beta + ss / 2 pr_mean = _ig_mean(alpha, beta) pr_var = _ig_var (alpha, beta) pr_mode = beta / (alpha + 1) pr_median = _ig_q(0.5, alpha, beta) po_mean = _ig_mean(a_post, b_post) po_var = _ig_var (a_post, b_post) po_mode = b_post / (a_post + 1) po_median = _ig_q(0.5, a_post, b_post) all_q_int = sorted(set(all_q + [0.5])) q_vals = [_ig_q(q, a_post, b_post) for q in all_q_int] _print_table( f”Normal-IGamma | {e[‘label’]} | Data Summary”, pd.DataFrame([{ “n”: nv, “mu_known”: f”{mu_known:.6f}”, “SS = sum((x-mu)^2)”: f”{ss:.6f}”, }]) ) _print_table( f”Normal-IGamma | {e[‘label’]} | Parameters & Moments (Variance)”, pd.DataFrame({ “Quantity”: [“alpha (shape)”, “beta (scale)”, “Mean”, “Median”, “Mode”, “Variance”, “SD”], “Prior”: [f”{alpha:.6f}”, f”{beta:.6f}”, _fmt(pr_mean), f”{pr_median:.6f}”, f”{pr_mode:.6f}”, _fmt(pr_var), _fmt(math.sqrt(pr_var) if pr_var is not None else None)], “Posterior”: [f”{a_post:.6f}”, f”{b_post:.6f}”, _fmt(po_mean), f”{po_median:.6f}”, f”{po_mode:.6f}”, _fmt(po_var), _fmt(math.sqrt(po_var) if po_var is not None else None)], }) ) cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type) if cri_df is not None: _print_table( f”Normal-IGamma | {e[‘label’]} | Credible Intervals ({cri_type}) [variance scale]”, cri_df ) q_df = _build_quantile_table(quantiles, all_q_int, q_vals) if q_df is not None: _print_table( f”Normal-IGamma | {e[‘label’]} | Posterior Quantiles [variance scale]”, q_df ) # — Plot — pr_lo = max(_ig_q(0.001, alpha, beta) * 0.5, 1e-6) pr_hi = _ig_q(0.999, alpha, beta) po_lo = max(_ig_q(0.001, a_post, b_post) * 0.5, 1e-6) po_hi = _ig_q(0.999, a_post, b_post) s2_pr = np.linspace(pr_lo, pr_hi, 1000) s2_po = np.linspace(po_lo, po_hi, 1000) fig = make_subplots(rows=1, cols=2, subplot_titles=[“Prior”, “Posterior”]) fig.add_trace(go.Scatter( x=s2_pr, y=_ig_pdf(s2_pr, alpha, beta), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=1) fig.add_trace(go.Scatter( x=s2_po, y=_ig_pdf(s2_po, a_post, b_post), mode=”lines”, line=dict(color=”#2C7BB6″, width=1.5), showlegend=False ), row=1, col=2) fig.update_xaxes(title_text=”σ² (variance)”, row=1, col=1) fig.update_xaxes(title_text=”σ² (variance)”, row=1, col=2) fig.update_yaxes(title_text=”Density”, row=1, col=1) fig.update_layout( title_text=( f”Normal-InvGamma (Variance) [{e[‘label’]}]
” f”Prior: IG({alpha:.2f}, {beta:.2f}) | ” f”Posterior: IG({a_post:.2f}, {b_post:.2f})” ), template=”plotly_white”, ) default_name = f”normal_igamma_{e[‘label’].replace(‘ ‘, ‘_’)}” fpath = _resolve_plot_path(plot_path, plot_filename, default_name) _save_plot(fig, fpath) results.append({ “label”: e[“label”], “data”: {“n”: nv, “mu_known”: mu_known, “ss”: ss}, “prior”: {“alpha”: alpha, “beta”: beta, “mean”: pr_mean, “median”: pr_median, “mode”: pr_mode, “var”: pr_var, “sd”: math.sqrt(pr_var) if pr_var is not None else None}, “posterior”: {“alpha”: a_post, “beta”: b_post, “mean”: po_mean, “median”: po_median, “mode”: po_mode, “var”: po_var, “sd”: math.sqrt(po_var) if po_var is not None else None, “quantiles”: dict(zip(all_q_int, q_vals))}, “plot”: fig, }) return results[0] if len(results) == 1 else results # ============================================================= # USAGE EXAMPLES # ============================================================= if __name__ == “__main__”: # scalar / list # res = conjugate_normal_igamma(x=[2.1,1.9,2.3,2.0], mu_known=2, # alpha=3, beta=1, cri_levels=(90, 95)) # df + var # df = pd.DataFrame({“measure”: [2.1,1.9,2.3,2.0,2.5,2.2,1.8,2.4]}) # res = conjugate_normal_igamma(data=df, var=”measure”, mu_known=2, # alpha=3, beta=1, cri_levels=(90, 95), # quantiles=(0.01, 0.10)) res = conjugate_normal_igamma( x=[2.1, 1.9, 2.3, 2.0], mu_known=2, alpha=3, beta=1, cri_levels=(90, 95), plot_path=”.”, plot_filename=”normal_igamma_result” )

# =============================================================
# Bayesian Conjugate Model - Normal-Inverse-Gamma (Variance, mean known)
# Closed-form analytical solution (standalone script)
#
# Likelihood : X_i ~ N(mu_known, sigma2)
# Prior      : sigma2 ~ InvGamma(alpha, scale = beta)
# Posterior  : sigma2 | x ~ InvGamma(alpha + n/2, scale = beta + SS/2)
#
# InvGamma(a, b) moments:
#   Mean = b / (a - 1)                for a > 1
#   Var  = b^2 / ((a-1)^2 * (a-2))    for a > 2
#
# Quantile: 1 / qgamma(1 - p, shape = a, rate = b)
#         = 1 / Gamma(a, scale=1/b).ppf(1 - p)
#
# Input  : scalar / list / vector  OR  DataFrame + var + group_var
# Output : clean console tables, Plotly HTML saved to disk
# Plots  : Plotly only — saved as HTML, never auto-rendered
# =============================================================

import os
import math
import numpy as np
import pandas as pd
from scipy.stats import gamma as sp_gamma
import plotly.graph_objects as go
from plotly.subplots import make_subplots


# =============================================================
# INTERNAL UTILITIES
# =============================================================

def _print_table(title: str, df: pd.DataFrame) -> None:
    """Plain text table to console — no color, no clutter."""
    col_names  = list(df.columns)
    col_widths = [
        max(len(nm), df[nm].astype(str).map(len).max())
        for nm in col_names
    ]
    col_widths = [max(w, 10) for w in col_widths]

    total_width = sum(col_widths) + 3 * (len(col_widths) - 1) + 2
    divider     = "=" * total_width
    thin_div    = "-" * total_width

    header = "   ".join(nm.ljust(w) for nm, w in zip(col_names, col_widths))

    lines = []
    lines.append(divider)
    lines.append(f"  {title}")
    lines.append(divider)
    lines.append(f"  {header}")
    lines.append(f"  {thin_div}")
    for _, row in df.iterrows():
        row_str = "   ".join(str(val).ljust(w) for val, w in zip(row, col_widths))
        lines.append(f"  {row_str}")
    lines.append(divider)
    lines.append("")
    print("\n".join(lines))


def _fmt(v, digits: int = 6) -> str:
    """Format float; return 'undefined' for None/NaN."""
    if v is None or (isinstance(v, float) and math.isnan(v)):
        return "undefined"
    return f"{v:.{digits}f}"


def _expand_cri(cri_levels, quantiles):
    """Expand cri_levels to symmetric tail quantile pairs; merge with raw quantiles."""
    q_all = sorted(set(quantiles)) if quantiles else []
    if cri_levels:
        for lev in sorted(set(cri_levels)):
            lo = (1 - lev / 100) / 2
            hi = 1 - lo
            q_all = sorted(set(q_all + [lo, hi]))
    return q_all


def _build_cri_table(cri_levels, all_q, q_vals, cri_type):
    """Build CrI table from cri_levels only — symmetric pairs."""
    if not cri_levels:
        return None
    rows = []
    for lev in sorted(set(cri_levels)):
        lo = (1 - lev / 100) / 2
        hi = 1 - lo
        idx_lo = next((i for i, q in enumerate(all_q) if abs(q - lo) < 1e-10), None)
        idx_hi = next((i for i, q in enumerate(all_q) if abs(q - hi) < 1e-10), None)
        if idx_lo is not None and idx_hi is not None:
            rows.append({
                "Level": f"{lev}% {cri_type}",
                "Lower": f"{q_vals[idx_lo]:.6f}",
                "Upper": f"{q_vals[idx_hi]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _build_quantile_table(quantiles, all_q, q_vals):
    """Build individual quantile table from quantiles arg only."""
    if not quantiles:
        return None
    rows = []
    for q in sorted(set(quantiles)):
        idx = next((i for i, v in enumerate(all_q) if abs(v - q) < 1e-10), None)
        if idx is not None:
            rows.append({
                "Quantile": f"Q({q:.4f})",
                "Value":    f"{q_vals[idx]:.6f}",
            })
    return pd.DataFrame(rows) if rows else None


def _validate_df_var(data: pd.DataFrame, var: str) -> None:
    """Validate DataFrame and column for the Normal-InvGamma model."""
    if not isinstance(data, pd.DataFrame):
        raise TypeError("`data` must be a pandas DataFrame")
    if not isinstance(var, str):
        raise TypeError("`var` must be a single column name (string)")
    if var not in data.columns:
        raise ValueError(f"Column '{var}' not found in data")

    col = data[var].dropna()
    if col.empty:
        raise ValueError(f"Column '{var}' is entirely NA")
    if not pd.api.types.is_numeric_dtype(col):
        raise TypeError(f"Column '{var}' must be numeric for Normal conjugate models")


def _validate_group(data: pd.DataFrame, group_var) -> None:
    if group_var is not None and group_var not in data.columns:
        raise ValueError(f"group_var column '{group_var}' not found in data")


def _split_entries(data: pd.DataFrame, var: str, group_var):
    """Split data into labelled entries by group."""
    if group_var is not None:
        entries = []
        for g in data[group_var].unique():
            sub = data[data[group_var] == g]
            entries.append({
                "label": f"{group_var} = {g}",
                "col":   sub[var].dropna().values
            })
        return entries
    else:
        return [{"label": "All", "col": data[var].dropna().values}]


def _resolve_plot_path(plot_path, plot_filename, default_name: str) -> str:
    """Resolve final HTML save path."""
    directory = plot_path if plot_path is not None else os.getcwd()
    filename  = (plot_filename if plot_filename is not None else default_name)
    if not filename.endswith(".html"):
        filename += ".html"
    return os.path.join(directory, filename)


def _save_plot(fig, path: str) -> None:
    fig.write_html(path, auto_open=False)
    print(f"  [Plot saved] {path}\n")


# =============================================================
# NORMAL - INVERSE GAMMA  (estimating VARIANCE | mean known)
# =============================================================

def conjugate_normal_igamma(
    data          = None,
    var           = None,
    group_var     = None,
    x             = None,
    mu_known      = None,
    alpha         = None,
    beta          = None,
    cri_levels    = (90, 95),
    quantiles     = None,
    cri_type      = "CrI",
    plot_path     = None,
    plot_filename = None,
):
    cri_levels = list(cri_levels) if cri_levels else []
    quantiles  = list(quantiles)  if quantiles  else []
    all_q      = _expand_cri(cri_levels, quantiles)

    def _ig_mean(a, b): return b / (a - 1)                    if a > 1 else None
    def _ig_var (a, b): return b**2 / ((a-1)**2 * (a-2))       if a > 2 else None
    def _ig_q   (p, a, b): return 1 / sp_gamma.ppf(1 - p, a=a, scale=1/b)
    def _ig_pdf (s, a, b): return sp_gamma.pdf(1/s, a=a, scale=1/b) / s**2

    if data is not None:
        _validate_df_var(data, var)
        _validate_group(data, group_var)
        entries_raw = _split_entries(data, var, group_var)
        entries = [{"label": e["label"], "x": e["col"]} for e in entries_raw]
    else:
        if x is None:
            raise ValueError("Provide `data` + `var`  OR  `x`")
        entries = [{"label": "All", "x": np.atleast_1d(np.asarray(x, dtype=float))}]

    results = []
    for e in entries:
        xv = np.asarray(e["x"], dtype=float)
        nv = len(xv)
        ss = float(np.sum((xv - mu_known)**2))

        a_post = alpha + nv / 2
        b_post = beta  + ss  / 2

        pr_mean   = _ig_mean(alpha,  beta)
        pr_var    = _ig_var (alpha,  beta)
        pr_mode   = beta   / (alpha  + 1)
        pr_median = _ig_q(0.5, alpha,  beta)

        po_mean   = _ig_mean(a_post, b_post)
        po_var    = _ig_var (a_post, b_post)
        po_mode   = b_post / (a_post + 1)
        po_median = _ig_q(0.5, a_post, b_post)

        all_q_int = sorted(set(all_q + [0.5]))
        q_vals    = [_ig_q(q, a_post, b_post) for q in all_q_int]

        _print_table(
            f"Normal-IGamma  |  {e['label']}  |  Data Summary",
            pd.DataFrame([{
                "n":                  nv,
                "mu_known":           f"{mu_known:.6f}",
                "SS = sum((x-mu)^2)": f"{ss:.6f}",
            }])
        )

        _print_table(
            f"Normal-IGamma  |  {e['label']}  |  Parameters & Moments (Variance)",
            pd.DataFrame({
                "Quantity":  ["alpha (shape)", "beta (scale)", "Mean", "Median", "Mode", "Variance", "SD"],
                "Prior":     [f"{alpha:.6f}", f"{beta:.6f}", _fmt(pr_mean),
                              f"{pr_median:.6f}", f"{pr_mode:.6f}",
                              _fmt(pr_var),
                              _fmt(math.sqrt(pr_var) if pr_var is not None else None)],
                "Posterior": [f"{a_post:.6f}", f"{b_post:.6f}", _fmt(po_mean),
                              f"{po_median:.6f}", f"{po_mode:.6f}",
                              _fmt(po_var),
                              _fmt(math.sqrt(po_var) if po_var is not None else None)],
            })
        )

        cri_df = _build_cri_table(cri_levels, all_q_int, q_vals, cri_type)
        if cri_df is not None:
            _print_table(
                f"Normal-IGamma  |  {e['label']}  |  Credible Intervals ({cri_type}) [variance scale]",
                cri_df
            )

        q_df = _build_quantile_table(quantiles, all_q_int, q_vals)
        if q_df is not None:
            _print_table(
                f"Normal-IGamma  |  {e['label']}  |  Posterior Quantiles [variance scale]",
                q_df
            )

        # --- Plot ---
        pr_lo = max(_ig_q(0.001, alpha,  beta)  * 0.5, 1e-6)
        pr_hi = _ig_q(0.999, alpha,  beta)
        po_lo = max(_ig_q(0.001, a_post, b_post) * 0.5, 1e-6)
        po_hi = _ig_q(0.999, a_post, b_post)

        s2_pr = np.linspace(pr_lo, pr_hi, 1000)
        s2_po = np.linspace(po_lo, po_hi, 1000)

        fig = make_subplots(rows=1, cols=2, subplot_titles=["Prior", "Posterior"])
        fig.add_trace(go.Scatter(
            x=s2_pr, y=_ig_pdf(s2_pr, alpha,  beta),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=1)
        fig.add_trace(go.Scatter(
            x=s2_po, y=_ig_pdf(s2_po, a_post, b_post),
            mode="lines", line=dict(color="#2C7BB6", width=1.5), showlegend=False
        ), row=1, col=2)
        fig.update_xaxes(title_text="σ² (variance)", row=1, col=1)
        fig.update_xaxes(title_text="σ² (variance)", row=1, col=2)
        fig.update_yaxes(title_text="Density", row=1, col=1)
        fig.update_layout(
            title_text=(
                f"Normal-InvGamma (Variance)  [{e['label']}]<br>"
                f"<sup>Prior: IG({alpha:.2f}, {beta:.2f})  |  "
                f"Posterior: IG({a_post:.2f}, {b_post:.2f})</sup>"
            ),
            template="plotly_white",
        )

        default_name = f"normal_igamma_{e['label'].replace(' ', '_')}"
        fpath = _resolve_plot_path(plot_path, plot_filename, default_name)
        _save_plot(fig, fpath)

        results.append({
            "label":     e["label"],
            "data":      {"n": nv, "mu_known": mu_known, "ss": ss},
            "prior":     {"alpha": alpha, "beta": beta,
                          "mean": pr_mean, "median": pr_median, "mode": pr_mode,
                          "var": pr_var,
                          "sd":  math.sqrt(pr_var) if pr_var is not None else None},
            "posterior": {"alpha": a_post, "beta": b_post,
                          "mean": po_mean, "median": po_median, "mode": po_mode,
                          "var": po_var,
                          "sd":  math.sqrt(po_var) if po_var is not None else None,
                          "quantiles": dict(zip(all_q_int, q_vals))},
            "plot":      fig,
        })

    return results[0] if len(results) == 1 else results


# =============================================================
# USAGE EXAMPLES
# =============================================================
if __name__ == "__main__":
    # scalar / list
    # res = conjugate_normal_igamma(x=[2.1,1.9,2.3,2.0], mu_known=2,
    #                               alpha=3, beta=1, cri_levels=(90, 95))

    # df + var
    # df = pd.DataFrame({"measure": [2.1,1.9,2.3,2.0,2.5,2.2,1.8,2.4]})
    # res = conjugate_normal_igamma(data=df, var="measure", mu_known=2,
    #                               alpha=3, beta=1, cri_levels=(90, 95),
    #                               quantiles=(0.01, 0.10))

    res = conjugate_normal_igamma(
        x=[2.1, 1.9, 2.3, 2.0], mu_known=2, alpha=3, beta=1,
        cri_levels=(90, 95),
        plot_path=".", plot_filename="normal_igamma_result"
    )

if name == “main“:
# scalar / list
# res = conjugate_normal_igamma(x=[2.1,1.9,2.3,2.0], mu_known=2,
# alpha=3, beta=1, cri_levels=(90, 95))

The output can be stored as an R object, allowing users to further enhance and customize the generated plots using the extensive capabilities of plotly, including options for graphical modifications.

The provided script generates and displays a basic plotly object on screen, which can be subsequently modified according to the desired presentation style, formatting preferences, or reporting requirements.


Scroll to Top