diff --git a/.gitignore b/.gitignore index 0df6a0dba..9fb2e1ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ dmypy.json # PyCharm .idea files .idea/ + +# Gallery images +docs/source/gallery/images/ diff --git a/.readthedocs.yml b/.readthedocs.yml index 20182bf9b..ada89aacb 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,16 +3,21 @@ version: 2 sphinx: - configuration: docs/source/conf.py + configuration: docs/source/conf.py build: os: ubuntu-22.04 tools: python: "3.10" + jobs: + pre_build: + # Install core dependencies first + - pip install --upgrade pip setuptools wheel + - python scripts/generate_gallery.py python: - install: - - method: pip - path: . - extra_requirements: - - docs + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/Makefile b/Makefile index aae720149..64d6d8140 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ test: ## Install test dependencies and run tests html: ## Install documentation dependencies and build HTML docs pip install .[docs] + python scripts/generate_gallery.py sphinx-build docs/source docs/build -b html cleandocs: ## Clean the documentation build directories diff --git a/docs/source/gallery/README.md b/docs/source/gallery/README.md new file mode 100644 index 000000000..822ded8d4 --- /dev/null +++ b/docs/source/gallery/README.md @@ -0,0 +1,29 @@ +# PyMC-Marketing Example Gallery + +This directory contains the gallery view for the PyMC-Marketing example notebooks. + +## Gallery Structure + +The gallery displays thumbnails and links to all example notebooks, organized by category. + +## Adding New Examples to the Gallery + +When adding new example notebooks: + +1. Add the notebook to the appropriate directory in `docs/source/notebooks/` +2. Update the gallery entry in `gallery.md` +3. Create a thumbnail image for the notebook (ideally a screenshot of a key visualization from the notebook) and place it in the `images/` directory +4. Run `python create_gallery_images.py` to generate a placeholder thumbnail if you don't have a specific image + +## Gallery Images + +The gallery uses thumbnail images to provide visual navigation. For best results: + +- Images should be in PNG format +- Images should have a 4:3 aspect ratio +- Size should be approximately 600x450 pixels +- Names should match the notebook filename + +## Updating the Gallery + +The gallery is structured using the Sphinx Design extension's grid layout. See the [Sphinx Design documentation](https://sphinx-design.readthedocs.io/en/latest/grids.html) for more information on customizing the grid layout. diff --git a/docs/source/gallery/gallery.md b/docs/source/gallery/gallery.md new file mode 100644 index 000000000..7e3cb6794 --- /dev/null +++ b/docs/source/gallery/gallery.md @@ -0,0 +1,197 @@ +(gallery)= +# Example Gallery + +```{toctree} +:hidden: +``` + +## Introduction + +Welcome to the PyMC-Marketing example gallery! This gallery provides visual navigation to all of our example notebooks to help you quickly find the techniques and models relevant to your marketing analytics needs. + +## Marketing Mix Models (MMM) + +### Fundamentals + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} MMM Example Notebook +:img-top: ../gallery/images/mmm_example.png +:link: ../notebooks/mmm/mmm_example.html +::: + +:::{grid-item-card} Custom Models with MMM components +:img-top: ../gallery/images/mmm_components.png +:link: ../notebooks/mmm/mmm_components.html +::: + +:::{grid-item-card} MMM Multidimensional Example Notebook (e.g. Geo-MMM) +:img-top: ../gallery/images/mmm_multidimensional_example.png +:link: ../notebooks/mmm/mmm_multidimensional_example.html +::: +:::: + +### Budget Allocation + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} Budget Allocation Example +:img-top: ../gallery/images/mmm_budget_allocation_example.png +:link: ../notebooks/mmm/mmm_budget_allocation_example.html +::: + +:::{grid-item-card} Budget Allocation Risk Assessment +:img-top: ../gallery/images/mmm_allocation_assessment.png +:link: ../notebooks/mmm/mmm_allocation_assessment.html +::: +:::: + +### Lift Test Calibration + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} Lift Test Analysis +:img-top: ../gallery/images/mmm_lift_test.png +:link: ../notebooks/mmm/mmm_lift_test.html +::: + +:::{grid-item-card} Case Study: Unobserved Confounders, ROAS and Lift Tests +:img-top: ../gallery/images/mmm_roas.png +:link: ../notebooks/mmm/mmm_roas.html +::: +:::: + +### Time-Varying Parameters + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} MMM with time-varying parameters +:img-top: ../gallery/images/mmm_tvp_example.png +:link: ../notebooks/mmm/mmm_tvp_example.html +::: + +:::{grid-item-card} MMM with time-varying media baseline +:img-top: ../gallery/images/mmm_time_varying_media_example.png +:link: ../notebooks/mmm/mmm_time_varying_media_example.html +::: +:::: + +### Model Evaluation + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} Cross-Validation +:img-top: ../gallery/images/mmm_time_slice_cross_validation.png +:link: ../notebooks/mmm/mmm_time_slice_cross_validation.html +::: + +:::{grid-item-card} Model Evaluation +:img-top: ../gallery/images/mmm_evaluation.png +:link: ../notebooks/mmm/mmm_evaluation.html +::: +:::: + +### Causal Inference + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} Causal Identification +:img-top: ../gallery/images/mmm_causal_identification.png +:link: ../notebooks/mmm/mmm_causal_identification.html +::: + +:::{grid-item-card} MMMs and Pearl's ladder of causal inference +:img-top: ../gallery/images/mmm_counterfactuals.png +:link: ../notebooks/mmm/mmm_counterfactuals.html +::: +:::: + +### Case Studies + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} MMM Case Study +:img-top: ../gallery/images/mmm_case_study.png +:link: ../notebooks/mmm/mmm_case_study.html +::: +:::: + +## Customer Lifetime Value (CLV) Models + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} CLV Quickstart +:img-top: ../gallery/images/clv_quickstart.png +:link: ../notebooks/clv/clv_quickstart.html +::: + +:::{grid-item-card} BG/NBD Model +:img-top: ../gallery/images/bg_nbd.png +:link: ../notebooks/clv/bg_nbd.html +::: + +:::{grid-item-card} MBG/NBD Model +:img-top: ../gallery/images/mbg_nbd.png +:link: ../notebooks/clv/mbg_nbd.html +::: + +:::{grid-item-card} Pareto/NBD Model +:img-top: ../gallery/images/pareto_nbd.png +:link: ../notebooks/clv/pareto_nbd.html +::: + +:::{grid-item-card} Gamma-Gamma Model +:img-top: ../gallery/images/gamma_gamma.png +:link: ../notebooks/clv/gamma_gamma.html +::: + +:::{grid-item-card} sBG Model +:img-top: ../gallery/images/sBG.png +:link: ../notebooks/clv/sBG.html +::: +:::: + +## Customer Choice Models + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} MV-ITS Saturated +:img-top: ../gallery/images/mv_its_saturated.png +:link: ../notebooks/customer_choice/mv_its_saturated.html +::: + +:::{grid-item-card} MV-ITS Unsaturated +:img-top: ../gallery/images/mv_its_unsaturated.png +:link: ../notebooks/customer_choice/mv_its_unsaturated.html +::: +:::: + +## General Tutorials + +::::{grid} 1 2 3 3 +:gutter: 3 + +:::{grid-item-card} Model Configuration +:img-top: ../gallery/images/model_configuration.png +:link: ../notebooks/general/model_configuration.html +::: + +:::{grid-item-card} Prior Predictive Checks +:img-top: ../gallery/images/prior_predictive.png +:link: ../notebooks/general/prior_predictive.html +::: + +:::{grid-item-card} NUTS Samplers +:img-top: ../gallery/images/other_nuts_samplers.png +:link: ../notebooks/general/other_nuts_samplers.html +::: +:::: diff --git a/docs/source/getting_started/quickstart/clv/index.md b/docs/source/getting_started/quickstart/clv/index.md index 000b0e16a..8d43e0b7b 100644 --- a/docs/source/getting_started/quickstart/clv/index.md +++ b/docs/source/getting_started/quickstart/clv/index.md @@ -19,4 +19,4 @@ beta_geo_model.fit() Once fitted, we can use the model to predict the number of future purchases for known customers, the probability that they are still alive, and get various visualizations plotted. -See the {ref}`howto` section for more on this. +See the {ref}`gallery` section for more on this. diff --git a/docs/source/getting_started/quickstart/customer_choice/index.md b/docs/source/getting_started/quickstart/customer_choice/index.md index cf59bdb2c..cc2df9456 100644 --- a/docs/source/getting_started/quickstart/customer_choice/index.md +++ b/docs/source/getting_started/quickstart/customer_choice/index.md @@ -38,4 +38,4 @@ model.sample(data[["competitor", "own"]], data["new"]) model.plot_fit(); ``` -See the {ref}`howto` section for more information about using the customer choice module. +See the {ref}`gallery` section for more information about using the customer choice module. diff --git a/docs/source/index.md b/docs/source/index.md index 0917bb514..b37d038db 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -55,9 +55,33 @@ We provide the following professional services: ## Quick links -:::::{grid} 1 1 2 2 +:::::{grid} 1 1 2 3 :gutter: 2 +::::{grid-item-card} Example Gallery +:class-header: sd-text-center no-border +:class-title: sd-text-center +:class-footer: no-border + +{material-outlined}`photo_library;5em` +^^^^^^^^^^^^^^^ + +Browse our visual gallery of example notebooks to quickly +find the techniques and models relevant to your +marketing analytics needs. + ++++ + +:::{button-ref} gallery/gallery +:expand: +:color: secondary +:click-parent: +:ref-type: doc + +To the example gallery +::: +:::: + ::::{grid-item-card} Example notebooks :class-header: sd-text-center no-border :class-title: sd-text-center @@ -110,7 +134,7 @@ To the reference guide ## Bayesian Marketing Mix Modeling (MMM) in PyMC -Leverage our Bayesian MMM API to tailor your marketing strategies effectively. Leveraging on top of the research article [Jin, Yuxue, et al. “Bayesian methods for media mix modeling with carryover and shape effects.” (2017)](https://research.google/pubs/pub46001/), and extending it by integrating the expertise from core PyMC developers, our API provides: +Leverage our Bayesian MMM API to tailor your marketing strategies effectively. Leveraging on top of the research article [Jin, Yuxue, et al. "Bayesian methods for media mix modeling with carryover and shape effects." (2017)](https://research.google/pubs/pub46001/), and extending it by integrating the expertise from core PyMC developers, our API provides: | Feature | Benefit | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -207,5 +231,6 @@ getting_started/index contributing/index guide/index api/index +gallery/gallery notebooks/index ::: diff --git a/docs/source/notebooks/index.md b/docs/source/notebooks/index.md deleted file mode 100644 index 1d9c8139c..000000000 --- a/docs/source/notebooks/index.md +++ /dev/null @@ -1,53 +0,0 @@ -(howto)= -# How-to - -Here you will find a collection of examples and how-to guides for using PyMC-Marketing MMM and CLV models. - -:::{toctree} -:caption: MMMs -:maxdepth: 1 - -mmm/mmm_allocation_assessment -mmm/mmm_budget_allocation_example -mmm/mmm_case_study -mmm/mmm_causal_identification -mmm/mmm_components -mmm/mmm_counterfactuals -mmm/mmm_evaluation -mmm/mmm_example -mmm/mmm_lift_test -mmm/mmm_multidimensional_example -mmm/mmm_roas -mmm/mmm_time_slice_cross_validation -mmm/mmm_time_varying_media_example -mmm/mmm_tvp_example -::: - -:::{toctree} -:caption: CLVs -:maxdepth: 1 - -clv/clv_quickstart -clv/bg_nbd -clv/gamma_gamma -clv/mbg_nbd -clv/pareto_nbd -clv/sBG -::: - -:::{toctree} -:caption: Customer Choice -:maxdepth: 1 - -customer_choice/mv_its_saturated -customer_choice/mv_its_unsaturated -::: - -:::{toctree} -:caption: General -:maxdepth: 1 - -general/model_configuration -general/other_nuts_samplers -general/prior_predictive -::: diff --git a/pyproject.toml b/pyproject.toml index 2b0406b06..22acc1877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,11 +41,7 @@ dependencies = [ ] [project.optional-dependencies] -dag = [ - "dowhy", - "networkx", - "osqp<1.0.0,>=0.6.2", -] +dag = ["dowhy", "networkx", "osqp<1.0.0,>=0.6.2"] docs = [ "blackjax", "fastprogress", diff --git a/scripts/generate_gallery.py b/scripts/generate_gallery.py new file mode 100755 index 000000000..8a7c4e318 --- /dev/null +++ b/scripts/generate_gallery.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +""" +Sphinx plugin to generate a gallery for notebooks + +Modified from the pytensor project, which was modified from the pymc project, +which modified the seaborn project, which modified the mpld3 project. +""" + +import base64 +import json +import logging +import os +import shutil +import tempfile +from pathlib import Path + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) + +try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib import image + + MATPLOTLIB_AVAILABLE = True +except ImportError: + logger.warning("Matplotlib not available. Using fallback for gallery generation.") + MATPLOTLIB_AVAILABLE = False + +# Define directories +PROJECT_ROOT = Path(__file__).resolve().parent.parent +NOTEBOOK_DIR = PROJECT_ROOT / "docs" / "source" / "notebooks" +GALLERY_DIR = PROJECT_ROOT / "docs" / "source" / "gallery" +GALLERY_IMG_DIR = GALLERY_DIR / "images" + +# Create gallery images directory if it doesn't exist +GALLERY_IMG_DIR.mkdir(exist_ok=True, parents=True) + +# Default image in case we can't extract one from a notebook +DEFAULT_IMG_LOC = PROJECT_ROOT / "docs" / "source" / "_static" / "flat_logo.png" + + +def create_thumbnail( + infile: str | Path, + outfile: str | Path, + width: int = 275, + height: int = 275, + cx: float = 0.5, + cy: float = 0.5, + border: int = 4, +) -> None: + """ + Create a thumbnail of the given image file + + Parameters + ---------- + infile : str or Path + The path to the input image file + outfile : str or Path + The path to save the thumbnail + width, height : int + The width and height of the thumbnail in pixels + cx, cy : float + The center position of the crop as a fraction of the image size + border : int + The size of the border in pixels + """ + if not MATPLOTLIB_AVAILABLE: + # If matplotlib is not available, just copy the default image + shutil.copy(DEFAULT_IMG_LOC, outfile) + return + + if not os.path.exists(infile): + logger.warning(f"Input file {infile} does not exist") + # Copy default image + shutil.copy(DEFAULT_IMG_LOC, outfile) + return + + try: + im = image.imread(infile) + rows, cols = im.shape[:2] + size = min(rows, cols) + + if size == cols: + xslice = slice(0, size) + ymin = min(max(0, int(cx * rows - size // 2)), rows - size) + yslice = slice(ymin, ymin + size) + else: + yslice = slice(0, size) + xmin = min(max(0, int(cx * cols - size // 2)), cols - size) + xslice = slice(xmin, xmin + size) + + thumb = im[yslice, xslice] + + # Add a border + if len(thumb.shape) == 3: # Color image + thumb[:border, :, :3] = thumb[-border:, :, :3] = 0 + thumb[:, :border, :3] = thumb[:, -border:, :3] = 0 + else: # Grayscale image + thumb[:border, :] = thumb[-border:, :] = 0 + thumb[:, :border] = thumb[:, -border:] = 0 + + dpi = 100 + fig = plt.figure(figsize=(width / dpi, height / dpi), dpi=dpi) + + ax = fig.add_axes( + [0, 0, 1, 1], aspect="auto", frameon=False, xticks=[], yticks=[] + ) + ax.imshow(thumb, aspect="auto", resample=True, interpolation="bilinear") + fig.savefig(outfile, dpi=dpi) + plt.close(fig) + logger.info(f"Created thumbnail: {outfile}") + except Exception as e: + logger.error(f"Error creating thumbnail for {infile}: {e}") + # Copy default image + shutil.copy(DEFAULT_IMG_LOC, outfile) + + +class NotebookProcessor: + """ + Process a notebook to extract images and create thumbnails + """ + + def __init__( + self, notebook_path: str | Path, category: str, temp_dir: str | Path + ) -> None: + self.notebook_path = Path(notebook_path) + self.category = category + self.name = self.notebook_path.stem + self.temp_dir = temp_dir + + # Create category thumbnail directory in temp dir + self.thumb_dir = Path(temp_dir) / category + self.thumb_dir.mkdir(exist_ok=True) + + # Define thumbnail and gallery image paths + self.thumb_path = self.thumb_dir / f"{self.name}.png" + self.gallery_img_path = GALLERY_IMG_DIR / f"{self.name}.png" + + def _use_default_image(self, reason: str = "") -> bool: + """Create thumbnail from default image and copy to gallery directory""" + if reason: + logger.info(reason) + create_thumbnail(DEFAULT_IMG_LOC, self.thumb_path) + shutil.copy(self.thumb_path, self.gallery_img_path) + return False + + def extract_first_image(self) -> bool: + """ + Extract the first image from the notebook + + Returns + ------- + bool + True if an image was successfully extracted, False otherwise + """ + if not MATPLOTLIB_AVAILABLE: + # If matplotlib is not available, just copy the default image + shutil.copy(DEFAULT_IMG_LOC, self.gallery_img_path) + logger.info( + f"Using default image for {self.notebook_path.name} (matplotlib not available)" + ) + return False + + temp_img_path = Path(self.temp_dir) / f"{self.name}_temp.png" + + try: + with open(self.notebook_path, encoding="utf-8") as f: + notebook = json.load(f) + + # Look for the first image output + for cell in notebook["cells"]: + if cell["cell_type"] != "code": + continue + + for output in cell.get("outputs", []): + if "data" in output and "image/png" in output["data"]: + # Found an image + img_data = output["data"]["image/png"] + img_bytes = base64.b64decode(img_data) + + # Save the image temporarily + with open(temp_img_path, "wb") as img_file: + img_file.write(img_bytes) + + # Create a thumbnail from the extracted image + create_thumbnail(temp_img_path, self.thumb_path) + + # Copy the thumbnail to the gallery images directory + shutil.copy(self.thumb_path, self.gallery_img_path) + + # Clean up temporary file + if temp_img_path.exists(): + temp_img_path.unlink() + + return True + + # No image found, use default + return self._use_default_image(f"No image found in {self.notebook_path}") + + except Exception as e: + logger.error(f"Error processing {self.notebook_path}: {e}") + return self._use_default_image() + + +def find_notebooks(notebook_dir: Path = NOTEBOOK_DIR) -> dict[str, list[Path]]: + """ + Find all notebooks in the notebook directory and return them by category + + Parameters + ---------- + notebook_dir : Path + The directory containing notebooks organized in subdirectories by category + + Returns + ------- + Dict[str, List[Path]] + Dictionary mapping category names to lists of notebook paths + """ + notebooks_by_category: dict[str, list[Path]] = {} + + # Check if notebook directory exists + if not notebook_dir.exists(): + logger.warning(f"Notebook directory {notebook_dir} does not exist.") + return notebooks_by_category + + # Find all notebook categories (directories in notebook_dir) + try: + categories = [ + d + for d in os.listdir(notebook_dir) + if os.path.isdir(os.path.join(notebook_dir, d)) and not d.startswith(".") + ] + except Exception as e: + logger.error(f"Error listing directory {notebook_dir}: {e}") + return notebooks_by_category + + for category in categories: + category_path = os.path.join(notebook_dir, category) + try: + # Get all .ipynb files in the category directory + notebook_paths = [ + Path(os.path.join(category_path, nb)) + for nb in os.listdir(category_path) + if nb.endswith(".ipynb") and not nb.startswith(".") + ] + notebooks_by_category[category] = notebook_paths + except Exception as e: + logger.error(f"Error listing notebooks in {category_path}: {e}") + notebooks_by_category[category] = [] + + return notebooks_by_category + + +def create_default_image() -> None: + """Create a default image if it doesn't exist""" + if not os.path.exists(DEFAULT_IMG_LOC): + logger.warning(f"Default image {DEFAULT_IMG_LOC} does not exist.") + try: + # Create a simple default image if matplotlib is available + if MATPLOTLIB_AVAILABLE: + logger.info("Creating a default image...") + os.makedirs(os.path.dirname(DEFAULT_IMG_LOC), exist_ok=True) + plt.figure(figsize=(4, 3)) + plt.text( + 0.5, 0.5, "PyMC Marketing", ha="center", va="center", fontsize=14 + ) + plt.savefig(DEFAULT_IMG_LOC) + plt.close() + else: + logger.warning("Cannot create default image (matplotlib not available)") + except Exception as e: + logger.error(f"Error creating default image: {e}") + + +def process_notebooks(temp_dir: str | Path) -> tuple[int, int]: + """ + Process all notebooks and create thumbnails + + Parameters + ---------- + temp_dir : str or Path + Path to temporary directory for storing intermediate files + + Returns + ------- + Tuple[int, int] + Tuple containing (success_count, total_count) + """ + # Find all notebooks + notebooks_by_category = find_notebooks() + + # Process each notebook + success_count = 0 + total_count = 0 + + for category, notebook_list in notebooks_by_category.items(): + logger.info(f"Processing category: {category}") + for notebook_path in notebook_list: + processor = NotebookProcessor(notebook_path, category, temp_dir) + if processor.extract_first_image(): + success_count += 1 + total_count += 1 + + logger.info( + f"\nSuccessfully extracted images from {success_count} out of {total_count} notebooks." + ) + logger.info(f"Gallery images are stored in {GALLERY_IMG_DIR}") + return success_count, total_count + + +def main() -> None: + """Main function to process notebooks and create thumbnails""" + logger.info("Starting gallery generation...") + + # Check if default image exists and create if needed + create_default_image() + + # Create a temporary directory for thumbnails + with tempfile.TemporaryDirectory() as temp_dir: + logger.info(f"Created temporary directory for thumbnails: {temp_dir}") + + # Process notebooks + process_notebooks(temp_dir) + + # The temporary directory is automatically deleted when the context manager exits + logger.info("Temporary thumbnail directory has been cleaned up") + + # Check if _thumbnails directory exists and remove it if it does + thumbnails_dir = PROJECT_ROOT / "docs" / "source" / "_thumbnails" + if thumbnails_dir.exists(): + logger.info(f"Removing old _thumbnails directory: {thumbnails_dir}") + shutil.rmtree(thumbnails_dir) + + +if __name__ == "__main__": + main()