Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lars/preprocessing/__init__.py
Original file line number Diff line number Diff line change
@@ -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
from .labels import load_labels, save_labels, change_file_path, copy_labels, apply_criteria_to_labels, combine_labels # noqa: F401
57 changes: 57 additions & 0 deletions lars/preprocessing/labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lars/util/__init__.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 18 additions & 6 deletions lars/util/confusion_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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'):
Expand Down
126 changes: 126 additions & 0 deletions lars/util/label_timeseries.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/test_confusion_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
82 changes: 82 additions & 0 deletions tests/test_label_timeseries.py
Original file line number Diff line number Diff line change
@@ -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": []}))
1 change: 0 additions & 1 deletion tests/test_validation_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading