From dbbbe54f2670dce19d8a282282da011868a0ce7b Mon Sep 17 00:00:00 2001 From: Robert Jackson Date: Tue, 14 Jul 2026 19:33:26 -0500 Subject: [PATCH 1/2] ADD: Time series plot of labels Add plot_label_timeseries to lars.util for visualising a categorical label time series, with codebook-ordered y-axis and an optional second label column (e.g. llm_label) overlaid for hand-vs-model comparison. Co-Authored-By: Claude Opus 4.8 --- lars/util/__init__.py | 1 + lars/util/label_timeseries.py | 126 +++++++++++++++++++++++++++++++++ tests/test_label_timeseries.py | 82 +++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 lars/util/label_timeseries.py create mode 100644 tests/test_label_timeseries.py diff --git a/lars/util/__init__.py b/lars/util/__init__.py index fa0864c..d7a1c04 100644 --- a/lars/util/__init__.py +++ b/lars/util/__init__.py @@ -1,2 +1,3 @@ from .confusion_matrix import plot_confusion_matrix, calculate_cohen_kappa # noqa: F401 from .image_grid import plot_label_images # noqa: F401 +from .label_timeseries import plot_label_timeseries # noqa: F401 diff --git a/lars/util/label_timeseries.py b/lars/util/label_timeseries.py new file mode 100644 index 0000000..cb491d2 --- /dev/null +++ b/lars/util/label_timeseries.py @@ -0,0 +1,126 @@ +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import pandas as pd + +# Codebook label order, from clear sky to organised convection. Used to order +# the categorical y-axis when the labels present in the data are a subset of +# these. Any labels not listed here are appended afterwards in the order they +# first appear. +DEFAULT_LABEL_ORDER = [ + "No Precipitation", + "Stratiform Precipitation", + "Isolated Convection", + "Mesoscale Convective System", + "Ambiguous", + "UNKNOWN", +] + + +def _ordered_categories(values, order): + """Return the unique *values* ordered by *order*, appending extras at end.""" + present = list(pd.unique(values.dropna())) + ordered = [c for c in order if c in present] + ordered += [c for c in present if c not in ordered] + return ordered + + +def plot_label_timeseries( + df, + time_col="time", + label_col="label", + pred_col=None, + order=None, + time_format="%m/%d/%y %H:%M", + ax=None, + output_path=None, +): + """ + Plot a time series of categorical labels. + + Each label class occupies a row on the y-axis and the sequence of labels is + drawn as a stepped line with markers, so runs of the same class and + transitions between classes are both easy to read. Optionally a second + label column (e.g. model predictions) can be overlaid for comparison. + + Parameters + ---------- + df : pd.DataFrame + DataFrame containing at least ``time_col`` and ``label_col``. + time_col : str + Column holding the timestamps. Parsed with ``pd.to_datetime``. + Default is ``"time"``. + label_col : str + Column holding the (categorical) labels to plot. Default is ``"label"``. + pred_col : str or None + Optional second label column to overlay on the same axes, useful for + comparing hand labels against model predictions. Default is ``None``. + order : list of str or None + Explicit top-to-bottom ordering of the label classes on the y-axis. + When ``None``, classes are ordered using the codebook order in + ``DEFAULT_LABEL_ORDER`` with any unlisted classes appended. + time_format : str or None + ``strptime`` format string passed to ``pd.to_datetime`` for parsing + ``time_col``. Defaults to ``"%m/%d/%y %H:%M"`` (the label CSV format). + Set to ``None`` to let pandas infer the format. + ax : matplotlib axis handle or None + Axis to plot on. When ``None`` the current axis is used. + output_path : str or None + When given, the figure is saved to this path (and left open for + further use). Default is ``None``. + + Returns + ------- + matplotlib.axes.Axes + The axis the time series was drawn on. + + Raises + ------ + ValueError + If ``df`` is empty or a requested column is missing. + """ + for col in (time_col, label_col) + ((pred_col,) if pred_col else ()): + if col not in df.columns: + raise ValueError(f"Column '{col}' not found in DataFrame.") + if len(df) == 0: + raise ValueError("Cannot plot an empty DataFrame.") + + df = df.copy() + df[time_col] = pd.to_datetime(df[time_col], format=time_format) + df = df.sort_values(time_col) + + if order is None: + label_values = df[label_col] + if pred_col: + label_values = pd.concat([label_values, df[pred_col]]) + order = _ordered_categories(label_values, DEFAULT_LABEL_ORDER) + + positions = {label: i for i, label in enumerate(order)} + + if ax is None: + ax = plt.gca() + + def _plot(col, **kwargs): + y = df[col].map(positions) + ax.plot(df[time_col], y, drawstyle="steps-post", marker="o", + markersize=4, **kwargs) + + _plot(label_col, label=label_col, color="tab:blue") + if pred_col: + _plot(pred_col, label=pred_col, color="tab:orange", alpha=0.7) + ax.legend(loc="best") + + ax.set_yticks(range(len(order))) + ax.set_yticklabels(order) + ax.set_ylim(-0.5, len(order) - 0.5) + ax.set_xlabel("Time") + ax.set_title("Label time series") + ax.grid(True, axis="x", alpha=0.3) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M")) + for tick in ax.get_xticklabels(): + tick.set_rotation(45) + tick.set_horizontalalignment("right") + + if output_path is not None: + ax.figure.savefig(output_path, dpi=150, bbox_inches="tight") + + return ax diff --git a/tests/test_label_timeseries.py b/tests/test_label_timeseries.py new file mode 100644 index 0000000..40cef54 --- /dev/null +++ b/tests/test_label_timeseries.py @@ -0,0 +1,82 @@ +import io +import pytest +import pandas as pd +import matplotlib +import matplotlib.pyplot as plt + +matplotlib.use("Agg") + +CSV_DATA = """\ +time,label,llm_label +3/4/25 0:00,No Precipitation,No Precipitation +3/4/25 0:12,No Precipitation,Stratiform Precipitation +3/4/25 0:24,Stratiform Precipitation,Stratiform Precipitation +3/4/25 0:36,Stratiform Precipitation,Isolated Convection +3/4/25 0:48,Isolated Convection,Isolated Convection +3/4/25 1:00,Mesoscale Convective System,Mesoscale Convective System +""" + + +@pytest.fixture +def sample_df(): + return pd.read_csv(io.StringIO(CSV_DATA)) + + +@pytest.fixture(autouse=True) +def close_figures(): + yield + plt.close("all") + + +@pytest.mark.mpl_image_compare(tolerance=60) +def test_plotting(sample_df): + from lars.util.label_timeseries import plot_label_timeseries + + fig, ax = plt.subplots() + plot_label_timeseries(sample_df, ax=ax) + return fig + + +def test_yticklabels_use_codebook_order(sample_df): + from lars.util.label_timeseries import plot_label_timeseries + + _, ax = plt.subplots() + plot_label_timeseries(sample_df, ax=ax) + labels = [t.get_text() for t in ax.get_yticklabels()] + assert labels == [ + "No Precipitation", + "Stratiform Precipitation", + "Isolated Convection", + "Mesoscale Convective System", + ] + + +def test_overlaying_predictions_adds_legend(sample_df): + from lars.util.label_timeseries import plot_label_timeseries + + _, ax = plt.subplots() + plot_label_timeseries(sample_df, pred_col="llm_label", ax=ax) + assert ax.get_legend() is not None + assert len(ax.lines) == 2 + + +def test_saves_output(tmp_path, sample_df): + from lars.util.label_timeseries import plot_label_timeseries + + out = tmp_path / "ts.png" + plot_label_timeseries(sample_df, output_path=str(out)) + assert out.exists() + + +def test_missing_column_raises(sample_df): + from lars.util.label_timeseries import plot_label_timeseries + + with pytest.raises(ValueError): + plot_label_timeseries(sample_df, label_col="does_not_exist") + + +def test_empty_dataframe_raises(): + from lars.util.label_timeseries import plot_label_timeseries + + with pytest.raises(ValueError): + plot_label_timeseries(pd.DataFrame({"time": [], "label": []})) From 2888ef754ffd82cd62058c41cba97f03c77fd3a1 Mon Sep 17 00:00:00 2001 From: Robert Jackson Date: Tue, 11 Aug 2026 12:40:34 -0500 Subject: [PATCH 2/2] Various commits --- lars/preprocessing/__init__.py | 2 +- lars/preprocessing/labels.py | 57 +++++++++++++++++++++++++++++++ lars/util/confusion_matrix.py | 24 +++++++++---- tests/test_confusion_matrix.py | 20 +++++++++++ tests/test_validation_tracking.py | 1 - 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/lars/preprocessing/__init__.py b/lars/preprocessing/__init__.py index 4e8770d..f5a0d98 100644 --- a/lars/preprocessing/__init__.py +++ b/lars/preprocessing/__init__.py @@ -1,2 +1,2 @@ from .radar_preprocessing import preprocess_radar_data # noqa: F401 -from .labels import load_labels, save_labels, change_file_path, copy_labels, apply_criteria_to_labels # noqa: F401 \ No newline at end of file +from .labels import load_labels, save_labels, change_file_path, copy_labels, apply_criteria_to_labels, combine_labels # noqa: F401 \ No newline at end of file diff --git a/lars/preprocessing/labels.py b/lars/preprocessing/labels.py index bd9d55b..6955944 100644 --- a/lars/preprocessing/labels.py +++ b/lars/preprocessing/labels.py @@ -153,6 +153,63 @@ def apply_criteria_to_labels(df, criteria, label_column='label', return df +def combine_labels(csv_files, source_names, label_column='label', match_on='file_path'): + """ + Combine labels from multiple CSV files (human or AI) into one long-format + DataFrame, tagged with each file's source. + + Each CSV in ``csv_files`` is loaded and stacked into a single DataFrame + with one row per (item, source) pair, suitable for downstream groupby or + majority-vote analysis across labelers. + + Parameters + ---------- + csv_files (list of str): Paths to the label CSV files to combine. + source_names (list of str): Name identifying the labeler/source of each + file in ``csv_files``, in the same order. Populates the 'source' + column of the output. + label_column (str): Name of the column containing labels in each input + file. Default 'label'. + match_on (str): Either 'time' (match on the 'time' column, or the row + index if no 'time' column is present) or 'file_path' (match on the + basename of the 'file_path' column). Default 'file_path'. + + Returns + ------- + pd.DataFrame + Long-format DataFrame with columns [match_on, label_column, 'source'], + one row per (item, source) pair loaded from the input files. + + Raises + ------ + ValueError + If ``csv_files`` and ``source_names`` have different lengths, or + ``match_on`` is not 'time' or 'file_path'. + """ + if len(csv_files) != len(source_names): + raise ValueError("csv_files and source_names must have the same length") + if match_on not in ('time', 'file_path'): + raise ValueError("match_on must be either 'time' or 'file_path'") + + combined = [] + for csv_file, source_name in zip(csv_files, source_names): + df = load_labels(csv_file) + if match_on == 'file_path': + key = df['file_path'].apply(os.path.basename) + elif 'time' in df.columns: + key = df['time'] + else: + key = df.index.to_series(index=df.index) + print(df) + combined.append(pd.DataFrame({ + match_on: key.values, + label_column: df[label_column].values, + 'source': source_name, + })) + + return pd.concat(combined, ignore_index=True) + + def save_labels(label_df, output_file): """ Save labels to a CSV file. diff --git a/lars/util/confusion_matrix.py b/lars/util/confusion_matrix.py index e019673..493a03e 100644 --- a/lars/util/confusion_matrix.py +++ b/lars/util/confusion_matrix.py @@ -2,7 +2,8 @@ from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay, cohen_kappa_score from sklearn.preprocessing import LabelEncoder -def plot_confusion_matrix(df, label_col='label', pred_col='llm_label', normalize=None, ax=None): +def plot_confusion_matrix(df, label_col='label', pred_col='llm_label', normalize=None, ax=None, + x_label=None, y_label=None): """ Plot a confusion matrix using true and predicted labels from a DataFrame. @@ -13,23 +14,34 @@ def plot_confusion_matrix(df, label_col='label', pred_col='llm_label', normalize pred_col (str): Column name for predicted labels. normalize (str or None): Normalization mode for confusion matrix. ax (matplotlib axis handle): The axis handle to plot on. Set to None to use the current axis. + x_label (str or None): Label for the x-axis. + y_label (str or None): Label for the y-axis. Returns ------- None """ + true_values = df[label_col].str.lower() + pred_values = df[pred_col].str.lower() + labels = sorted(set(true_values) | set(pred_values)) + le = LabelEncoder() - true_labels = le.fit_transform(df[label_col].str.lower()) - pred_labels = le.transform(df[pred_col].str.lower()) + le.fit(labels) + true_labels = le.transform(true_values) + pred_labels = le.transform(pred_values) + if ax is None: ax = plt.gca() cm = confusion_matrix(true_labels, pred_labels, normalize=normalize) - disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=le.classes_,) - - + disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=le.classes_) + disp.plot(ax=ax, cmap=plt.cm.Blues, xticks_rotation=45) ax.set_title('Confusion Matrix') + if x_label is not None: + ax.set_xlabel(x_label) + if y_label is not None: + ax.set_ylabel(y_label) def calculate_cohen_kappa(df, label_col='label', pred_col='llm_label'): diff --git a/tests/test_confusion_matrix.py b/tests/test_confusion_matrix.py index 366123d..4ef088f 100644 --- a/tests/test_confusion_matrix.py +++ b/tests/test_confusion_matrix.py @@ -120,3 +120,23 @@ def test_normalized_confusion_matrix_values(sample_df): plot_confusion_matrix(sample_df, normalize="true", ax=ax) actual = ax.images[0].get_array() np.testing.assert_array_almost_equal(actual, expected) + + +def test_combines_labels_from_true_and_pred(): + from lars.util.confusion_matrix import plot_confusion_matrix + + df = pd.DataFrame({ + "label": ["a", "b", "a"], + "llm_label": ["a", "c", "b"], + }) + + _, ax = plt.subplots() + plot_confusion_matrix(df, ax=ax) + + expected = np.array([ + [1, 1, 0], + [0, 0, 1], + [0, 0, 0], + ]) + actual = ax.images[0].get_array() + np.testing.assert_array_equal(actual, expected) diff --git a/tests/test_validation_tracking.py b/tests/test_validation_tracking.py index 2ed19c9..85a9470 100644 --- a/tests/test_validation_tracking.py +++ b/tests/test_validation_tracking.py @@ -16,7 +16,6 @@ def test_color_criteria_from_codebook_covers_all_labels(): from lars.nepho.inference import color_criteria_from_codebook rules = color_criteria_from_codebook(CODEBOOK_PATH) - print(rules) assert set(rules) == { "No Precipitation", "Stratiform Precipitation",