Repository: epic-open-source/seismometer

Issue:
Readd data dictionary creation features
<!--- Issues must not contain any PHI, PII, or other confidential information. --->

<!--- Provide a general summary of the issue in the Title above -->

## Problem Summary
<!--- Give a concise description of the problem -->
#90 removed a function that created a data dictionary from a parquet file. This saves a lot of time setting up new use cases so we should readd the code. 

## Suggested fix
<!--- How do you suggest addressing the problem? -->
Add a version of the below to the existing io file.

```
def generate_data_dict_from_parquet(inpath: Path | str, outpath: Path | str, section: str = "predictions"):
    """
    Generate a data dictionary YAML file from a Parquet file.
    Parameters
    ----------
    inpath : Path | str
        The path to the input Parquet file.
    outpath : Path | str
        The path to the output YAML file.
    section : str, optional
        The section name to be used in the YAML file, by default "predictions".
    """
    import pandas as pd
    import yaml

    df = pd.read_parquet(inpath)

    items = []

    for c in df.columns:
        items.append({"name": c, "dtype": str(df[c].dtype), "definition": f"Placeholder description for {c}"})

    with open(outpath, "w") as f:
        f.write(yaml.dump({section: items}))
```

Discussion:

Solution patch:
diff --git a/changelog/82.feature.rst b/changelog/82.feature.rst
new file mode 100644
index 00000000..f31dbc14
--- /dev/null
+++ b/changelog/82.feature.rst
@@ -0,0 +1,5 @@
+Added ExploreSubgroups as a drop in replacement for sm.cohort_list
+Added ExploreModelScoreComparison to compare two scores againts a shared target
+Added ExploreModelTargetComparison to compare a single score across two targets
+Added MultiselectDropdownWidget as a new widget for selecting cohort_dicts, uses a drop down and dismissalbe tags to keep the UX neater.
+Updated handling around `primary_output` and `outputs`, so that if primary_output is in outputs, it does not get added in again during startup. 
\ No newline at end of file
diff --git a/changelog/93.feature.rst b/changelog/93.feature.rst
new file mode 100644
index 00000000..1771b46f
--- /dev/null
+++ b/changelog/93.feature.rst
@@ -0,0 +1 @@
+Add a configuration helper that can generate a dictionary file for events or predictions.
diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst
index 260428e1..fb90089c 100644
--- a/docs/reference/configuration.rst
+++ b/docs/reference/configuration.rst
@@ -29,3 +29,12 @@ Configuration DataModel
    EventTableMap
    OtherInfo
    PredictionDictionary
+
+Configuration Helper
+~~~~~~~~~~~~~~~~~~~~
+.. currentmodule:: seismometer.configuration.config_helpers
+
+.. autosummary::
+   :toctree: api/
+
+   generate_dictionary_from_parquet
diff --git a/docs/reference/seismometer.rst b/docs/reference/seismometer.rst
index d6b8ff4d..d2ec07dd 100644
--- a/docs/reference/seismometer.rst
+++ b/docs/reference/seismometer.rst
@@ -10,12 +10,16 @@ Exploration APIs
 .. autosummary::
    :toctree: api/
 
+   ExploreSubgroups
    ExploreModelEvaluation
    ExploreCohortEvaluation
    ExploreCohortHistograms
    ExploreCohortLeadTime
    ExploreCohortOutcomeInterventionTimes
    ExploreFairnessAudit
+   ExploreModelScoreComparison
+   ExploreModelTargetComparison
+   
 
 Public API
 ~~~~~~~~~~~
@@ -52,6 +56,9 @@ Custom Visualization Controls
 
 
    explore.ExplorationWidget
+   explore.ExplorationSubpopulationWidget
    explore.ExplorationModelSubgroupEvaluationWidget
    explore.ExplorationCohortSubclassEvaluationWidget
    explore.ExplorationCohortOutcomeInterventionEvaluationWidget
+   explore.ExplorationScoreComparisonByCohortWidget
+   explore.ExplorationTargetComparisonByCohortWidget
diff --git a/example-notebooks/binary-classifier/classifier_bin.ipynb b/example-notebooks/binary-classifier/classifier_bin.ipynb
index 5117431f..09547ca4 100644
--- a/example-notebooks/binary-classifier/classifier_bin.ipynb
+++ b/example-notebooks/binary-classifier/classifier_bin.ipynb
@@ -261,7 +261,7 @@
    },
    "outputs": [],
    "source": [
-    "sm.cohort_list()"
+    "sm.ExploreSubgroups()"
    ]
   },
   {
diff --git a/setup.cfg b/setup.cfg
index 7de30900..d35055a3 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -30,6 +30,7 @@ install_requires =
     ipywidgets>=8.0
     jupyterlab>=4.2.5,<5
     matplotlib>=3.6,<4
+    seaborn>=0.13.2,<0.14
     pandas>=1.5.0,<3
     scikit-learn>=1.2.0,<2
     pyarrow>=9.0.0
diff --git a/src/seismometer/_api.py b/src/seismometer/_api.py
index 1d0c0562..09ca56a8 100644
--- a/src/seismometer/_api.py
+++ b/src/seismometer/_api.py
@@ -13,6 +13,9 @@
     ExplorationCohortOutcomeInterventionEvaluationWidget,
     ExplorationCohortSubclassEvaluationWidget,
     ExplorationModelSubgroupEvaluationWidget,
+    ExplorationScoreComparisonByCohortWidget,
+    ExplorationSubpopulationWidget,
+    ExplorationTargetComparisonByCohortWidget,
     ExplorationWidget,
     ModelFairnessAuditOptions,
 )
@@ -231,7 +234,9 @@ def generate_fairness_audit(
     HTML | IFrame | Altair Chart
         IFrame holding the HTML of the audit
     """
+
     sg = Seismogram()
+
     path = "aequitas_{cohorts}_with_{target}_and_{score}_gt_{threshold}_metrics_{metrics}_ratio_{ratio}".format(
         cohorts="_".join(cohort_columns),
         target=target_column,
@@ -247,20 +252,19 @@ def generate_fairness_audit(
 
     if NotebookHost.supports_iframe() and fairness_path.exists():
         return load_as_iframe(fairness_path, height=height)
-
-    target = pdh.event_value(target_column)
     data = (
         pdh.event_score(
             sg.data(),
             sg.entity_keys,
-            score=sg.output,
+            score=score_column,
             ref_event=sg.predict_time,
-            aggregation_method=sg.event_aggregation_method(sg.target),
+            aggregation_method=sg.event_aggregation_method(target_column),
         )
         if per_context
         else sg.data()
     )
 
+    target = pdh.event_value(target_column)
     data = data[[target, score_column] + cohort_columns]
     data = FilterRule.isin(target, (0, 1)).filter(data)
     positive_samples = data[target].sum()
@@ -286,6 +290,19 @@ def generate_fairness_audit(
 # region notebook IPWidgets
 
 
+@export
+class ExploreSubgroups(ExplorationSubpopulationWidget):
+    """
+    Explore the models base statistics based on a selected subpopulation.
+    """
+
+    def __init__(self):
+        """
+        Passes the plot function to the superclass.
+        """
+        super().__init__("Subpopulation Statistics", cohort_list_details)
+
+
 @export
 def cohort_list():
     """
@@ -299,13 +316,13 @@ def cohort_list():
 
     options = sg.available_cohort_groups
 
-    comparison_selections = MultiSelectionListWidget(options, title="Cohort")
+    comparison_selections = MultiSelectionListWidget(options, title="Cohort", show_all=True)
     output = Output()
 
     def on_widget_value_changed(*args):
         with output:
             display("Recalculating...", clear=True)
-            html = _cohort_list_details(comparison_selections.value)
+            html = cohort_list_details(comparison_selections.value)
             display(html, clear=True)
 
     comparison_selections.observe(on_widget_value_changed, "value")
@@ -317,7 +334,8 @@ def on_widget_value_changed(*args):
 
 
 @disk_cached_html_segment
-def _cohort_list_details(cohort_dict: dict[str, tuple[Any]]) -> HTML:
+@export
+def cohort_list_details(cohort_dict: dict[str, tuple[Any]]) -> HTML:
     """
     Generates a HTML table of cohort details.
 
@@ -1403,6 +1421,132 @@ def show_cohort_summaries(by_target: bool = False, by_score: bool = False) -> HT
     return template.render_cohort_summary_template(dfs)
 
 
+@disk_cached_html_segment
+@export
+def plot_model_score_comparison(
+    cohort_dict: dict[str, tuple[Any]], target: str, scores: tuple[str], *, per_context: bool
+) -> HTML:
+    """
+    Plots a comparison of model scores for a given subpopulation.
+
+    Parameters
+    ----------
+    cohort_dict : dict[str,tuple[Any]]
+        The cohort values that comprise the subpopulation of interest.
+    target : str
+        The target column.
+    scores : tuple[str]
+        The score columns to compare.
+    per_context : bool
+        If True, limits data to one row per context_id.
+    """
+    sg = Seismogram()
+
+    cohort_filter = FilterRule.from_cohort_dictionary(cohort_dict)
+    data = cohort_filter.filter(sg.dataframe)
+    target_event = pdh.event_value(target)
+    dataframe = FilterRule.isin(target_event, (0, 1)).filter(data)
+
+    # Need a dataframe with three columns, ScoreName, Score, and Target
+    # Index - one copy of the index for each score name (to allow three columns of real scores.)
+    # ScoreName - the cohort column (which score was used)
+    # Score - the score value
+    # Target - the target value
+
+    data = []
+    for score in scores:
+        if per_context:
+            one_score_data = pdh.event_score(dataframe, sg.entity_keys, score=score, aggregation_method="max")[
+                [score, target_event]
+            ]
+        else:
+            one_score_data = dataframe[[score, target_event]].copy()
+        one_score_data["ScoreName"] = score
+        one_score_data.rename(columns={score: "Score"}, inplace=True)
+        data.append(one_score_data)
+    data = pd.concat(data, axis=0, ignore_index=True)
+    data["ScoreName"] = data["ScoreName"].astype("category")
+
+    plot_data = get_cohort_performance_data(
+        data,
+        "ScoreName",
+        proba=data["Score"],
+        true=data[target_event],
+        splits=list(scores),
+        censor_threshold=sg.censor_threshold,
+    )
+    try:
+        assert_valid_performance_metrics_df(plot_data)
+    except ValueError:
+        return template.render_censored_plot_message(sg.censor_threshold)
+    svg = plot.cohort_evaluation_vs_threshold(plot_data, cohort_feature="ScoreName")
+    title = f"Model Metrics: {', '.join(scores)} vs {target}"
+    return template.render_title_with_image(title, svg)
+
+
+@disk_cached_html_segment
+@export
+def plot_model_target_comparison(
+    cohort_dict: dict[str, tuple[Any]], targets: tuple[str], score: str, *, per_context: bool
+) -> HTML:
+    """
+    Plots a comparison of model targets for a given score and subpopulation.
+
+    Parameters
+    ----------
+    cohort_dict : dict[str,tuple[Any]]
+        The cohort values that comprise the subpopulation of interest.
+    targets : tuple[str]
+        The target columns to compare.
+    scores: str
+        The score column
+    per_context : bool
+        If True, limits data to one row per context_id.
+    """
+    sg = Seismogram()
+
+    cohort_filter = FilterRule.from_cohort_dictionary(cohort_dict)
+    source_data = cohort_filter.filter(sg.dataframe)
+
+    # Need a dataframe with three columns, ScoreName, Score, and Target
+    # Index - one copy of the index for each score name (to allow three columns of real scores.)
+    # TargetName - the cohort column (which target was used)
+    # Score - the score value
+    # Target - the target value
+
+    data = []
+    for target in targets:
+        target_event = pdh.event_value(target)
+        dataframe = FilterRule.isin(target_event, (0, 1)).filter(source_data)
+        if per_context:
+            one_score_data = pdh.event_score(dataframe, sg.entity_keys, score=score, aggregation_method="max")[
+                [score, target_event]
+            ]
+        else:
+            one_score_data = dataframe[[score, target_event]].copy()
+        one_score_data["TargetName"] = target
+        one_score_data.rename(columns={target_event: "Target"}, inplace=True)
+        data.append(one_score_data)
+    data = pd.concat(data, axis=0, ignore_index=True)
+    data["TargetName"] = data["TargetName"].astype("category")
+
+    plot_data = get_cohort_performance_data(
+        data,
+        "TargetName",
+        proba=data[score],
+        true=data["Target"],
+        splits=list(targets),
+        censor_threshold=sg.censor_threshold,
+    )
+    try:
+        assert_valid_performance_metrics_df(plot_data)
+    except ValueError:
+        return template.render_censored_plot_message(sg.censor_threshold)
+    svg = plot.cohort_evaluation_vs_threshold(plot_data, cohort_feature="ScoreName")
+    title = f"Model Metrics: {', '.join(targets)} vs {score}"
+    return template.render_title_with_image(title, svg)
+
+
 # endregion
 
 # region Exploration Widgets
@@ -1424,6 +1568,38 @@ def __init__(self):
         super().__init__("Model Performance", plot_model_evaluation)
 
 
+@export
+class ExploreModelScoreComparison(ExplorationScoreComparisonByCohortWidget):
+    """
+    Exploration widget for model evaluation, showing model performance for a specific subpopulation.
+
+    This includes the ROC, recall vs predicted condition prevalence, calibration,
+    PPV vs sensitivity, sensitivity/specificity/ppv, and a histogram.
+    """
+
+    def __init__(self):
+        """
+        Passes the plot function to the superclass.
+        """
+        super().__init__("Model Score Comparison", plot_model_score_comparison)
+
+
+@export
+class ExploreModelTargetComparison(ExplorationTargetComparisonByCohortWidget):
+    """
+    Exploration widget for model target evaluation, showing model performance for a specific subpopulation.
+
+    This includes the ROC, recall vs predicted condition prevalence, calibration,
+    PPV vs sensitivity, sensitivity/specificity/PPV, and a histogram.
+    """
+
+    def __init__(self):
+        """
+        Passes the plot function to the superclass.
+        """
+        super().__init__("Model Target Comparison", plot_model_target_comparison)
+
+
 @export
 class ExploreCohortEvaluation(ExplorationCohortSubclassEvaluationWidget):
     """
diff --git a/src/seismometer/configuration/config.py b/src/seismometer/configuration/config.py
index 515a7305..af3bdd6e 100644
--- a/src/seismometer/configuration/config.py
+++ b/src/seismometer/configuration/config.py
@@ -102,7 +102,7 @@ def _resolve_other_paths(
     @property
     def config(self) -> OtherInfo:
         """
-        The configuration definiton.
+        The configuration definition.
 
         Usually from config.yml, this is primarily used during initial loading to know
         where other pieces are located.
@@ -250,6 +250,8 @@ def output_list(self) -> list:
 
         Configured in usage_data as outputs or primary_output.
         """
+        if self.output in self.usage.outputs:
+            return self.usage.outputs
         return [self.output] + self.usage.outputs
 
     @property
diff --git a/src/seismometer/configuration/config_helpers.py b/src/seismometer/configuration/config_helpers.py
new file mode 100644
index 00000000..889a260a
--- /dev/null
+++ b/src/seismometer/configuration/config_helpers.py
@@ -0,0 +1,63 @@
+from pathlib import Path
+
+import pandas as pd
+
+from seismometer.core.io import write_yaml
+
+from .model import DictionaryItem, EventDictionary, PredictionDictionary
+
+
+def generate_dictionary_from_parquet(
+    inpath: Path | str, outpath: Path | str, section: str = "predictions", *, column: str = "Type"
+) -> None:
+    """
+    Generate a data dictionary YAML file from a parquet file.
+
+    Parameters
+    ----------
+    inpath : Path | str
+        The path to the input Parquet file.
+    outpath : Path | str
+        The path to the output YAML file.
+    section : str, optional
+        The section name to be used in the YAML file, by default "predictions".
+    column : str, optional
+        The column name of data relevant to defining the dictionary; currently used
+        for events section, by default "Type".
+    """
+    df = pd.read_parquet(inpath)
+    if df.empty:
+        raise ValueError("No data loaded; check the input file")
+
+    datadict = None
+    match section:
+        case "predictions":
+            datadict = _generate_prediction_dictionary(df)
+        case "events":
+            datadict = _generate_event_dictionary(df, column)
+        case _:
+            raise ValueError(f"Section {section} not recognized; only supports predictions and events")
+
+    write_yaml(datadict.model_dump(), outpath)
+
+
+def _generate_prediction_dictionary(df: pd.DataFrame) -> PredictionDictionary:
+    """Generates dictionary based on columns in the DataFrame"""
+    items = [
+        DictionaryItem(name=c, dtype=str(df[c].dtype), definition=f"Placeholder description for {c}")
+        for c in df.columns
+    ]
+    return PredictionDictionary(predictions=items)
+
+
+def _generate_event_dictionary(df, column="Type") -> EventDictionary:
+    """Generates entry for each unique value in the specified column"""
+    items = (
+        [
+            DictionaryItem(name=c, dtype="string", definition=f"Placeholder description for {c}")
+            for c in df[column].unique()
+        ]
+        if column in df
+        else []
+    )
+    return EventDictionary(events=items)
diff --git a/src/seismometer/configuration/model.py b/src/seismometer/configuration/model.py
index e9396f1c..cf1492e6 100644
--- a/src/seismometer/configuration/model.py
+++ b/src/seismometer/configuration/model.py
@@ -234,7 +234,7 @@ class DataUsage(BaseModel):
 
     The features and scores list, when defined, limit the loading of data from the predictions file to only those
     inputs and outputs (plus primary_score and cohort attributes).
-    The events similary limits the event types that are merged into the working dataframe and available to analyses.
+    The events similarly limits the event types that are merged into the working dataframe and available to analyses.
     """
 
     entity_id: str = "Id"
diff --git a/src/seismometer/controls/explore.py b/src/seismometer/controls/explore.py
index b252ce38..193d83e0 100644
--- a/src/seismometer/controls/explore.py
+++ b/src/seismometer/controls/explore.py
@@ -4,30 +4,41 @@
 
 import traitlets
 from IPython.display import display
-from ipywidgets import HTML, Box, Button, Checkbox, Dropdown, FloatSlider, HBox, Layout, Output, ValueWidget, VBox
+from ipywidgets import HTML, Box, Button, Checkbox, Dropdown, FloatSlider, Layout, Output, ValueWidget, VBox
 
-from .selection import DisjointSelectionListsWidget, MultiSelectionListWidget, SelectionListWidget
+from .selection import DisjointSelectionListsWidget, MultiselectDropdownWidget, MultiSelectionListWidget
 from .styles import BOX_GRID_LAYOUT, WIDE_LABEL_STYLE, html_title
 from .thresholds import MonotonicProbabilitySliderListWidget
 
 logger = logging.getLogger("seismometer")
 
+
 # region Model Evaluation Header Controls
+def _combine_scores_checkbox(per_context):
+    """Returns checkbox for if scores should be combined."""
+    return Checkbox(
+        value=per_context,
+        description="Combine scores?",
+        disabled=False,
+        tooltip="Combine scores by taking a representative score from the target window.",
+        style=WIDE_LABEL_STYLE,
+    )
 
 
 class UpdatePlotWidget(Box):
     """Widget for updating plots and showing code behind the plot call."""
 
-    UPDATE_PLOTS = "Update Plots"
+    UPDATE_PLOTS = "Update"
     UPDATING_PLOTS = "Updating ..."
+    SHOW_CODE = "Show code?"
 
     def __init__(self):
         self.code_checkbox = Checkbox(
             value=False,
-            description="show code",
+            description=self.SHOW_CODE,
             disabled=False,
             indent=False,
-            tooltip="Show the code used to generate the plot",
+            tooltip="Show the code used to generate the plot.",
             layout=Layout(margin="var(--jp-widgets-margin) var(--jp-widgets-margin) var(--jp-widgets-margin) 10px;"),
         )
 
@@ -40,6 +51,10 @@ def __init__(self):
     def show_code(self) -> bool:
         return self.code_checkbox.value
 
+    @show_code.setter
+    def show_code(self, show_code: bool) -> bool:
+        self.code_checkbox.value = show_code
+
     @property
     def disabled(self) -> bool:
         return self.plot_button.disabled
@@ -69,7 +84,7 @@ def callback_wrapper(change):
 
 
 class ModelOptionsWidget(VBox, ValueWidget):
-    value = traitlets.Dict(help="The selected values for the model options")
+    value = traitlets.Dict(help="The selected values for the model options.")
 
     def __init__(
         self,
@@ -78,18 +93,19 @@ def __init__(
         thresholds: Optional[dict[str, float]] = None,
         per_context: Optional[bool] = None,
     ):
-        """Widget for model based options
+        """
+        Widget for model options.
 
         Parameters
         ----------
         target_names : tuple[Any]
-            list of target column names
+            List of target column names.
         score_names : tuple[Any]
-            list of model score names
+            List of model score names.
         thresholds : dict[str, float]
-            list of thresholds for the model scores, will be sorted into decreasing order
+            List of thresholds for the model scores, will be sorted into decreasing order.
         per_context : bool, optional
-            if scores should be grouped by context, by default None, in which case this checkbox is not shown.
+            If scores should be grouped by context, by default None, in which case this checkbox is not shown.
         """
         self.title = html_title("Model Options")
         self.target_list = Dropdown(
@@ -119,13 +135,7 @@ def __init__(
             self.threshold_list = None
 
         if per_context is not None:
-            self.per_context_checkbox = Checkbox(
-                value=per_context,
-                description="combine scores",
-                disabled=False,
-                tooltip="Combine scores by taking the maximum score in the target window",
-                style=WIDE_LABEL_STYLE,
-            )
+            self.per_context_checkbox = _combine_scores_checkbox(per_context)
             children.append(self.per_context_checkbox)
             self.per_context_checkbox.observe(self._on_value_change, "value")
         else:
@@ -135,6 +145,7 @@ def __init__(
             children=children,
             layout=Layout(align_items="flex-start", flex="0 0 auto"),
         )
+        self._on_value_change()
         self._disabled = False
 
     @property
@@ -161,29 +172,348 @@ def _on_value_change(self, change=None):
 
     @property
     def target(self) -> str:
-        """target column descriptor"""
+        """Target column descriptor"""
         return self.target_list.value
 
     @property
     def score(self) -> str:
-        """score column descriptor"""
+        """Score column descriptor"""
         return self.score_list.value
 
     @property
     def thresholds(self) -> tuple[float]:
-        """thresholds for the score"""
+        """Thresholds for the score"""
         if self.threshold_list:
             return self.threshold_list.value
 
     @property
     def group_scores(self) -> bool:
-        """if the scores should be grouped"""
+        """If scores should be grouped by context."""
+        if self.per_context_checkbox:
+            return self.per_context_checkbox.value
+
+
+class ModelScoreComparisonOptionsWidget(VBox, ValueWidget):
+    value = traitlets.Dict(help="The selected values for the model options and scores to compare.")
+
+    def __init__(
+        self,
+        target_names: tuple[Any],
+        score_names: tuple[Any],
+        per_context: Optional[bool] = None,
+    ):
+        """
+        Widget for model based options, including scores to compare.
+
+        Parameters
+        ----------
+        target_names : tuple[Any]
+            List of target column names.
+        score_names : tuple[Any]
+            List of model score names.
+        per_context : bool, optional
+            If scores should be grouped by context, by default None, in which case this checkbox is not shown.
+        """
+        self.title = html_title("Model Options")
+        self.target_list = Dropdown(
+            options=target_names,
+            value=target_names[0],
+            description="Target Column",
+            style=WIDE_LABEL_STYLE,
+        )
+        self.score_list = MultiselectDropdownWidget(
+            options=score_names, value=score_names[0:2], title="Compare Scores"
+        )
+
+        self.target_list.observe(self._on_value_change, "value")
+        self.score_list.observe(self._on_value_change, "value")
+        children = [
+            self.title,
+            VBox(children=[self.target_list, self.score_list], layout=Layout(align_items="flex-end")),
+        ]
+
+        if per_context is not None:
+            self.per_context_checkbox = _combine_scores_checkbox(per_context)
+            children.append(self.per_context_checkbox)
+            self.per_context_checkbox.observe(self._on_value_change, "value")
+        else:
+            self.per_context_checkbox = None
+
+        super().__init__(
+            children=children,
+            layout=Layout(align_items="flex-start", flex="0 0 auto"),
+        )
+        self._on_value_change()
+        self._disabled = False
+
+    @property
+    def disabled(self) -> bool:
+        return self._disabled
+
+    @disabled.setter
+    def disabled(self, disabled: bool):
+        self._disabled = disabled
+        self.target_list.disabled = disabled
+        self.score_list.disabled = disabled
+        if self.per_context_checkbox:
+            self.per_context_checkbox.disabled = disabled
+
+    def _on_value_change(self, change=None):
+        self.value = {
+            "target": self.target,
+            "scores": self.scores,
+            "group_scores": self.group_scores,
+        }
+
+    @property
+    def target(self) -> str:
+        """Target column descriptor"""
+        return self.target_list.value
+
+    @property
+    def scores(self) -> tuple[str]:
+        """Score column descriptor"""
+        return self.score_list.value
+
+    @property
+    def group_scores(self) -> bool:
+        """If scores should be grouped by context."""
+        if self.per_context_checkbox:
+            return self.per_context_checkbox.value
+
+
+class ModelScoreComparisonAndCohortsWidget(Box, ValueWidget):
+    value = traitlets.Dict(help="The selected values for the cohorts and model options.")
+
+    def __init__(
+        self,
+        cohort_groups: dict[str, tuple[Any]],
+        target_names: tuple[Any],
+        score_names: tuple[Any],
+        per_context: bool = False,
+    ):
+        """
+        Widget for model based options and cohort selection, including scores to compare.
+
+        Parameters
+        ----------
+        cohort_groups : dict[str, tuple[Any]]
+            cohort columns and groupings
+        target_names : tuple[Any]
+            model target columns
+        score_names : tuple[Any]
+            model score columns
+        per_context : bool, optional
+            if scores should be grouped by context, by default False
+        """
+        self.cohort_list = MultiSelectionListWidget(options=cohort_groups, title="Cohort Filter")
+        self.model_options = ModelScoreComparisonOptionsWidget(target_names, score_names, per_context)
+        self.cohort_list.observe(self._on_value_change, "value")
+        self.model_options.observe(self._on_value_change, "value")
+
+        super().__init__(children=[self.model_options, self.cohort_list], layout=BOX_GRID_LAYOUT)
+
+        self._on_value_change()
+        self._disabled = False
+
+    @property
+    def disabled(self) -> bool:
+        return self._disabled
+
+    @disabled.setter
+    def disabled(self, disabled: bool):
+        self._disabled = disabled
+        self.cohort_list.disabled = disabled
+        self.model_options.disabled = disabled
+
+    def _on_value_change(self, change=None):
+        self.value = {
+            "cohorts": self.cohort_list.value,
+            "model_options": self.model_options.value,
+        }
+
+    @property
+    def cohorts(self) -> dict[str, tuple[str]]:
+        """Selected cohorts"""
+        return self.cohort_list.value
+
+    @property
+    def target(self) -> str:
+        """Target column descriptor"""
+        return self.model_options.target
+
+    @property
+    def scores(self) -> tuple[str]:
+        """Score column descriptors"""
+        return self.model_options.scores
+
+    @property
+    def group_scores(self) -> bool:
+        """If scores should be grouped by context."""
+        return self.model_options.group_scores
+
+
+class ModelTargetComparisonOptionsWidget(VBox, ValueWidget):
+    value = traitlets.Dict(help="The selected values for the model options and targets to evaluate.")
+
+    def __init__(
+        self,
+        target_names: tuple[Any],
+        score_names: tuple[Any],
+        per_context: Optional[bool] = None,
+    ):
+        """
+        Widget for model based options, including multiple targets.
+
+        Parameters
+        ----------
+        target_names : tuple[Any]
+            List of target names
+        score_names : tuple[Any]
+            List of model score names
+        per_context : bool, optional
+            If scores should be grouped by context, by default None, in which case this checkbox is not shown.
+        """
+        self.title = html_title("Model Options")
+        self.target_list = MultiselectDropdownWidget(
+            options=target_names, value=target_names[0:2], title="Compare Targets"
+        )
+        self.score_list = Dropdown(
+            options=score_names,
+            value=score_names[0],
+            description="Score Column",
+            style=WIDE_LABEL_STYLE,
+        )
+        self.target_list.observe(self._on_value_change, "value")
+        self.score_list.observe(self._on_value_change, "value")
+        children = [
+            self.title,
+            VBox(children=[self.target_list, self.score_list], layout=Layout(align_items="flex-end")),
+        ]
+
+        if per_context is not None:
+            self.per_context_checkbox = _combine_scores_checkbox(per_context)
+            children.append(self.per_context_checkbox)
+            self.per_context_checkbox.observe(self._on_value_change, "value")
+        else:
+            self.per_context_checkbox = None
+
+        super().__init__(
+            children=children,
+            layout=Layout(align_items="flex-start", flex="0 0 auto"),
+        )
+        self._on_value_change()
+        self._disabled = False
+
+    @property
+    def disabled(self) -> bool:
+        return self._disabled
+
+    @disabled.setter
+    def disabled(self, disabled: bool):
+        self._disabled = disabled
+        self.target_list.disabled = disabled
+        self.score_list.disabled = disabled
+        if self.per_context_checkbox:
+            self.per_context_checkbox.disabled = disabled
+
+    def _on_value_change(self, change=None):
+        self.value = {
+            "targets": self.targets,
+            "score": self.score,
+            "group_scores": self.group_scores,
+        }
+
+    @property
+    def targets(self) -> str:
+        """Target column descriptors"""
+        return self.target_list.value
+
+    @property
+    def score(self) -> tuple[str]:
+        """Score column descriptor"""
+        return self.score_list.value
+
+    @property
+    def group_scores(self) -> bool:
+        """If scores should be grouped by context."""
         if self.per_context_checkbox:
             return self.per_context_checkbox.value
 
 
+class ModelTargetComparisonAndCohortsWidget(Box, ValueWidget):
+    value = traitlets.Dict(help="The selected values for the cohorts and model options and targets.")
+
+    def __init__(
+        self,
+        cohort_groups: dict[str, tuple[Any]],
+        target_names: tuple[Any],
+        score_names: tuple[Any],
+        per_context: bool = False,
+    ):
+        """
+        Widget for model based options and cohort selection, including multiple targets.
+
+        Parameters
+        ----------
+        cohort_groups : dict[str, tuple[Any]]
+            Cohort columns and groupings
+        target_names : tuple[Any]
+            List of model target descriptors
+        score_names : tuple[Any]
+            List of model score names
+        per_context : bool, optional
+            If scores should be grouped by context, by default False.
+        """
+        self.cohort_list = MultiSelectionListWidget(options=cohort_groups, title="Cohort Filter")
+        self.model_options = ModelTargetComparisonOptionsWidget(target_names, score_names, per_context)
+        self.cohort_list.observe(self._on_value_change, "value")
+        self.model_options.observe(self._on_value_change, "value")
+
+        super().__init__(children=[self.model_options, self.cohort_list], layout=BOX_GRID_LAYOUT)
+
+        self._on_value_change()
+        self._disabled = False
+
+    @property
+    def disabled(self) -> bool:
+        return self._disabled
+
+    @disabled.setter
+    def disabled(self, disabled: bool):
+        self._disabled = disabled
+        self.cohort_list.disabled = disabled
+        self.model_options.disabled = disabled
+
+    def _on_value_change(self, change=None):
+        self.value = {
+            "cohorts": self.cohort_list.value,
+            "model_options": self.model_options.value,
+        }
+
+    @property
+    def cohorts(self) -> dict[str, tuple[str]]:
+        """Selected cohorts"""
+        return self.cohort_list.value
+
+    @property
+    def targets(self) -> str:
+        """Target column descriptors"""
+        return self.model_options.targets
+
+    @property
+    def score(self) -> tuple[str]:
+        """Score column descriptor"""
+        return self.model_options.score
+
+    @property
+    def group_scores(self) -> bool:
+        """If scores should be grouped by context."""
+        return self.model_options.group_scores
+
+
 class ModelOptionsAndCohortsWidget(Box, ValueWidget):
-    value = traitlets.Dict(help="The selected values for the cohorts and model options")
+    value = traitlets.Dict(help="The selected values for the cohorts and model options.")
 
     def __init__(
         self,
@@ -194,7 +524,7 @@ def __init__(
         per_context: bool = False,
     ):
         """
-        Widget for model based options and cohort selection
+        Widget for model based options and cohort selection.
 
         Parameters
         ----------
@@ -207,7 +537,7 @@ def __init__(
         thresholds : dict[str, float]
             model thresholds
         per_context : bool, optional
-            if scores should be grouped by context, by default False
+            If scores should be grouped by context, by default False.
         """
         self.cohort_list = MultiSelectionListWidget(options=cohort_groups, title="Cohort Filter")
         self.model_options = ModelOptionsWidget(target_names, score_names, thresholds, per_context)
@@ -216,6 +546,7 @@ def __init__(
 
         super().__init__(children=[self.model_options, self.cohort_list], layout=BOX_GRID_LAYOUT)
 
+        self._on_value_change()
         self._disabled = False
 
     @property
@@ -236,12 +567,12 @@ def _on_value_change(self, change=None):
 
     @property
     def cohorts(self) -> dict[str, tuple[str]]:
-        """selected cohorts"""
+        """Selected cohorts"""
         return self.cohort_list.value
 
     @property
     def target(self) -> str:
-        """target column descriptor"""
+        """Target column descriptor"""
         return self.model_options.target
 
     @property
@@ -256,12 +587,12 @@ def thresholds(self) -> tuple[float]:
 
     @property
     def group_scores(self) -> bool:
-        """If scores should be grouped by context"""
+        """If scores should be grouped by context."""
         return self.model_options.group_scores
 
 
 class ModelOptionsAndCohortGroupWidget(Box, ValueWidget):
-    value = traitlets.Dict(help="The selected values for the cohorts and model options")
+    value = traitlets.Dict(help="The selected values for the cohorts and model options.")
 
     def __init__(
         self,
@@ -273,7 +604,7 @@ def __init__(
     ):
         """
         Selection widget for model options and cohort groups, when displaying results for
-        individual values within a cohort attribute
+        individual values within a cohort attribute.
 
         Parameters
         ----------
@@ -286,7 +617,7 @@ def __init__(
         thresholds : dict[str, float]
             thresholds for the model scores
         per_context : bool, optional
-            if scores should be grouped by context, by default False
+            If scores should be grouped by context, by default False.
         """
         self.cohort_list = DisjointSelectionListsWidget(options=cohort_groups, title="Cohort Filter", select_all=True)
         self.model_options = ModelOptionsWidget(target_names, score_names, thresholds, per_context)
@@ -294,6 +625,8 @@ def __init__(
         self.model_options.observe(self._on_value_change, "value")
 
         super().__init__(children=[self.model_options, self.cohort_list], layout=BOX_GRID_LAYOUT)
+
+        self._on_value_change()
         self._disabled = False
 
     @property
@@ -314,32 +647,32 @@ def _on_value_change(self, change=None):
 
     @property
     def cohort(self) -> str:
-        """cohort column descriptor"""
+        """Cohort column descriptor"""
         return self.cohort_list.value[0]
 
     @property
     def cohort_groups(self) -> tuple[Any]:
-        """cohort groups"""
+        """Cohort groups"""
         return self.cohort_list.value[1]
 
     @property
     def target(self) -> str:
-        """target column descriptor"""
+        """Target column descriptor"""
         return self.model_options.target
 
     @property
     def score(self) -> str:
-        """score column descriptor"""
+        """Score column descriptor"""
         return self.model_options.score
 
     @property
     def thresholds(self) -> tuple[float]:
-        """score thresholds"""
+        """Score thresholds"""
         return self.model_options.thresholds
 
     @property
     def group_scores(self) -> bool:
-        """if scores should be grouped by context"""
+        """If scores should be grouped by context."""
         return self.model_options.group_scores
 
 
@@ -353,16 +686,16 @@ def __init__(
         reference_time_names: tuple[Any] = None,
     ):
         """
-        Widget for selecting an intervention and outcome for a model implementation
+        Widget for selecting an intervention and outcome for a model implementation.
 
         Parameters
         ----------
         outcome_names : tuple[Any], optional
-            names of outcome columns, by default None
+            Names of outcome columns, by default None.
         intervention_names : tuple[Any], optional
-            names of intervention columns, by default None
+            Names of intervention columns, by default None.
         reference_time_names : tuple[Any], optional
-            name for the reference time to align patients, by default None
+            Name for the reference time to align patients, by default None.
         """
         self.title = html_title("Model Options")
         self.outcome_list = Dropdown(options=outcome_names, value=outcome_names[0], description="Outcome")
@@ -383,6 +716,8 @@ def __init__(
         self.outcome_list.observe(self._on_value_change, "value")
         self.intervention_list.observe(self._on_value_change, "value")
         self.reference_time_list.observe(self._on_value_change, "value")
+
+        self._on_value_change()
         self._disabled = False
 
     @property
@@ -405,17 +740,17 @@ def _on_value_change(self, change=None):
 
     @property
     def outcome(self) -> str:
-        """outcome column descriptor"""
+        """Outcome column descriptor"""
         return self.outcome_list.value
 
     @property
     def intervention(self) -> str:
-        """intervention column descriptor"""
+        """Intervention column descriptor"""
         return self.intervention_list.value
 
     @property
     def reference_time(self) -> str:
-        """reference time column descriptor"""
+        """Reference time column descriptor"""
         return self.reference_time_list.value
 
 
@@ -435,13 +770,13 @@ def __init__(
         Parameters
         ----------
         cohort_groups : dict[str, tuple[Any]]
-            cohort names and category values
+            Cohort names and category values.
         outcome_names : tuple[Any], optional
-            outcome descriptors, by default None
+            Outcome descriptors, by default None.
         intervention_names : tuple[Any], optional
-            intervention descriptors, by default None
+            Intervention descriptors, by default None.
         reference_time_names : tuple[Any], optional
-            reference time descriptors, by default None
+            Reference time descriptors, by default None.
         """
         self.cohort_list = DisjointSelectionListsWidget(options=cohort_groups, title="Cohort Filter", select_all=True)
         self.model_options = ModelInterventionOptionsWidget(outcome_names, intervention_names, reference_time_names)
@@ -449,6 +784,8 @@ def __init__(
         self.model_options.observe(self._on_value_change, "value")
 
         super().__init__(children=[self.model_options, self.cohort_list], layout=BOX_GRID_LAYOUT)
+
+        self._on_value_change()
         self._disabled = False
 
     @property
@@ -469,32 +806,32 @@ def _on_value_change(self, change=None):
 
     @property
     def cohort(self) -> str:
-        """cohort column descriptor"""
+        """Cohort column descriptor"""
         return self.cohort_list.value[0]
 
     @property
     def cohort_groups(self) -> tuple[Any]:
-        """cohort category values"""
+        """Cohort category values"""
         return self.cohort_list.value[1]
 
     @property
     def outcome(self) -> str:
-        """outcome column descriptor"""
+        """Outcome column descriptor"""
         return self.model_options.outcome
 
     @property
     def intervention(self) -> str:
-        """intervention column descriptor"""
+        """Intervention column descriptor"""
         return self.model_options.intervention
 
     @property
     def reference_time(self) -> str:
-        """reference time column descriptor"""
+        """Reference time column descriptor"""
         return self.model_options.reference_time
 
 
 class ModelFairnessAuditOptions(Box, ValueWidget):
-    value = traitlets.Dict(help="The selected values for the audit and model options")
+    value = traitlets.Dict(help="The selected values for the audit and model options.")
 
     def __init__(
         self,
@@ -511,17 +848,17 @@ def __init__(
         Parameters
         ----------
         target_names : tuple[Any]
-            model target columns
+            Model target columns.
         score_names : tuple[Any]
-            model score columns
+            Model score columns.
         score_threshold : float
-            main threshold for the model score
+            Main threshold for the model score.
         per_context : bool, optional
-            if scores should be grouped by context, by default True
+            If scores should be grouped by context, by default True.
         fairness_metrics : tuple[str], optional
-            list of fairness metrics to display, if None (default) will use ["tpr", "fpr", "pprev"]
+            List of fairness metrics to display, if None (default) will use ["tpr", "fpr", "pprev"].
         fairness_threshold : float, optional
-            threshold for fairness metrics, by default 1.25
+            Threshold for fairness metrics, by default 1.25.
         """
         all_metrics = ["pprev", "tpr", "fpr", "fnr", "ppr", "precision"]
         fairness_metrics = fairness_metrics or ["pprev", "tpr", "fpr"]
@@ -535,9 +872,10 @@ def __init__(
             style=WIDE_LABEL_STYLE,
         )
         thresholds = {"Score Threshold": score_threshold}
-        self.fairness_list = SelectionListWidget(options=all_metrics, value=fairness_metrics, title="Metrics")
+        self.fairness_list = MultiselectDropdownWidget(options=all_metrics, value=fairness_metrics, title="Metrics")
         fairness_section = VBox(
-            children=[html_title("Audit Options"), HBox(children=[self.fairness_list, self.fairness_slider])]
+            children=[html_title("Audit Options"), self.fairness_slider, self.fairness_list],
+            layout=Layout(align_items="flex-end"),
         )
         self.model_options = ModelOptionsWidget(target_names, score_names, thresholds, per_context)
 
@@ -546,6 +884,8 @@ def __init__(
         self.fairness_list.observe(self._on_value_change, "value")
         self.fairness_slider.observe(self._on_value_change, "value")
         self.model_options.observe(self._on_value_change, "value")
+
+        self._on_value_change()
         self._disabled = False
 
     @property
@@ -568,17 +908,17 @@ def _on_value_change(self, change=None):
 
     @property
     def metrics(self) -> tuple[str]:
-        """selected cohorts"""
+        """Selected cohorts"""
         return self.fairness_list.value
 
     @property
     def fairness_threshold(self) -> tuple[str]:
-        """selected cohorts"""
+        """Threshold under which differences are considered to be fair."""
         return self.fairness_slider.value
 
     @property
     def target(self) -> str:
-        """target column descriptor"""
+        """Target column descriptor"""
         return self.model_options.target
 
     @property
@@ -593,7 +933,7 @@ def score_threshold(self) -> tuple[float]:
 
     @property
     def group_scores(self) -> bool:
-        """If scores should be grouped by context"""
+        """If scores should be grouped by context."""
         return self.model_options.group_scores
 
 
@@ -606,17 +946,22 @@ class ExplorationWidget(VBox):
     Parent class for model exploration widgets.
     """
 
-    def __init__(self, title: str, option_widget: ValueWidget, plot_function: Callable[..., Any]):
-        """Parent class for a plot exploration widget.
+    NO_CODE_STRING = "No plot generated."
+
+    def __init__(
+        self, title: str, option_widget: ValueWidget, plot_function: Callable[..., Any], initial_plot: bool = True
+    ):
+        """
+        Parent class for a plot exploration widget.
 
         Parameters
         ----------
         title : str
-            Widget title
+            Widget title.
         option_widget : ValueWidget
-            widget that contains the options the plot_function
+            Widget that contains the options the plot_function.
         plot_function : Callable[..., Any]
-            a function that generates content for display within the control.
+            A function that generates content for display within the control.
         """
         layout = Layout(
             width="100%",
@@ -635,7 +980,10 @@ def __init__(self, title: str, option_widget: ValueWidget, plot_function: Callab
         )
 
         # show initial plot
-        self.update_plot(initial=True)
+        if initial_plot:
+            self.update_plot(initial=True)
+        else:
+            self.current_plot_code = self.NO_CODE_STRING
 
         # attache event handlers
         self.option_widget.observe(self._on_option_change, "value")
@@ -652,30 +1000,40 @@ def disabled(self) -> bool:
     @property
     def show_code(self) -> bool:
         """
-        If the widget should show the plot's code
+        If the widget should show the plot's code.
         """
         return self.update_plot_widget.show_code
 
+    @show_code.setter
+    def show_code(self, show_code: bool):
+        self.update_plot_widget.show_code = show_code
+
     def update_plot(self, initial: bool = False):
         plot_args, plot_kwargs = self.generate_plot_args()
         self.current_plot_code = self.generate_plot_code(plot_args, plot_kwargs)
-        plot = self.plot_function(*plot_args, **plot_kwargs)
+        try:
+            plot = self.plot_function(*plot_args, **plot_kwargs)
+        except Exception as e:
+            import traceback
+
+            plot = HTML(f"<div><h3>Exception: <pre>{e}</pre> </h3><pre>{traceback.format_exc()}</pre></div>")
         if not initial:
             self.center.clear_output()
             with self.center:
                 display(plot)
         else:
             self.center.append_display_data(plot)
+        self.update_plot_widget.disabled = True
 
     def _on_plot_button_click(self, button=None):
-        """handle for the update plot button"""
+        """Handle for the update plot button."""
         self.option_widget.disabled = True
         self.update_plot()
         self._on_toggle_code(self.show_code)
         self.option_widget.disabled = False
 
     def generate_plot_code(self, plot_args: tuple = None, plot_kwargs: dict = None) -> str:
-        """String representation of the plot's code"""
+        """String representation of the plot's code."""
         plot_module = self.plot_function.__module__
         plot_method = self.plot_function.__name__
 
@@ -698,7 +1056,7 @@ def generate_plot_code(self, plot_args: tuple = None, plot_kwargs: dict = None)
         return f"{method_string}({args_string})"
 
     def _on_toggle_code(self, show_code: bool):
-        """handle for the toggle code checkbox"""
+        """Handle for the toggle code checkbox."""
         self.code_output.clear_output()
         if not show_code:
             return
@@ -711,14 +1069,59 @@ def _on_toggle_code(self, show_code: bool):
             display(highlighted_code)
 
     def _on_option_change(self, change=None):
-        """enable the plot to be updated"""
+        """Enable the plot to be updated."""
         self.update_plot_widget.disabled = self.disabled
 
     def generate_plot_args(self) -> tuple[tuple, dict]:
-        """override this method with code to show a plot"""
+        """Override this method with code to show a plot."""
         raise NotImplementedError("Subclasses must implement this method")
 
 
+class ExplorationSubpopulationWidget(ExplorationWidget):
+    """
+    A widget for exploring subpopulations based on cohort selection.
+    """
+
+    def __init__(
+        self,
+        title: str,
+        plot_function: Callable[..., Any],
+    ):
+        """
+        Exploration widget for a subpopulation defined by a cohort selection.
+
+        Parameters
+        ----------
+        title : str
+            Title of the control..
+        plot_function : Callable[..., Any]
+            Expected to have the following signature:
+
+            .. code:: python
+
+                def plot_function(cohort_dict: dict[str,tuple[Any]]) -> Any
+        """
+        from seismometer.seismogram import Seismogram
+
+        sg = Seismogram()
+        cohort_groups = sg.available_cohort_groups
+        option_widget = MultiSelectionListWidget(options=cohort_groups, title="Cohort Filter")
+        option_widget.layout = BOX_GRID_LAYOUT
+        super().__init__(
+            title=title,
+            option_widget=option_widget,
+            plot_function=plot_function,
+        )
+
+    def generate_plot_args(self) -> tuple[tuple, dict]:
+        """Generates the plot arguments for the model evaluation plot."""
+        args = (self.option_widget.value,)
+        kwargs = {
+            # empty dictionary
+        }
+        return args, kwargs
+
+
 class ExplorationModelSubgroupEvaluationWidget(ExplorationWidget):
     """
     A widget for exploring the model performance of a cohort.
@@ -736,14 +1139,14 @@ def __init__(
         Parameters
         ----------
         title : str
-            title of the control
+            Title of the control.
         plot_function : Callable[..., Any]
             Expected to have the following signature:
 
             .. code:: python
 
                 def plot_function(
-                    cohorts: dict[str,tuple[Any]]
+                    cohort_dict: dict[str,tuple[Any]],
                     target: str,
                     score: str,
                     thresholds: tuple[float],
@@ -762,7 +1165,7 @@ def plot_function(
         )
 
     def generate_plot_args(self) -> tuple[tuple, dict]:
-        """Generates the plot arguments for the model evaluation plot"""
+        """Generates the plot arguments for the model evaluation plot."""
         args = (
             self.option_widget.cohorts,
             self.option_widget.target,
@@ -795,7 +1198,7 @@ def __init__(
         Parameters
         ----------
         title : str
-            title of the control
+            Title of the control.
         plot_function : Callable[..., Any]
             Expected to have the following signature:
 
@@ -839,7 +1242,7 @@ def disabled(self):
         return not self.option_widget.cohort_groups
 
     def generate_plot_args(self) -> tuple[tuple, dict]:
-        """Generates the plot arguments for the model evaluation plot"""
+        """Generates the plot arguments for the model evaluation plot."""
 
         args = [
             self.option_widget.cohort,
@@ -875,7 +1278,7 @@ def __init__(
         Parameters
         ----------
         title : str
-            title of the control
+            Title of the control.
         plot_function : Callable[..., Any]
             Expected to have the following signature:
 
@@ -907,7 +1310,7 @@ def plot_function(
         )
 
     def generate_plot_args(self) -> tuple[tuple, dict]:
-        """Generates the plot arguments for the model evaluation plot"""
+        """Generates the plot arguments for the model evaluation plot."""
         args = [
             self.option_widget.cohort,
             self.option_widget.cohort_groups,
@@ -919,4 +1322,106 @@ def generate_plot_args(self) -> tuple[tuple, dict]:
         return args, kwargs
 
 
+class ExplorationScoreComparisonByCohortWidget(ExplorationWidget):
+    """
+    A widget to explore different model scores based on a cohort selection.
+    """
+
+    def __init__(self, title: str, plot_function: Callable[..., Any]):
+        """
+        Exploration widget for model score comparison, showing a plot for a given target
+        and cohort selection, across different scores.
+
+        Parameters
+        ----------
+        title : str
+            Title of the control.
+        plot_function : Callable[..., Any]
+            Expected to have the following signature:
+
+            .. code:: python
+
+                def plot_function(
+                    cohort_dict: dict[str,tuple[Any]],
+                    target: str,
+                    scores: tuple[str],
+                    *, per_context: bool) -> Any
+        """
+        from seismometer.seismogram import Seismogram
+
+        sg = Seismogram()
+        super().__init__(
+            title,
+            option_widget=ModelScoreComparisonAndCohortsWidget(
+                sg.available_cohort_groups, sg.target_cols, sg.output_list, per_context=False
+            ),
+            plot_function=plot_function,
+        )
+
+    @property
+    def disabled(self):
+        return len(self.option_widget.scores) < 2
+
+    def generate_plot_args(self) -> tuple[tuple, dict]:
+        """Generates the plot arguments for the model evaluation plot."""
+        args = [
+            self.option_widget.cohorts,
+            self.option_widget.target,
+            self.option_widget.scores,
+        ]
+        kwargs = {"per_context": self.option_widget.group_scores}
+        return args, kwargs
+
+
+class ExplorationTargetComparisonByCohortWidget(ExplorationWidget):
+    """
+    A widget to explore different model targets based on a cohort selection.
+    """
+
+    def __init__(self, title: str, plot_function: Callable[..., Any]):
+        """
+        Exploration widget for model target comparison, showing a plot for a given scores
+        and cohort selection, across different targets.
+
+        Parameters
+        ----------
+        title : str
+            Title of the control.
+        plot_function : Callable[..., Any]
+            Expected to have the following signature:
+
+            .. code:: python
+
+                def plot_function(
+                    cohort_dict: dict[str,tuple[Any]],
+                    targets: tuple[str],
+                    score: str,
+                    *, per_context: bool) -> Any
+        """
+        from seismometer.seismogram import Seismogram
+
+        sg = Seismogram()
+        super().__init__(
+            title,
+            option_widget=ModelTargetComparisonAndCohortsWidget(
+                sg.available_cohort_groups, sg.target_cols, sg.output_list, per_context=False
+            ),
+            plot_function=plot_function,
+        )
+
+    @property
+    def disabled(self):
+        return len(self.option_widget.targets) < 2
+
+    def generate_plot_args(self) -> tuple[tuple, dict]:
+        """Generates the plot arguments for the model evaluation plot."""
+        args = [
+            self.option_widget.cohorts,
+            self.option_widget.targets,
+            self.option_widget.score,
+        ]
+        kwargs = {"per_context": self.option_widget.group_scores}
+        return args, kwargs
+
+
 # endregion
diff --git a/src/seismometer/controls/selection.py b/src/seismometer/controls/selection.py
index d82b9ec6..766138d4 100644
--- a/src/seismometer/controls/selection.py
+++ b/src/seismometer/controls/selection.py
@@ -2,9 +2,9 @@
 from typing import Optional
 
 import traitlets
-from ipywidgets import HTML, Box, Dropdown, Label, Layout, Stack, ToggleButton, ValueWidget, VBox, jslink
+from ipywidgets import HTML, Box, Button, Dropdown, Label, Layout, Stack, ToggleButton, ValueWidget, VBox, jslink
 
-from .styles import DROPDOWN_LAYOUT, html_title
+from .styles import DROPDOWN_LAYOUT, WIDE_BUTTON_LAYOUT, WIDE_LABEL_STYLE, html_title
 
 
 class SelectionListWidget(ValueWidget, VBox):
@@ -105,6 +105,7 @@ def __init__(
         *,
         title: str = None,
         border: bool = False,
+        show_all: bool = False,
     ):
         """
         A table of buttons organized into columns by their keys. Collapsable to save space.
@@ -119,6 +120,8 @@ def __init__(
             Name of the control, by default None.
         border : bool, optional
             If True, display a border around the widget, by default False.
+        show_all : bool, optional
+            If True, show all optoins, else show only selected, by default False.
         """
         super().__init__()
         self.title = title
@@ -130,8 +133,9 @@ def __init__(
             values = {k: tuple(v) for k, v in values.items()}
         self.value = values
 
+        selection_widget_class = SelectionListWidget if show_all else MultiselectDropdownWidget
         for key in options:
-            selection_widget = SelectionListWidget(title=key, options=options[key], value=values.get(key, None))
+            selection_widget = selection_widget_class(title=key, options=options[key], value=values.get(key, None))
             self.selection_widgets[key] = selection_widget
             selection_widget.observe(self._on_subselection_change, "value")
         self.value_update_in_progress = False
@@ -242,11 +246,8 @@ def __init__(
             selection_value = values[dropdown_value]
             value = (dropdown_value, selection_value)
 
-        self.dropdown = Dropdown(
-            options=[key for key in values],
-            value=value[0],
-            layout=DROPDOWN_LAYOUT,
-        )
+        self.dropdown = Dropdown(options=[key for key in values], value=value[0], layout=DROPDOWN_LAYOUT)
+
         self.dropdown.observe(self._on_selection_change, "value")
         self.selection_widgets = {}
         for key in options:
@@ -288,3 +289,120 @@ def get_selection_text(self) -> str:
         """Return the selection for the widget as a key value pair."""
         key = self.value[0]
         return f"{key}: {self.selection_widgets[key].get_selection_text()}"
+
+
+class MultiselectDropdownWidget(ValueWidget, VBox):
+    """
+    A multi select dropdown which allows multiple selections from a dropdown which get displayed as toggle buttons.
+    """
+
+    value = traitlets.Tuple(help="The selected values for the dropdown.")
+
+    def __init__(
+        self,
+        options: tuple[str],
+        value: Optional[tuple[str]] = None,
+        *,
+        title: str = None,
+    ):
+        """
+        A dropdown selector where multiple selections are allowed.
+
+        Parameters
+        ----------
+        options : tuple[str]
+            Dropdown entries.
+        value : Optional[tuple[str]], optional
+            Pre-selected values, by default None.
+        title : str, optional
+            Display show in the dropdown, by default "Add...".
+        """
+        self.options = tuple(options)
+        self.value = tuple(value) if value else ()
+        self.title = title
+        default_option = (title, -2) if title else ("Add...", -2)
+        add_all_option = ("Add all", -1)
+        tooltip = f"Select an option to add to {title}" if title else "Select an option to add"
+        self.dropdown = Dropdown(
+            options=[default_option, add_all_option] + [(str(v), i) for i, v in enumerate(options)],
+            index=0,
+            style=WIDE_LABEL_STYLE,
+            tooltip=tooltip,
+            layout=WIDE_BUTTON_LAYOUT,
+        )
+        self.selection_options = VBox(children=[], layout=Layout(align_self="flex-end", align_items="flex-end"))
+        self.buttons = {
+            option: Button(
+                description=str(option),
+                tooltip=f"Remove {option}",
+                indent=True,
+                button_style="primary",
+                layout=WIDE_BUTTON_LAYOUT,
+            )
+            for option in options
+        }
+
+        for val in self.value:
+            self._insert_button(val)
+
+        children = [self.dropdown, self.selection_options]
+        super().__init__(children=children, layout=Layout(width="min-content"))
+
+        for button in self.buttons.values():
+            button.on_click(self._remove_button)
+        self.dropdown.observe(self._on_dropdown_changed, "value")
+        self.observe(self._on_value_change, "value")
+        self._disabled = False
+
+    def _remove_button(self, button, update_value=True):
+        self.selection_options.children = [child for child in self.selection_options.children if child != button]
+        if update_value:
+            self.value = tuple(val for val in self.value if self.buttons[val] != button)
+
+    def _insert_button(self, val):
+        button = self.buttons[val]
+        if button not in self.selection_options.children:
+            self.selection_options.children = [child for child in self.selection_options.children] + [button]
+
+    def _on_dropdown_changed(self, change):
+        if change["owner"] != self.dropdown:
+            return
+        option_index = change["new"]
+        match option_index:
+            case -2:
+                pass
+            case -1:
+                self.value = self.options
+            case option_index if self.options[option_index] in self.value:
+                pass
+            case _:
+                self.value = tuple(
+                    [value for value in self.options if value in self.value or value == self.options[option_index]]
+                )
+
+        self.selection_options.children = [self.buttons[val] for val in self.value]
+        self.dropdown.value = -2
+
+    @property
+    def disabled(self) -> bool:
+        return self._disabled
+
+    @disabled.setter
+    def disabled(self, disabled: bool):
+        self._disabled = disabled
+        self.dropdown.disabled = disabled
+        for button in self.buttons.values():
+            button.disabled = disabled
+
+    def _on_value_change(self, change=None):
+        """Bubble up changes."""
+        self.selection_options.children = [self.buttons[val] for val in change["new"]]
+        self.dropdown.value = -2
+
+    def get_selection_text(self) -> str:
+        """Description of the currently selected values."""
+        text = f"{self.title}: " if self.title else ""
+        if self.value:
+            return text + f"{', '.join([str(x) for x in self.value])}"
+        else:
+            return text + ""
diff --git a/src/seismometer/controls/styles.py b/src/seismometer/controls/styles.py
index d166b509..d4dad7ff 100644
--- a/src/seismometer/controls/styles.py
+++ b/src/seismometer/controls/styles.py
@@ -5,10 +5,10 @@
 BOX_GRID_LAYOUT = Layout(
     align_items="flex-start", grid_gap="20px", width="100%", min_width="300px", max_width="1200px"
 )
-WIDE_BUTTON_LAYOUT = Layout(align_items="flex-start", width="max-content", min_width="200px")
+WIDE_BUTTON_LAYOUT = Layout(align_items="flex-start", width="max-content", min_width="150px", max_width="300px")
 DROPDOWN_LAYOUT = Layout(width="calc(max(max-content, var(--jp-widgets-inline-width-short)))")
 
 
-def html_title(title: str) -> HTML:
+def html_title(title: str, block: bool = True) -> HTML:
     """html title style"""
-    return HTML(f'<h4 style="text-align: left;  margin: 0px;">{title}</h4>')
+    return HTML(f'<h4 style="text-align: left;  margin: 0px;">{title}</h4>', layout=Layout(align_self="flex-start"))
diff --git a/src/seismometer/controls/thresholds.py b/src/seismometer/controls/thresholds.py
index 2c239d87..5e902690 100644
--- a/src/seismometer/controls/thresholds.py
+++ b/src/seismometer/controls/thresholds.py
@@ -44,6 +44,7 @@ def __init__(self, names: Iterable[str], value: Optional[Iterable[int]] = None):
                 max=1.0,
                 step=0.01,
                 description=name,
+                tooltip=name,
                 disabled=False,
                 continuous_update=False,
                 orientation="horizontal",
diff --git a/src/seismometer/core/io.py b/src/seismometer/core/io.py
index 2a329344..fffe4aaf 100644
--- a/src/seismometer/core/io.py
+++ b/src/seismometer/core/io.py
@@ -12,9 +12,24 @@
 import yaml
 
 Pathlike = str | Path
-
 logger = logging.getLogger("seismometer")
 
+# region io-accessor functions
+read_ipynb = partial(nbformat.read, as_version=nbformat.current_nbformat)
+dump_json_pretty = partial(json.dump, indent=4)
+dump_yaml = yaml.dump
+
+
+def _read_text(fo):
+    return fo.readlines()
+
+
+def _print_to_file(content, fo):
+    print(content, file=fo)
+
+
+# endregion
+
 
 def slugify(value: str) -> str:
     """
@@ -56,22 +71,6 @@ def slugify(value: str) -> str:
     return value
 
 
-# region io-accessor functions
-read_ipynb = partial(nbformat.read, as_version=nbformat.current_nbformat)
-dump_json_pretty = partial(json.dump, indent=4)
-
-
-def _print_to_file(content, fo):
-    print(content, file=fo)
-
-
-def _read_text(fo):
-    return fo.readlines()
-
-
-# endregion
-
-
 def resolve_filename(
     filename: Path,
     cohort_attribute: Optional[str] = None,
@@ -234,6 +233,20 @@ def _load(loader: Callable["fileobject", Any], filepath: Path, resource_dir: Pat
 
 # endregion
 # region write functions
+def write_yaml(content: dict, filepath: Path, *, overwrite: bool = False) -> None:
+    """
+    Writes a dictionary to a YAML file.
+
+    Parameters
+    ----------
+    content : dict
+        The dictionary to write.
+    filepath : Path
+        The location to write.
+    overwrite : bool, optional
+        Write the file even if one exists, by default False.
+    """
+    _write(dump_yaml, content, filepath, overwrite)
 
 
 def write_markdown(content: str, filepath: Path, *, overwrite: bool = False) -> None:
@@ -303,7 +316,6 @@ def _write(writer: Callable[[Any, "fileobject"], None], content: Any, file: Path
         If True, writes the file even if one exists.
 
     """
-
     file = Path(file)
     if file.is_file() and not overwrite:
         raise FileExistsError(f"File already exists: {file.resolve()}; Specify overwrite to write anyway.")
diff --git a/src/seismometer/data/cohorts.py b/src/seismometer/data/cohorts.py
index ed4fa66b..f27f3416 100644
--- a/src/seismometer/data/cohorts.py
+++ b/src/seismometer/data/cohorts.py
@@ -141,6 +141,9 @@ def get_cohort_performance_data(
             pd.DataFrame({"cohort": label, "cohort-count": 0, "cohort-targetcount": 0}, index=[0])
         )
 
+    if not cohort_perf_stats:
+        return pd.DataFrame()
+
     frame = pd.concat(cohort_perf_stats, ignore_index=True)
     frame["cohort"] = frame["cohort"].astype(pd.CategoricalDtype(data["cohort"].cat.categories))
 
@@ -263,7 +266,7 @@ def label_cohorts_categorical(series: SeriesOrArray, cat_values: Optional[list]
     series : SeriesOrArray
         pandas series of data to bin.
     cat_values : Optional[list], optional
-        List of categories to reduce to (default: None-> all observeded categories).
+        List of categories to reduce to (default: None-> all observed categories).
 
     Returns
     -------
diff --git a/src/seismometer/seismogram.py b/src/seismometer/seismogram.py
index 0501cc48..f575b2f7 100644
--- a/src/seismometer/seismogram.py
+++ b/src/seismometer/seismogram.py
@@ -28,7 +28,7 @@ class Seismogram(object, metaclass=Singleton):
             b. Source Data
         2. Hold the current dynamic configuration that is shared state.
             a. Cohort Selection
-        3. Providing data accces for other objects without compromising the source data.
+        3. Providing data access for other objects without compromising the source data.
             a. Merge Event data with Prediction data for Label generation
             b. Cohort based data selection
             c. Model configuration help texts
@@ -324,7 +324,7 @@ def score_bins(self):
     # region initialization and preprocessing (this region knows about config)
     def copy_config_metadata(self):
         """
-        Loads the base configuration and alerting congfiguration
+        Loads the base configuration and alerting configuration.
         """
         self.alert_config = AlertConfigProvider(self.config.config_dir)
 


Test patch:
diff --git a/tests/configuration/test_helpers.py b/tests/configuration/test_helpers.py
new file mode 100644
index 00000000..5845e5b8
--- /dev/null
+++ b/tests/configuration/test_helpers.py
@@ -0,0 +1,90 @@
+from unittest.mock import patch
+
+import pandas as pd
+import pytest
+
+import seismometer.configuration.config_helpers as undertest
+from seismometer.core.io import load_yaml
+
+
+def frame_case():
+    df = pd.DataFrame({"Type": ["A", "B", "C", "B"], "IntCol": [1, 2, 3, 4]})
+    df.Type = df.Type.astype("category")
+    df.IntCol = df.IntCol.astype("int32")
+
+    return df
+
+
+@pytest.mark.usefixtures("tmp_as_current")
+@patch.object(pd, "read_parquet", return_value=frame_case())
+class TestGenerateDict:
+    def test_generate_events(self, mock_read):
+        outfile = "out.yml"
+        expected = {
+            "events": [
+                {
+                    "definition": "Placeholder description for A",
+                    "display_name": "A",
+                    "dtype": "string",
+                    "name": "A",
+                },
+                {
+                    "definition": "Placeholder description for B",
+                    "display_name": "B",
+                    "dtype": "string",
+                    "name": "B",
+                },
+                {
+                    "definition": "Placeholder description for C",
+                    "display_name": "C",
+                    "dtype": "string",
+                    "name": "C",
+                },
+            ]
+        }
+
+        undertest.generate_dictionary_from_parquet("TESTIN", outfile, section="events")
+        actual = load_yaml(outfile)
+
+        mock_read.assert_called_once_with("TESTIN")
+        assert actual == expected
+
+    def test_generate_predictions(self, mock_read):
+        outfile = "out.yml"
+        expected = {
+            "predictions": [
+                {
+                    "definition": "Placeholder description for Type",
+                    "display_name": "Type",
+                    "dtype": "category",
+                    "name": "Type",
+                },
+                {
+                    "definition": "Placeholder description for IntCol",
+                    "display_name": "IntCol",
+                    "dtype": "int32",
+                    "name": "IntCol",
+                },
+            ]
+        }
+
+        undertest.generate_dictionary_from_parquet("TESTIN", outfile, section="predictions")
+        actual = load_yaml(outfile)
+
+        mock_read.assert_called_once_with("TESTIN")
+        assert actual == expected
+
+    def test_generate_dict_invalid_section(self, mock_read):
+        section_type = "invalid"
+        with pytest.raises(ValueError, match="not recognized") as err:
+            undertest.generate_dictionary_from_parquet("TESTIN", "out.yml", section=section_type)
+
+        assert section_type in str(err.value)
+
+
+# This is intentionally outside the class to NOT use the shared pd patch
+@pytest.mark.parametrize("section_type", ["events", "predictions"])
+@patch.object(undertest.pd, "read_parquet", return_value=pd.DataFrame())
+def test_generate_dict_no_data_raises_error(mock_read, section_type, tmp_as_current):
+    with pytest.raises(ValueError, match="No data loaded"):
+        undertest.generate_dictionary_from_parquet("TESTIN", "out.yml", section=section_type)
diff --git a/tests/configuration/test_provider.py b/tests/configuration/test_provider.py
index 049df125..fc5d1dac 100644
--- a/tests/configuration/test_provider.py
+++ b/tests/configuration/test_provider.py
@@ -42,3 +42,17 @@ def test_testconfig_is_valid_simple_object(self, property, value, res):
     def test_testconfig_uses_tmp(self, tmp_path, res):
         config = undertest.ConfigProvider(res / TEST_CONFIG)
         assert config.output_dir == tmp_path / "outputs"
+
+    @pytest.mark.parametrize(
+        "outputs, output_list",
+        [
+            (["Score2"], ["Score", "Score2"]),
+            (["Score"], ["Score"]),
+            (["Score2", "Score"], ["Score2", "Score"]),
+            (["Score1", "Score2"], ["Score", "Score1", "Score2"]),
+        ],
+    )
+    def test_provider_groups_primary_output_with_output_list(self, outputs, output_list, res):
+        config = undertest.ConfigProvider(res / TEST_CONFIG)
+        config.usage.outputs = outputs
+        assert config.output_list == output_list
diff --git a/tests/controls/test_explore.py b/tests/controls/test_explore.py
index e79df06e..c7fe5bc2 100644
--- a/tests/controls/test_explore.py
+++ b/tests/controls/test_explore.py
@@ -1,15 +1,19 @@
+from unittest.mock import MagicMock, Mock, patch
+
 import ipywidgets
 import pytest
 
 import seismometer.controls.explore as undertest
+from seismometer import seismogram
 
 
+# region Test Base Classes
 class TestUpdatePlotWidget:
     def test_init(self):
         widget = undertest.UpdatePlotWidget()
         assert widget.plot_button.description == undertest.UpdatePlotWidget.UPDATE_PLOTS
         assert widget.plot_button.disabled is False
-        assert widget.code_checkbox.description == "show code"
+        assert widget.code_checkbox.description == widget.SHOW_CODE
         assert widget.show_code is False
 
     def test_plot_button_click(self):
@@ -58,6 +62,134 @@ def test_base_class(self, caplog):
         with pytest.raises(NotImplementedError):
             undertest.ExplorationWidget("ExploreTest", option_widget, lambda x: x)
 
+    @pytest.mark.parametrize(
+        "plot_module,plot_code",
+        [
+            ("__main__", "plot_something(False)"),
+            ("seismometer._api", "sm.plot_something(False)"),
+            ("something_else", "something_else.plot_something(False)"),
+        ],
+    )
+    def test_args_subclass(self, plot_module, plot_code):
+        option_widget = ipywidgets.Checkbox(description="ClickMe")
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_something"
+        plot_function.__module__ = plot_module
+
+        class ExploreFake(undertest.ExplorationWidget):
+            def __init__(self):
+                super().__init__("Fake Explorer", option_widget, plot_function)
+
+            def generate_plot_args(self) -> tuple[tuple, dict]:
+                return [self.option_widget.value], {}
+
+        widget = ExploreFake()
+        assert widget.center.outputs[0]["data"]["text/plain"] == "'some result'"
+        plot_function.assert_called_once_with(False)
+        assert widget.current_plot_code == plot_code
+        assert widget.show_code is False
+
+    def test_kwargs_subclass(self):
+        option_widget = ipywidgets.Checkbox(description="ClickMe")
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_something"
+        plot_function.__module__ = "test_explore"
+
+        class ExploreFake(undertest.ExplorationWidget):
+            def __init__(self):
+                super().__init__("Fake Explorer", option_widget, plot_function)
+
+            def generate_plot_args(self) -> tuple[tuple, dict]:
+                return [], {"checkbox": self.option_widget.value}
+
+        widget = ExploreFake()
+        assert widget.center.outputs[0]["data"]["text/plain"] == "'some result'"
+        plot_function.assert_called_once_with(checkbox=False)
+        assert widget.current_plot_code == "test_explore.plot_something(checkbox=False)"
+        assert widget.show_code is False
+
+    def test_args_kwargs_subclass(self):
+        option_widget = ipywidgets.Checkbox(description="ClickMe")
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_something"
+        plot_function.__module__ = "test_explore"
+
+        class ExploreFake(undertest.ExplorationWidget):
+            def __init__(self):
+                super().__init__("Fake Explorer", option_widget, plot_function)
+
+            def generate_plot_args(self) -> tuple[tuple, dict]:
+                return ["test"], {"checkbox": self.option_widget.value}
+
+        widget = ExploreFake()
+        assert widget.center.outputs[0]["data"]["text/plain"] == "'some result'"
+        plot_function.assert_called_once_with("test", checkbox=False)
+        assert widget.current_plot_code == "test_explore.plot_something('test', checkbox=False)"
+        assert widget.show_code is False
+
+    def test_exception_plot_code_subclass(self):
+        option_widget = ipywidgets.Checkbox(description="ClickMe")
+
+        def plot_something(*args, **kwargs):
+            raise ValueError("Test Exception")
+
+        class ExploreFake(undertest.ExplorationWidget):
+            def __init__(self):
+                super().__init__("Fake Explorer", option_widget, plot_something)
+
+            def generate_plot_args(self) -> tuple[tuple, dict]:
+                return ["test"], {"checkbox": self.option_widget.value}
+
+        widget = ExploreFake()
+        assert "Traceback" in widget.center.outputs[0]["data"]["text/plain"]
+        assert "Test Exception" in widget.center.outputs[0]["data"]["text/plain"]
+
+    def test_no_initial_plot_subclass(self):
+        option_widget = ipywidgets.Checkbox(description="ClickMe")
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_something"
+        plot_function.__module__ = "test_explore"
+
+        class ExploreFake(undertest.ExplorationWidget):
+            def __init__(self):
+                super().__init__("Fake Explorer", option_widget, plot_function, initial_plot=False)
+
+            def generate_plot_args(self) -> tuple[tuple, dict]:
+                return ["test"], {"checkbox": self.option_widget.value}
+
+        widget = ExploreFake()
+        widget.center.outputs == []
+        plot_function.assert_not_called()
+        assert widget.current_plot_code == ExploreFake.NO_CODE_STRING
+        assert widget.show_code is False
+
+    @pytest.mark.parametrize("show_code", [True, False])
+    def test_toggle_code_callback(self, show_code, capsys):
+        option_widget = ipywidgets.Checkbox(description="ClickMe")
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_something"
+        plot_function.__module__ = "test_explore"
+
+        class ExploreFake(undertest.ExplorationWidget):
+            def __init__(self):
+                super().__init__("Fake Explorer", option_widget, plot_function)
+
+            def generate_plot_args(self) -> tuple[tuple, dict]:
+                return ["test"], {"checkbox": self.option_widget.value}
+
+        widget = ExploreFake()
+        widget.show_code = show_code
+        widget.code_output = MagicMock()
+        widget._on_plot_button_click()
+        stdout = capsys.readouterr().out
+        assert "some result" in stdout
+        code_in_output = "test_explore.plot_something" in stdout.split("\n")[-2]
+        assert code_in_output == show_code
+
+
+# endregion
+# region Test Model Options Widgets
+
 
 class TestModelOptionsWidget:
     def test_init_with_all_options(self):
@@ -265,3 +397,238 @@ def test_disable(self):
         assert widget.fairness_slider.disabled
         assert widget.fairness_list.disabled
         assert widget.disabled
+
+
+class TestModelScoreComparisonOptionsWidget:
+    def test_init(self):
+        widget = undertest.ModelScoreComparisonOptionsWidget(
+            target_names=["T1", "T2"], score_names=["S1", "S2", "S3", "S4"]
+        )
+        assert len(widget.children) == 2
+        assert "Model Options" in widget.title.value
+        assert widget.target == "T1"
+        assert widget.scores == (
+            "S1",
+            "S2",
+        )
+
+    def test_disable(self):
+        widget = undertest.ModelScoreComparisonOptionsWidget(target_names=["T1", "T2"], score_names=["S1", "S2"])
+        widget.disabled = True
+        assert widget.target_list.disabled
+        assert widget.score_list.disabled
+        assert widget.disabled
+
+    def test_init_per_context(self):
+        widget = undertest.ModelScoreComparisonOptionsWidget(
+            target_names=["T1", "T2"], score_names=["S1", "S2", "S3", "S4"], per_context=True
+        )
+        assert len(widget.children) == 3
+        assert "Model Options" in widget.title.value
+        assert widget.target == "T1"
+        assert widget.scores == (
+            "S1",
+            "S2",
+        )
+        assert widget.group_scores is True
+
+    def test_disable_per_context(self):
+        widget = undertest.ModelScoreComparisonOptionsWidget(
+            target_names=["T1", "T2"], score_names=["S1", "S2"], per_context=False
+        )
+        assert widget.group_scores is False
+        widget.disabled = True
+        assert widget.target_list.disabled
+        assert widget.score_list.disabled
+        assert widget.per_context_checkbox.disabled
+        assert widget.disabled
+
+
+class TestModelTargetComparisonOptionsWidget:
+    def test_init(self):
+        widget = undertest.ModelTargetComparisonOptionsWidget(
+            target_names=["T1", "T2", "T3"], score_names=["S1", "S2"]
+        )
+        assert len(widget.children) == 2
+        assert "Model Options" in widget.title.value
+        assert widget.targets == (
+            "T1",
+            "T2",
+        )
+        assert widget.score == "S1"
+
+    def test_disable(self):
+        widget = undertest.ModelTargetComparisonOptionsWidget(target_names=["T1", "T2"], score_names=["S1", "S2"])
+        widget.disabled = True
+        assert widget.target_list.disabled
+        assert widget.score_list.disabled
+        assert widget.disabled
+
+    def test_init_per_context(self):
+        widget = undertest.ModelTargetComparisonOptionsWidget(
+            target_names=["T1", "T2", "T3"], score_names=["S1", "S2"], per_context=True
+        )
+        assert len(widget.children) == 3
+        assert "Model Options" in widget.title.value
+        assert widget.targets == (
+            "T1",
+            "T2",
+        )
+        assert widget.score == "S1"
+        assert widget.group_scores is True
+
+    def test_disable_per_context(self):
+        widget = undertest.ModelTargetComparisonOptionsWidget(
+            target_names=["T1", "T2"], score_names=["S1", "S2"], per_context=False
+        )
+        assert widget.group_scores is False
+        widget.disabled = True
+        assert widget.target_list.disabled
+        assert widget.score_list.disabled
+        assert widget.per_context_checkbox.disabled
+        assert widget.disabled
+
+
+class TestModelScoreComparisonAndCohortsWidget:
+    def test_init(self):
+        widget = undertest.ModelScoreComparisonAndCohortsWidget(
+            cohort_groups={"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]},
+            target_names=["T1", "T2"],
+            score_names=["S1", "S2", "S3", "S4"],
+        )
+        assert len(widget.children) == 2
+        assert widget.cohorts == {}
+        assert widget.target == "T1"
+        assert widget.scores == (
+            "S1",
+            "S2",
+        )
+        assert widget.group_scores is False
+
+    def test_disable(self):
+        widget = undertest.ModelScoreComparisonAndCohortsWidget(
+            cohort_groups={"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]},
+            target_names=["T1", "T2"],
+            score_names=["S1", "S2"],
+        )
+        widget.disabled = True
+        assert widget.model_options.disabled
+        assert widget.cohort_list.disabled
+        assert widget.disabled
+
+
+class TestModelTargetComparisonAndCohortsWidget:
+    def test_init(self):
+        widget = undertest.ModelTargetComparisonAndCohortsWidget(
+            cohort_groups={"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]},
+            target_names=["T1", "T2", "T3"],
+            score_names=["S1", "S2"],
+        )
+        assert len(widget.children) == 2
+        assert widget.cohorts == {}
+        assert widget.targets == (
+            "T1",
+            "T2",
+        )
+        assert widget.score == "S1"
+        assert widget.group_scores is False
+
+    def test_disable(self):
+        widget = undertest.ModelTargetComparisonAndCohortsWidget(
+            cohort_groups={"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]},
+            target_names=["T1", "T2"],
+            score_names=["S1", "S2"],
+        )
+        widget.disabled = True
+        assert widget.model_options.disabled
+        assert widget.cohort_list.disabled
+        assert widget.disabled
+
+
+# endregion
+# region Test Exploration Widgets
+class TestExplorationSubpopulationWidget:
+    @patch.object(seismogram, "Seismogram", return_value=Mock())
+    def test_init(self, mock_seismo):
+        fake_seismo = mock_seismo()
+        fake_seismo.available_cohort_groups = {"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]}
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_function"
+        plot_function.__module__ = "test_explore"
+
+        widget = undertest.ExplorationSubpopulationWidget(title="Subpopulation", plot_function=plot_function)
+
+        assert widget.disabled is False
+        assert widget.update_plot_widget.disabled
+        assert widget.current_plot_code == "test_explore.plot_function({})"
+        plot_function.assert_called_once_with({})  # default value
+
+    @patch.object(seismogram, "Seismogram", return_value=Mock())
+    def test_option_update(self, mock_seismo):
+        fake_seismo = mock_seismo()
+        fake_seismo.available_cohort_groups = {"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]}
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_function"
+        plot_function.__module__ = "test_explore"
+
+        widget = undertest.ExplorationSubpopulationWidget(title="Subpopulation", plot_function=plot_function)
+
+        widget.option_widget.value = {
+            "C2": [
+                "C2.1",
+            ]
+        }
+        widget.update_plot()
+        plot_function.assert_called_with(
+            {
+                "C2": [
+                    "C2.1",
+                ]
+            }
+        )  # updated value
+
+
+class TestExplorationModelSubgroupEvaluationWidget:
+    @patch.object(seismogram, "Seismogram", return_value=Mock())
+    def test_init(self, mock_seismo):
+        fake_seismo = mock_seismo()
+        fake_seismo.available_cohort_groups = {"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]}
+        fake_seismo.thresholds = [0.1, 0.2]
+        fake_seismo.target_cols = ["T1", "T2"]
+        fake_seismo.output_list = ["S1", "S2"]
+
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_function"
+        plot_function.__module__ = "test_explore"
+
+        widget = undertest.ExplorationModelSubgroupEvaluationWidget(
+            title="Unit Test Title", plot_function=plot_function
+        )
+
+        assert widget.disabled is False
+        assert widget.update_plot_widget.disabled
+        assert widget.current_plot_code == "test_explore.plot_function({}, 'T1', 'S1', [0.2, 0.1], per_context=False)"
+        plot_function.assert_called_once_with({}, "T1", "S1", [0.2, 0.1], per_context=False)  # default value
+
+    @patch.object(seismogram, "Seismogram", return_value=Mock())
+    def test_option_update(self, mock_seismo):
+        fake_seismo = mock_seismo()
+        fake_seismo.available_cohort_groups = {"C1": ["C1.1", "C1.2"], "C2": ["C2.1", "C2.2"]}
+        fake_seismo.thresholds = [0.1, 0.2]
+        fake_seismo.target_cols = ["T1", "T2"]
+        fake_seismo.output_list = ["S1", "S2"]
+
+        plot_function = Mock(return_value="some result")
+        plot_function.__name__ = "plot_function"
+        plot_function.__module__ = "test_explore"
+
+        widget = undertest.ExplorationModelSubgroupEvaluationWidget(
+            title="Unit Test Title", plot_function=plot_function
+        )
+
+        widget.option_widget.cohort_list.value = {"C2": ("C2.1",)}
+        widget.update_plot()
+        plot_function.assert_called_with({"C2": ("C2.1",)}, "T1", "S1", [0.2, 0.1], per_context=False)  # updated value
+
+
+# endregion
diff --git a/tests/controls/test_selection.py b/tests/controls/test_selection.py
index 71cf09b8..0cf52a6f 100644
--- a/tests/controls/test_selection.py
+++ b/tests/controls/test_selection.py
@@ -1,3 +1,5 @@
+import pytest
+
 import seismometer.controls.selection as undertest
 
 
@@ -48,24 +50,30 @@ def test_disabled(self):
         assert not widget.button_from_option["c"].disabled
 
 
+@pytest.mark.parametrize("show_all", [True, False])
 class TestMultiSelectionListWidget:
-    def test_init(self):
+    def test_init(self, show_all):
         widget = undertest.MultiSelectionListWidget(
             options={"a": ["a", "b", "c"], "b": ["a", "c"]},
             values={"a": ["a", "b"], "b": ["c"]},
             title="title",
             border=True,
+            show_all=show_all,
         )
         assert widget.title == "title"
         assert widget.value == {"a": ("a", "b"), "b": ("c",)}
 
-    def test_init_no_value(self):
-        widget = undertest.MultiSelectionListWidget(options={"a": ["a", "b", "c"], "b": ["a", "c"]})
+    def test_init_no_value(self, show_all):
+        widget = undertest.MultiSelectionListWidget(
+            options={"a": ["a", "b", "c"], "b": ["a", "c"]},
+            show_all=show_all,
+        )
         assert widget.value == {}
 
-    def test_value_propagation(self):
+    def test_value_propagation(self, show_all):
         widget = undertest.MultiSelectionListWidget(
             options={"a": ["a", "b", "c"], "b": ["a", "c"], "d": ["a", "b", "c"]},
+            show_all=show_all,
         )
         assert widget.value == {}
         widget.value = {"a": ("a", "b"), "b": ("c",)}
@@ -73,9 +81,10 @@ def test_value_propagation(self):
         assert widget.selection_widgets["b"].value == ("c",)
         assert widget.selection_widgets["d"].value == ()
 
-    def test_on_subselection_changed(self):
+    def test_on_subselection_changed(self, show_all):
         widget = undertest.MultiSelectionListWidget(
             options={"a": ["a", "b", "c"], "b": ["a", "c"], "d": ["a", "b", "c"]},
+            show_all=show_all,
         )
         assert widget.value == {}
         widget.selection_widgets["a"].value = ("a", "b")
@@ -83,18 +92,20 @@ def test_on_subselection_changed(self):
         assert widget.selection_widgets["b"].value == ()
         assert widget.selection_widgets["d"].value == ()
 
-    def test_display_text(self):
+    def test_display_text(self, show_all):
         widget = undertest.MultiSelectionListWidget(
             options={"a": ["a", "b", "c"], "b": ["a", "c"], "d": ["a", "b", "c"]},
+            show_all=show_all,
         )
         assert widget.value == {}
         assert widget.get_selection_text() == ""
         widget.value = {"a": ("a", "b"), "b": ("c",)}
         assert widget.get_selection_text() == "a: a, b\nb: c"
 
-    def test_disabled(self):
+    def test_disabled(self, show_all):
         widget = undertest.MultiSelectionListWidget(
             options={"a": ["a", "b", "c"], "b": ["a", "c"], "d": ["a", "b", "c"]},
+            show_all=show_all,
         )
         assert not widget.disabled
         widget.disabled = True
@@ -176,3 +187,62 @@ def test_disabled(self):
         assert not widget.dropdown.disabled
         assert not widget.selection_widgets["a"].disabled
         assert not widget.selection_widgets["b"].disabled
+
+
+class TestMultiselectDropdownWidget:
+    def test_init(self):
+        widget = undertest.MultiselectDropdownWidget(options=["a", "b", "c"], value=["a", "b"], title="title")
+        assert widget.title == "title"
+        assert widget.value == ("a", "b")
+        assert widget.get_selection_text() == "title: a, b"
+
+    def test_init_no_value(self):
+        widget = undertest.MultiselectDropdownWidget(options=["a", "b", "c"])
+        assert widget.title is None
+        assert widget.value == ()
+        assert widget.get_selection_text() == ""
+
+    def test_add_all(self):
+        widget = undertest.MultiselectDropdownWidget(options=["a", "b", "c"])
+        widget.dropdown.value = -1
+        assert widget.value == ("a", "b", "c")
+        assert widget.dropdown.value == -2  # reset to default
+
+    def test_value_propagation(self):
+        widget = undertest.MultiselectDropdownWidget(options=["a", "b", "c"])
+        widget.value = ("a", "b")
+        assert len(widget.selection_options.children) == 2
+        assert widget.selection_options.children[0].description == "a"
+        assert widget.selection_options.children[1].description == "b"
+
+    def test_value_propagation_remove_option(self):
+        widget = undertest.MultiselectDropdownWidget(options=["a", "b", "c"], value=["a", "b"], title="title")
+        assert widget.title == "title"
+        widget.selection_options.children[0].click()
+        assert widget.value == ("b",)
+
+    def test_dropdown_changes_value(self):
+        widget = undertest.MultiselectDropdownWidget(options=["a", "b", "c"])
+        widget.dropdown.value = 1  # index of b
+        assert widget.value == ("b",)
+        widget.dropdown.value = 2  # index of c
+        assert widget.value == (
+            "b",
+            "c",
+        )
+        widget.dropdown.value = 0  # index of a
+        assert widget.value == (
+            "a",
+            "b",
+            "c",
+        )
+
+    def test_disabled(self):
+        widget = undertest.MultiselectDropdownWidget(options=["a", "b", "c"])
+        assert not widget.disabled
+        widget.disabled = True
+        assert widget.disabled
+        assert widget.dropdown.disabled
+        widget.disabled = False
+        assert not widget.disabled
+        assert not widget.dropdown.disabled
diff --git a/tests/core/test_io.py b/tests/core/test_io.py
index 1e9bdbac..38ec226f 100644
--- a/tests/core/test_io.py
+++ b/tests/core/test_io.py
@@ -168,6 +168,27 @@ def test_overwrite_ipynb_if_specified(self, tmp_as_current):
         assert actual == self.content
 
 
+class TestYamlWriters:
+    file = Path("test.yml")
+    content = {"topkey": {"key1": "value1"}}
+
+    def test_write_yaml(self, tmp_as_current):
+        undertest.write_yaml(self.content, self.file)
+        assert self.file.read_text().strip() == "topkey:\n  key1: value1"
+
+    def test_write_yaml_fails_if_file_exists(self, tmp_as_current):
+        self.file.touch()
+
+        with pytest.raises(FileExistsError):
+            undertest.write_yaml(self.content, self.file)
+
+    def test_overwrite_yaml_if_specified(self, tmp_as_current):
+        self.file.touch()
+
+        undertest.write_yaml(self.content, self.file, overwrite=True)
+        assert self.file.read_text().strip() == "topkey:\n  key1: value1"
+
+
 def test_write_new_file_in_nonexistent_directory(tmp_as_current):
     file = Path("nonexistent_directory") / "new_file.txt"
     undertest._write(lambda content, fo: fo.write(content), "test content", file, overwrite=False)