Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Feature Importance Investigation using Random Forest Model

ARM Logo

Feature Importance Investigation using Random Forest Model

# ============================================================
# STANDALONE MACHINE LEARNING SECTION
# OBS vs WRF/LASSO vs DP-SCREAM
#
# Goal:
#   Rank which variables are most associated with each model's
#   low-cloud-fraction bias.
#
# Target:
#   cf_bias = model_low_cloud_fraction - observed_low_cloud_fraction
#
# Design choices (based on prior discussion):
#   - Feature sets are CURATED, not auto-selected, to avoid
#     state-vs-bias redundancy and quasi-tautological features
#     (e.g., qc/qr mean are too close to cloud fraction itself).
#   - WRF and DP-SCREAM share a core set of environmental-bias
#     features so their importance bars are directly comparable.
#   - DP-SCREAM gets additional process diagnostics (tendencies,
#     fluxes, TKE) that WRF does not expose.
#   - RH biases (surface + BL) are included since RH is the
#     proximal driver of cloud formation.
# ============================================================

import numpy as np
import pandas as pd
import xarray as xr
import matplotlib.pyplot as plt

from pathlib import Path
from glob import glob
from itertools import compress

from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import LeaveOneGroupOut
from sklearn.metrics import r2_score, mean_squared_error
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.inspection import permutation_importance


# ============================================================
# 0. SETTINGS
# ============================================================

BASE_LASSO = "/data/project/ARM_Summer_School_2026/data/modeling/lasso"
BASE_DPSCREAM = "/data/project/ARM_Summer_School_2026/data/modeling/dpscream"

CASES = ["20180709", "20190517", "20190929"]
CASE_LABELS = ["2018-07-09", "2019-05-17", "2019-09-29"]
CASE_COLORS = ["#1f77b4", "#d62728", "#2ca02c"]

ML_CASE_LABELS = CASE_LABELS
ML_CASE_COLORS = CASE_COLORS

UTC_TO_LST = pd.Timedelta(hours=-6)   # SGP is ~ -97.5 deg lon; solar LST ~ UTC-6.5h.
                                       # Using -6 (CST) is the ARM convention.
RANDOM_STATE = 42

OUTDIR = Path("./cloud_model_eval_outputs")
OUTDIR.mkdir(exist_ok=True)

TARGET_COL = "cf_bias"

N_ESTIMATORS = 500
N_REPEATS_PERM = 100


# ============================================================
# 1. SMALL HELPERS
# ============================================================

def safe_display(obj):
    try:
        display(obj)
    except NameError:
        print(obj)


def to_1d(x):
    return np.asarray(x).ravel()


def to_lst(t):
    return pd.DatetimeIndex(pd.to_datetime(t)) + UTC_TO_LST


def safe_compute(da):
    try:
        return da.compute()
    except Exception:
        return da


def qv_to_gkg_values(values):
    """
    Convert qv to g/kg if it looks like kg/kg.
    """
    arr = np.asarray(values, dtype=float)
    finite = arr[np.isfinite(arr)]

    if len(finite) == 0:
        return arr

    med = np.nanmedian(finite)

    if abs(med) < 1:
        return arr * 1000.0
    else:
        return arr


def qv_to_gkg_da(da):
    """
    Same conversion but for xarray DataArray.
    Uses the full-array median rather than a single point.
    """
    if da is None:
        return None

    try:
        med = float(da.median(skipna=True).compute().values)
    except Exception:
        try:
            med = float(np.nanmedian(np.asarray(da.values)))
        except Exception:
            return da

    if not np.isfinite(med):
        return da

    if abs(med) < 1:
        return da * 1000.0
    else:
        return da


def interp_da_to_time(source_da, target_time):
    """
    Interpolate a 1D time DataArray to target_time.
    """
    target_time = pd.to_datetime(target_time)

    if source_da is None:
        return np.full(len(target_time), np.nan)

    source_da = safe_compute(source_da)

    if "time" not in source_da.coords:
        return np.full(len(target_time), np.nan)

    t_src = pd.to_datetime(source_da["time"].values)
    y_src = to_1d(source_da.values)

    if len(t_src) == 0 or len(y_src) == 0:
        return np.full(len(target_time), np.nan)

    return np.interp(
        target_time.astype("int64"),
        t_src.astype("int64"),
        y_src,
        left=np.nan,
        right=np.nan,
    )


def truncate(arr, n):
    arr = to_1d(arr)
    out = np.full(n, np.nan, dtype=float)
    m = min(n, len(arr))
    out[:m] = arr[:m]
    return out


# ============================================================
# 2. LOAD DATASETS
# ============================================================

# -------------------------
# LASSO / WRF / OBS main files
# -------------------------
lasso_files = glob(
    f"{BASE_LASSO}/*/sgplassodiagconfobsmod*/obs_model/"
    "sgplassodiagobsmod*C1.m1*.nc"
)

lasso_files = list(compress(
    lasso_files,
    [("modz" not in f) and ("5C1" not in f) for f in lasso_files]
))

lasso_files = sorted([
    f for f in lasso_files
    if any(case in f for case in CASES)
])

if len(lasso_files) == 0:
    raise FileNotFoundError("No LASSO main obs_model files found.")

print("LASSO / WRF / OBS main files:")
for f in lasso_files:
    print("  ", f)

ds_all = xr.open_mfdataset(
    lasso_files,
    combine="nested",
    concat_dim="date",
    decode_times=True,
    chunks={},
)


# -------------------------
# DP-SCREAM files
# -------------------------
dp_files = glob(f"{BASE_DPSCREAM}/*.nc")

dp_files = sorted([
    f for f in dp_files
    if any(f"{c[:4]}-{c[4:6]}-{c[6:8]}" in f for c in CASES)
])

if len(dp_files) == 0:
    raise FileNotFoundError("No DP-SCREAM files found.")

print("\nDP-SCREAM files:")
for f in dp_files:
    print("  ", f)

ds_dp = xr.open_mfdataset(
    dp_files,
    combine="nested",
    concat_dim="date",
    decode_times=True,
    chunks={},
)


# ============================================================
# 3. DP-SCREAM DERIVED VARIABLES
# ============================================================

# Track which DP variables actually resolved (vs returned None)
dp_loaded_status = {}


def _record(name, value):
    dp_loaded_status[name] = ("OK" if value is not None else "MISSING")
    return value


def dp_domain_mean(var):
    if var not in ds_dp:
        return None
    da = ds_dp[var]
    if "ncol" in da.dims:
        da = da.mean(dim="ncol", skipna=True)
    return da


def dp_low_layer_mean(var, ztop_m=3000.0):
    if var not in ds_dp or "z_mid" not in ds_dp:
        return None
    da = ds_dp[var]
    z = ds_dp["z_mid"]
    da_low = da.where(z <= ztop_m)
    dims_to_mean = [d for d in da_low.dims if d in ["lev", "ncol"]]
    if len(dims_to_mean) > 0:
        da_low = da_low.mean(dim=dims_to_mean, skipna=True)
    return da_low


def dp_low_layer_max(var, ztop_m=3000.0):
    if var not in ds_dp or "z_mid" not in ds_dp:
        return None
    da = ds_dp[var]
    z = ds_dp["z_mid"]
    da_low = da.where(z <= ztop_m)
    if "lev" in da_low.dims:
        da_low = da_low.max(dim="lev", skipna=True)
    if "ncol" in da_low.dims:
        da_low = da_low.mean(dim="ncol", skipna=True)
    return da_low


def dp_low_cf():
    return dp_low_layer_max("cldfrac_tot", ztop_m=3000.0)


def dp_total_cf():
    if "cldfrac_tot" not in ds_dp:
        return None
    da = ds_dp["cldfrac_tot"]
    if "ncol" in da.dims:
        da = da.mean(dim="ncol", skipna=True)
    if "lev" in da.dims:
        da = da.max(dim="lev", skipna=True)
    return da


def dp_lwp_gm2():
    if "LiqWaterPath" not in ds_dp:
        return None
    da = ds_dp["LiqWaterPath"]
    if "ncol" in da.dims:
        da = da.mean(dim="ncol", skipna=True)
    return da * 1000.0


def dp_real_water_fraction(threshold=1e-7, ztop_m=3000.0):
    """
    Fraction of low-level cells with non-trivial liquid+rain water.
    Kept as a "is the cloud real?" diagnostic. NOT replaced by qc_mean,
    which would be too close to LWP / cloud fraction definition.
    """
    needed = ["qc", "qr", "z_mid"]
    if not all(v in ds_dp for v in needed):
        return None
    water = ds_dp["qc"] + ds_dp["qr"]
    low = ds_dp["z_mid"] <= ztop_m
    real = (water.where(low) > threshold).astype(float)
    dims = [d for d in real.dims if d in ["lev", "ncol"]]
    if len(dims) > 0:
        real = real.mean(dim=dims, skipna=True)
    return real


def dp_max_rh_below_3km():
    """
    Max RH below 3 km. RH is the proximal driver of cloud formation.
    """
    if "RelativeHumidity" not in ds_dp or "z_mid" not in ds_dp:
        return None
    rh = ds_dp["RelativeHumidity"].where(ds_dp["z_mid"] <= 3000.0)
    if "lev" in rh.dims:
        rh = rh.max(dim="lev", skipna=True)
    if "ncol" in rh.dims:
        rh = rh.mean(dim="ncol", skipna=True)

    # Convert fraction to percent if needed, using full-array median
    try:
        med = float(rh.median(skipna=True).compute().values)
        if np.isfinite(med) and med <= 2:
            rh = rh * 100.0
    except Exception:
        pass
    return rh


def dp_bl_mean_from_pbl(var, scale=1.0):
    if var not in ds_dp:
        return None
    if "z_mid" not in ds_dp or "pbl_height" not in ds_dp:
        return None
    da = ds_dp[var]
    z = ds_dp["z_mid"]
    pblh = ds_dp["pbl_height"]
    da_bl = da.where(z <= pblh)
    dims = [d for d in da_bl.dims if d in ["lev", "ncol"]]
    if len(dims) > 0:
        da_bl = da_bl.mean(dim=dims, skipna=True)
    return da_bl * scale


def dp_low_layer_mean_rh():
    """
    Boundary-layer mean RH, in percent.
    """
    if "RelativeHumidity" not in ds_dp:
        return None
    raw = dp_bl_mean_from_pbl("RelativeHumidity", scale=1.0)
    if raw is None:
        return None
    try:
        med = float(raw.median(skipna=True).compute().values)
        if np.isfinite(med) and med <= 2:
            raw = raw * 100.0
    except Exception:
        pass
    return raw


def dp_surface_rh():
    """
    Approximate surface RH from T_2m and qv_2m + ps if needed.
    If a direct surface RH variable exists, use it; otherwise return None
    and we'll let the RH bias features only cover BL.
    """
    for candidate in ["RH_2m", "rh_2m", "RelativeHumidity_2m"]:
        if candidate in ds_dp:
            rh = ds_dp[candidate]
            if "ncol" in rh.dims:
                rh = rh.mean(dim="ncol", skipna=True)
            try:
                med = float(rh.median(skipna=True).compute().values)
                if np.isfinite(med) and med <= 2:
                    rh = rh * 100.0
            except Exception:
                pass
            return rh
    return None


# Resolve all DP variables and record status
dp_low_cf_da          = _record("dp_low_cf",          dp_low_cf())
dp_total_cf_da        = _record("dp_total_cf",        dp_total_cf())
dp_lwp_da             = _record("dp_lwp",             dp_lwp_gm2())

dp_T2m_da             = _record("T_2m",               dp_domain_mean("T_2m"))
dp_qv2m_da            = _record("qv_2m",              qv_to_gkg_da(dp_domain_mean("qv_2m")))
dp_pblh_da            = _record("pbl_height",         dp_domain_mean("pbl_height"))

dp_T_bl_da = None
for candidate in ["T_mid", "T"]:
    if candidate in ds_dp:
        dp_T_bl_da = dp_bl_mean_from_pbl(candidate, scale=1.0)
        break
_record("T_bl (from T_mid/T)", dp_T_bl_da)

dp_qv_bl_da = None
for candidate in ["qv", "qv_mid"]:
    if candidate in ds_dp:
        dp_qv_bl_da = qv_to_gkg_da(dp_bl_mean_from_pbl(candidate, scale=1.0))
        break
_record("qv_bl (from qv/qv_mid)", dp_qv_bl_da)

dp_rh_surface_da      = _record("RH_surface",         dp_surface_rh())
dp_rh_bl_da           = _record("RH_BL",              dp_low_layer_mean_rh())

real_water_fraction_da = _record("real_water_fraction", dp_real_water_fraction())
max_RH_below_3km_da    = _record("max_RH_below_3km",    dp_max_rh_below_3km())

sensible_heat_flux_da = _record("surf_sens_flux",     dp_domain_mean("surf_sens_flux"))
latent_heat_flux_da   = _record("surf_lat_flux",      dp_domain_mean("surface_upward_latent_heat_flux"))

shoc_T_tend_da        = _record("shoc_T_mid_tend",    dp_low_layer_mean("shoc_T_mid_tend"))
p3_T_tend_da          = _record("p3_T_mid_tend",      dp_low_layer_mean("p3_T_mid_tend"))
rad_T_tend_da         = _record("rrtmgp_T_mid_tend",  dp_low_layer_mean("rrtmgp_T_mid_tend"))

shoc_qv_tend_da       = _record("shoc_qv_tend",       dp_low_layer_mean("shoc_qv_tend"))
p3_qv_tend_da         = _record("p3_qv_tend",         dp_low_layer_mean("p3_qv_tend"))

tke_da                = _record("tke",                dp_low_layer_mean("tke"))
eddy_diff_mom_da      = _record("eddy_diff_mom",      dp_low_layer_mean("eddy_diff_mom"))

# Print which DP variables actually loaded - critical for diagnosing the feature set
print("\n" + "=" * 70)
print("DP-SCREAM variable load status:")
print("=" * 70)
for k, v in dp_loaded_status.items():
    print(f"  {k:35s} {v}")


# ============================================================
# 4. LASSO / WRF / OBS HELPERS
# ============================================================

def get_lasso_series(var, case_index, source_type):
    """source_type: 0 = OBS, 1 = WRF/LASSO"""
    if var not in ds_all:
        return None
    return ds_all[var].sel(source_type=source_type).isel(date=case_index)


def extract_lasso_to_obs_time(var, case_index, source_type, obs_time, qv=False):
    da = get_lasso_series(var, case_index, source_type)
    if da is None:
        return np.full(len(obs_time), np.nan)
    vals = interp_da_to_time(da, obs_time)
    if qv:
        vals = qv_to_gkg_values(vals)
    return vals


def extract_dp_to_obs_time(dp_da, case_index, obs_time):
    if dp_da is None:
        return np.full(len(obs_time), np.nan)
    try:
        da = dp_da.isel(date=case_index)
    except Exception:
        return np.full(len(obs_time), np.nan)
    return interp_da_to_time(da, obs_time)


# ============================================================
# 5. BUILD COMPARISON TABLE
# ============================================================

def build_ml_compare_df():
    """
    Build one dataframe with both:
      model = LASSO
      model = DP-SCREAM
    Time grid = OBS low cloud fraction time.
    """
    if "low_cloud_fraction_arscl" not in ds_all:
        raise ValueError("low_cloud_fraction_arscl missing from ds_all.")

    rows = []
    ncase = min(3, ds_all.sizes["date"], ds_dp.sizes["date"])

    for i in range(ncase):
        # OBS cloud fraction defines time grid
        obs_cf_da = get_lasso_series("low_cloud_fraction_arscl", i, 0)
        obs_time = pd.to_datetime(obs_cf_da["time"].values)
        obs_time_lst = to_lst(obs_time)
        n = len(obs_time)

        obs_low_cf = truncate(safe_compute(obs_cf_da).values, n)

        # OBS environmental
        obs_T2m  = extract_lasso_to_obs_time("temperature_surface", i, 0, obs_time)
        obs_qv2m = extract_lasso_to_obs_time("water_vapor_mixing_ratio_surface", i, 0, obs_time, qv=True)
        obs_rh2m = extract_lasso_to_obs_time("rh_surface", i, 0, obs_time)

        obs_T_bl  = extract_lasso_to_obs_time("temperature_boundary_layer", i, 0, obs_time)
        obs_qv_bl = extract_lasso_to_obs_time("water_vapor_mixing_ratio_boundary_layer", i, 0, obs_time, qv=True)
        obs_rh_bl = extract_lasso_to_obs_time("rh_boundary_layer", i, 0, obs_time)

        obs_lcl = extract_lasso_to_obs_time("lcl", i, 0, obs_time)
        obs_cbh = extract_lasso_to_obs_time("cloud_base_height", i, 0, obs_time)
        obs_lwp = extract_lasso_to_obs_time("lwp", i, 0, obs_time)

        # WRF/LASSO environmental
        wrf_low_cf = extract_lasso_to_obs_time("low_cloud_fraction_arscl", i, 1, obs_time)
        wrf_lwp    = extract_lasso_to_obs_time("lwp", i, 1, obs_time)

        wrf_T2m  = extract_lasso_to_obs_time("temperature_surface", i, 1, obs_time)
        wrf_qv2m = extract_lasso_to_obs_time("water_vapor_mixing_ratio_surface", i, 1, obs_time, qv=True)
        wrf_rh2m = extract_lasso_to_obs_time("rh_surface", i, 1, obs_time)

        wrf_T_bl  = extract_lasso_to_obs_time("temperature_boundary_layer", i, 1, obs_time)
        wrf_qv_bl = extract_lasso_to_obs_time("water_vapor_mixing_ratio_boundary_layer", i, 1, obs_time, qv=True)
        wrf_rh_bl = extract_lasso_to_obs_time("rh_boundary_layer", i, 1, obs_time)

        wrf_lcl = extract_lasso_to_obs_time("lcl", i, 1, obs_time)
        wrf_cbh = extract_lasso_to_obs_time("cloud_base_height", i, 1, obs_time)

        # DP-SCREAM
        dp_low_cf_v = extract_dp_to_obs_time(dp_low_cf_da, i, obs_time)
        dp_lwp_v    = extract_dp_to_obs_time(dp_lwp_da, i, obs_time)

        dp_T2m_v  = extract_dp_to_obs_time(dp_T2m_da, i, obs_time)
        dp_qv2m_v = extract_dp_to_obs_time(dp_qv2m_da, i, obs_time)
        dp_rh2m_v = extract_dp_to_obs_time(dp_rh_surface_da, i, obs_time)
        dp_pblh_v = extract_dp_to_obs_time(dp_pblh_da, i, obs_time)

        dp_T_bl_v  = extract_dp_to_obs_time(dp_T_bl_da, i, obs_time)
        dp_qv_bl_v = extract_dp_to_obs_time(dp_qv_bl_da, i, obs_time)
        dp_rh_bl_v = extract_dp_to_obs_time(dp_rh_bl_da, i, obs_time)

        dp_real_water = extract_dp_to_obs_time(real_water_fraction_da, i, obs_time)
        dp_max_rh     = extract_dp_to_obs_time(max_RH_below_3km_da, i, obs_time)

        dp_shf = extract_dp_to_obs_time(sensible_heat_flux_da, i, obs_time)
        dp_lhf = extract_dp_to_obs_time(latent_heat_flux_da, i, obs_time)

        dp_shoc_T = extract_dp_to_obs_time(shoc_T_tend_da, i, obs_time) * 86400.0
        dp_p3_T   = extract_dp_to_obs_time(p3_T_tend_da, i, obs_time) * 86400.0
        dp_rad_T  = extract_dp_to_obs_time(rad_T_tend_da, i, obs_time) * 86400.0

        dp_shoc_qv = extract_dp_to_obs_time(shoc_qv_tend_da, i, obs_time) * 86400.0 * 1000.0
        dp_p3_qv   = extract_dp_to_obs_time(p3_qv_tend_da, i, obs_time) * 86400.0 * 1000.0

        dp_tke  = extract_dp_to_obs_time(tke_da, i, obs_time)
        dp_eddy = extract_dp_to_obs_time(eddy_diff_mom_da, i, obs_time)

        for j in range(n):
            base = {
                "case_index": i,
                "case": CASE_LABELS[i],
                "time_utc": obs_time[j],
                "time_lst": obs_time_lst[j],
                "hour_lst": obs_time_lst[j].hour + obs_time_lst[j].minute / 60.0,
                "obs_low_cf": obs_low_cf[j],
            }

            # -------------------------
            # WRF/LASSO row
            # -------------------------
            wrf_cf_bias = wrf_low_cf[j] - obs_low_cf[j]

            rows.append({
                **base,
                "model": "LASSO",
                "model_low_cf": wrf_low_cf[j],
                "model_lwp": wrf_lwp[j],
                "cf_bias": wrf_cf_bias,
                "lwp_bias": wrf_lwp[j] - obs_lwp[j],

                # Environmental biases (shared core)
                "T2m_bias":      wrf_T2m[j]  - obs_T2m[j],
                "qv2m_bias_gkg": wrf_qv2m[j] - obs_qv2m[j],
                "rh2m_bias":     wrf_rh2m[j] - obs_rh2m[j],
                "T_bl_bias":     wrf_T_bl[j]  - obs_T_bl[j],
                "qv_bl_bias_gkg": wrf_qv_bl[j] - obs_qv_bl[j],
                "rh_bl_bias":    wrf_rh_bl[j] - obs_rh_bl[j],

                # WRF-only
                "lcl_bias": wrf_lcl[j] - obs_lcl[j],
                "cbh_bias": wrf_cbh[j] - obs_cbh[j],

                # DP-only -> NaN
                "pblh": np.nan, "pblh_minus_lcl": np.nan, "max_RH_below_3km": np.nan,
                "real_water_fraction": np.nan,
                "sensible_heat_flux": np.nan, "latent_heat_flux": np.nan,
                "shoc_T_tend_Kday": np.nan, "p3_T_tend_Kday": np.nan, "rad_T_tend_Kday": np.nan,
                "shoc_qv_tend_gkgday": np.nan, "p3_qv_tend_gkgday": np.nan,
                "tke": np.nan, "eddy_diff_mom": np.nan,
            })

            # -------------------------
            # DP-SCREAM row
            # -------------------------
            dp_cf_bias = dp_low_cf_v[j] - obs_low_cf[j]

            rows.append({
                **base,
                "model": "DP-SCREAM",
                "model_low_cf": dp_low_cf_v[j],
                "model_lwp": dp_lwp_v[j],
                "cf_bias": dp_cf_bias,
                "lwp_bias": dp_lwp_v[j] - obs_lwp[j],

                # Environmental biases (shared core)
                "T2m_bias":      dp_T2m_v[j]  - obs_T2m[j],
                "qv2m_bias_gkg": dp_qv2m_v[j] - obs_qv2m[j],
                "rh2m_bias":     dp_rh2m_v[j] - obs_rh2m[j],
                "T_bl_bias":     dp_T_bl_v[j]  - obs_T_bl[j],
                "qv_bl_bias_gkg": dp_qv_bl_v[j] - obs_qv_bl[j],
                "rh_bl_bias":    dp_rh_bl_v[j] - obs_rh_bl[j],

                # WRF-only -> NaN
                "lcl_bias": np.nan,
                "cbh_bias": np.nan,

                # DP-only process diagnostics
                "pblh": dp_pblh_v[j],
                "pblh_minus_lcl": dp_pblh_v[j] - obs_lcl[j],
                "max_RH_below_3km": dp_max_rh[j],
                "real_water_fraction": dp_real_water[j],
                "sensible_heat_flux": dp_shf[j],
                "latent_heat_flux": dp_lhf[j],
                "shoc_T_tend_Kday": dp_shoc_T[j],
                "p3_T_tend_Kday": dp_p3_T[j],
                "rad_T_tend_Kday": dp_rad_T[j],
                "shoc_qv_tend_gkgday": dp_shoc_qv[j],
                "p3_qv_tend_gkgday": dp_p3_qv[j],
                "tke": dp_tke[j],
                "eddy_diff_mom": dp_eddy[j],
            })

    return pd.DataFrame(rows)


ml_compare_df = build_ml_compare_df()

print("\n" + "=" * 70)
print(f"Built ml_compare_df: {len(ml_compare_df)} rows")
print("=" * 70)
print(ml_compare_df["model"].value_counts())
ml_compare_df.to_csv(OUTDIR / "ml_model_comparison_bias_table.csv", index=False)


# ============================================================
# 6. FEATURE NAME LOOKUP
# ============================================================

feature_long_names = {
    # Shared environmental bias
    "T2m_bias":        "2-m temperature bias [K]",
    "qv2m_bias_gkg":   "2-m humidity bias [g kg$^{-1}$]",
    "rh2m_bias":       "2-m relative humidity bias [%]",
    "T_bl_bias":       "BL temperature bias [K]",
    "qv_bl_bias_gkg":  "BL humidity bias [g kg$^{-1}$]",
    "rh_bl_bias":      "BL relative humidity bias [%]",

    # WRF-only
    "lcl_bias":        "LCL bias [m]",
    "cbh_bias":        "Cloud-base-height bias [m]",

    # DP-only
    "pblh":            "PBL height [m]",
    "pblh_minus_lcl":  "PBL height − observed LCL [m]",
    "max_RH_below_3km":"Max RH below 3 km [%]",
    "real_water_fraction": "Real-water cloud occurrence",
    "sensible_heat_flux": "Sensible heat flux [W m$^{-2}$]",
    "latent_heat_flux":   "Latent heat flux [W m$^{-2}$]",
    "shoc_T_tend_Kday":   "SHOC T tendency [K day$^{-1}$]",
    "p3_T_tend_Kday":     "P3 T tendency [K day$^{-1}$]",
    "rad_T_tend_Kday":    "Radiation T tendency [K day$^{-1}$]",
    "shoc_qv_tend_gkgday":"SHOC qv tendency [g kg$^{-1}$ day$^{-1}$]",
    "p3_qv_tend_gkgday":  "P3 qv tendency [g kg$^{-1}$ day$^{-1}$]",
    "tke":             "TKE [m$^2$ s$^{-2}$]",
    "eddy_diff_mom":   "Eddy diff. for momentum [m$^2$ s$^{-1}$]",
}


# ============================================================
# 7. CURATED FEATURE SETS
# ============================================================
# Design notes:
#  - SHARED features are identical between WRF and DP-SCREAM so the
#    importance ranking can be directly compared.
#  - WRF gets LCL/CBH biases additionally (DP has no LCL/CBH variables).
#  - DP gets process diagnostics that WRF doesn't expose.
#  - Excluded on purpose:
#      * model_T2m, model_qv2m, model_T_bl, model_qv_bl (state-vs-bias
#        redundancy with the *_bias features)
#      * qc_mean_gkg, qr_mean_gkg, qc_occurrence (mechanically too close
#        to cloud-fraction definition; would be tautological)
#      * model_low_cf, model_lwp (part of the target)

SHARED_FEATURES = [
    "T2m_bias",
    "qv2m_bias_gkg",
    "rh2m_bias",
    "T_bl_bias",
    "qv_bl_bias_gkg",
    "rh_bl_bias",
]

WRF_EXTRA_FEATURES = [
    "lcl_bias",
    "cbh_bias",
]

DP_EXTRA_FEATURES = [
    "pblh",
    "pblh_minus_lcl",
    "max_RH_below_3km",
    "real_water_fraction",
    "sensible_heat_flux",
    "latent_heat_flux",
    "shoc_T_tend_Kday",
    "p3_T_tend_Kday",
    "rad_T_tend_Kday",
    "shoc_qv_tend_gkgday",
    "p3_qv_tend_gkgday",
    "tke",
    "eddy_diff_mom",
]

wrf_feature_candidates    = SHARED_FEATURES + WRF_EXTRA_FEATURES
scream_feature_candidates = SHARED_FEATURES + DP_EXTRA_FEATURES


# ============================================================
# 8. TRAIN ONE MODEL
# ============================================================

def train_one_model_ml(
    df,
    model_name,
    feature_candidates,
    target_col="cf_bias",
    save_prefix=None,
    save=True,
):
    if save_prefix is None:
        save_prefix = model_name.replace("-", "").replace("/", "_").replace(" ", "_")

    sub = df[df["model"] == model_name].copy()

    # Drop features with no usable data
    features = [
        c for c in feature_candidates
        if c in sub.columns and sub[c].notna().sum() >= 3
    ]

    use = sub[
        features + [target_col, "case_index", "case", "time_lst", "model"]
    ].replace([np.inf, -np.inf], np.nan)
    use = use[np.isfinite(use[target_col])].copy()

    print("\n" + "=" * 95)
    print(f"Training ML for: {model_name}")
    print("=" * 95)
    print(f"Target: {target_col}")
    print(f"Rows:   {len(use)}")
    print("Features:")
    for c in features:
        print(f"  {c:24s} non-NaN = {use[c].notna().sum()} / {len(use)}")

    if len(use) < 8 or len(features) == 0:
        print(f"Not enough data for {model_name}.")
        return None

    X = use[features]
    y = use[target_col].values
    groups = use["case_index"].values

    # Equal weighting per case so one long case doesn't dominate
    case_counts = use["case_index"].value_counts().to_dict()
    sample_weight = use["case_index"].map(lambda c: 1.0 / case_counts[c]).values
    sample_weight = sample_weight / sample_weight.mean()  # normalize

    rf = Pipeline([
        ("imputer", SimpleImputer(strategy="median")),
        ("model", RandomForestRegressor(
            n_estimators=N_ESTIMATORS,
            random_state=RANDOM_STATE,
            max_depth=4,
            min_samples_leaf=3,
        )),
    ])

    # --- Leave-one-case-out CV with skill score ---
    logo = LeaveOneGroupOut()
    cv_rows = []
    for train_idx, test_idx in logo.split(X, y, groups):
        rf.fit(
            X.iloc[train_idx], y[train_idx],
            model__sample_weight=sample_weight[train_idx],
        )
        pred = rf.predict(X.iloc[test_idx])
        train_mean = np.average(y[train_idx], weights=sample_weight[train_idx])
        baseline_pred = np.full_like(y[test_idx], train_mean, dtype=float)

        model_rmse = np.sqrt(mean_squared_error(y[test_idx], pred))
        baseline_rmse = np.sqrt(mean_squared_error(y[test_idx], baseline_pred))
        skill = 1.0 - (model_rmse**2 / baseline_rmse**2) if baseline_rmse > 0 else np.nan

        cv_rows.append({
            "model": model_name,
            "test_case": use.iloc[test_idx]["case"].iloc[0],
            "model_rmse": model_rmse,
            "baseline_rmse": baseline_rmse,
            "skill_score": skill,   # >0 means RF beats predicting the mean
            "model_r2": r2_score(y[test_idx], pred),
        })
    cv_df = pd.DataFrame(cv_rows)
    print("\nLeave-one-case-out validation:")
    safe_display(cv_df)

    # --- Final model on all data ---
    rf.fit(X, y, model__sample_weight=sample_weight)
    rf_model = rf.named_steps["model"]

    importance_df = pd.DataFrame({
        "feature": features,
        "importance": rf_model.feature_importances_,
    }).sort_values("importance", ascending=False)
    importance_df["feature_long"] = importance_df["feature"].map(
        feature_long_names).fillna(importance_df["feature"])

    print("\nRF feature importance:")
    safe_display(importance_df)

    # --- Permutation importance ---
    perm = permutation_importance(
        rf, X, y,
        n_repeats=N_REPEATS_PERM,
        random_state=RANDOM_STATE,
        sample_weight=sample_weight,
    )
    perm_df = pd.DataFrame({
        "feature": features,
        "importance_mean": perm.importances_mean,
        "importance_std": perm.importances_std,
    }).sort_values("importance_mean", ascending=False)
    perm_df["feature_long"] = perm_df["feature"].map(
        feature_long_names).fillna(perm_df["feature"])

    print("\nPermutation importance:")
    safe_display(perm_df)

    # --- Save ---
    if save:
        cv_df.to_csv(OUTDIR / f"{save_prefix}_cv_scores.csv", index=False)
        importance_df.to_csv(OUTDIR / f"{save_prefix}_rf_feature_importance.csv", index=False)
        perm_df.to_csv(OUTDIR / f"{save_prefix}_permutation_importance.csv", index=False)

    # --- Plots ---
    for kind, plot_df_full, xlabel, fname in [
        ("rf",   importance_df, "Random Forest feature importance", "rf_feature_importance"),
        ("perm", perm_df,       "Permutation importance",            "permutation_importance"),
    ]:
        fig, ax = plt.subplots(figsize=(10, max(4, 0.4 * len(plot_df_full))))
        plot_df = plot_df_full.iloc[::-1]
        if kind == "perm":
            ax.barh(plot_df["feature_long"], plot_df["importance_mean"],
                    xerr=plot_df["importance_std"])
        else:
            ax.barh(plot_df["feature_long"], plot_df["importance"])
        ax.set_xlabel(xlabel)
        ax.set_title(f"{model_name}: predictors of low-cloud-fraction bias")
        ax.grid(axis="x", alpha=0.25)
        plt.tight_layout()
        if save:
            fig.savefig(OUTDIR / f"{save_prefix}_{fname}.png",
                        dpi=300, bbox_inches="tight")
        plt.show()

    # --- Predicted vs actual ---
    pred = rf.predict(X)
    fig, ax = plt.subplots(figsize=(6, 5))
    for k, case in enumerate(ML_CASE_LABELS):
        idx = use["case"] == case
        if idx.sum() == 0:
            continue
        ax.scatter(y[idx], pred[idx], color=ML_CASE_COLORS[k],
                   s=70, edgecolor="k", linewidth=0.5, label=case)
    lo = np.nanmin([np.nanmin(y), np.nanmin(pred)])
    hi = np.nanmax([np.nanmax(y), np.nanmax(pred)])
    ax.plot([lo, hi], [lo, hi], "k--", lw=1)
    ax.axhline(0, color="gray", lw=0.8)
    ax.axvline(0, color="gray", lw=0.8)
    ax.set_xlabel("Actual low-cloud-fraction bias")
    ax.set_ylabel("Predicted low-cloud-fraction bias")
    ax.set_title(f"{model_name}: predicted vs actual")
    ax.grid(alpha=0.25)
    ax.legend(frameon=False)
    plt.tight_layout()
    if save:
        fig.savefig(OUTDIR / f"{save_prefix}_predicted_vs_actual.png",
                    dpi=300, bbox_inches="tight")
    plt.show()

    return {
        "model_name": model_name,
        "data": use,
        "features": features,
        "rf": rf,
        "cv": cv_df,
        "importance": importance_df,
        "permutation": perm_df,
    }


# ============================================================
# 9. TRAIN BOTH MODELS
# ============================================================

wrf_ml_result = train_one_model_ml(
    df=ml_compare_df,
    model_name="LASSO",
    feature_candidates=wrf_feature_candidates,
    target_col=TARGET_COL,
    save_prefix="ml_wrf_lasso",
    save=True,
)

scream_ml_result = train_one_model_ml(
    df=ml_compare_df,
    model_name="DP-SCREAM",
    feature_candidates=scream_feature_candidates,
    target_col=TARGET_COL,
    save_prefix="ml_dp_scream",
    save=True,
)


# ============================================================
# 10. SIDE-BY-SIDE COMPARISON PLOT
# ============================================================
# Two-panel plot:
#   Left  panel: shared features only (directly comparable across models)
#   Right panel: each model's full feature set, sorted independently

def compare_two_models_sidebyside(
    result_a, result_b,
    label_a="WRF/LASSO", label_b="DP-SCREAM",
    save=True,
):
    if result_a is None or result_b is None:
        print("Missing model result.")
        return None

    a_perm = result_a["permutation"].set_index("feature")
    b_perm = result_b["permutation"].set_index("feature")

    # ---- Panel 1: SHARED features only ----
    shared = [f for f in SHARED_FEATURES
              if f in a_perm.index and f in b_perm.index]

    shared_df = pd.DataFrame({
        "feature": shared,
        "feature_long": [feature_long_names.get(f, f) for f in shared],
        label_a: [a_perm.loc[f, "importance_mean"] for f in shared],
        label_a + "_std": [a_perm.loc[f, "importance_std"] for f in shared],
        label_b: [b_perm.loc[f, "importance_mean"] for f in shared],
        label_b + "_std": [b_perm.loc[f, "importance_std"] for f in shared],
    })
    # Sort by max across the two models
    shared_df["max_imp"] = shared_df[[label_a, label_b]].max(axis=1)
    shared_df = shared_df.sort_values("max_imp", ascending=True)

    fig, axes = plt.subplots(1, 2, figsize=(14, 6),
                             gridspec_kw={"width_ratios": [1, 1.2]})

    # ---- Left: shared features grouped bars ----
    ax = axes[0]
    y_pos = np.arange(len(shared_df))
    height = 0.4
    ax.barh(y_pos - height/2, shared_df[label_a], height,
            xerr=shared_df[label_a + "_std"], label=label_a, color="#1f77b4")
    ax.barh(y_pos + height/2, shared_df[label_b], height,
            xerr=shared_df[label_b + "_std"], label=label_b, color="#d62728")
    ax.set_yticks(y_pos)
    ax.set_yticklabels(shared_df["feature_long"])
    ax.set_xlabel("Permutation importance")
    ax.set_title("Shared environmental-bias features\n(directly comparable)")
    ax.grid(axis="x", alpha=0.25)
    ax.legend(frameon=False, loc="lower right")
    ax.axvline(0, color="k", lw=0.6)

    # ---- Right: each model's top features, sorted independently ----
    ax = axes[1]

    a_top = result_a["permutation"].head(10).iloc[::-1].copy()
    b_top = result_b["permutation"].head(10).iloc[::-1].copy()

    # Stack them: model A on bottom, model B on top, separated by a gap
    gap = 1
    a_y = np.arange(len(a_top))
    b_y = np.arange(len(b_top)) + len(a_top) + gap

    ax.barh(a_y, a_top["importance_mean"], xerr=a_top["importance_std"],
            color="#1f77b4", label=label_a)
    ax.barh(b_y, b_top["importance_mean"], xerr=b_top["importance_std"],
            color="#d62728", label=label_b)

    yticks = list(a_y) + list(b_y)
    yticklabels = list(a_top["feature_long"]) + list(b_top["feature_long"])
    ax.set_yticks(yticks)
    ax.set_yticklabels(yticklabels)

    # Visual separator
    ax.axhline(len(a_top) + gap/2 - 0.5, color="gray", lw=0.8, ls="--")
    ax.set_xlabel("Permutation importance")
    ax.set_title("Top features per model\n(sorted independently)")
    ax.grid(axis="x", alpha=0.25)
    ax.legend(frameon=False, loc="lower right")
    ax.axvline(0, color="k", lw=0.6)

    plt.tight_layout()

    if save:
        fig.savefig(OUTDIR / "ml_compare_wrf_lasso_vs_dp_scream.png",
                    dpi=300, bbox_inches="tight")
        shared_df.to_csv(OUTDIR / "ml_compare_shared_features.csv", index=False)

    plt.show()

    print("\nShared-feature comparison:")
    safe_display(shared_df.sort_values("max_imp", ascending=False))

    return shared_df


shared_compare = compare_two_models_sidebyside(
    wrf_ml_result, scream_ml_result,
    label_a="WRF/LASSO", label_b="DP-SCREAM",
    save=True,
)


# ============================================================
# 11. SUMMARY
# ============================================================

print("\n" + "=" * 70)
print("Output files saved in:", OUTDIR.resolve())
print("=" * 70)
for f in sorted(OUTDIR.glob("*")):
    print(" ", f.name)
LASSO / WRF / OBS main files:
   /data/project/ARM_Summer_School_2026/data/modeling/lasso/20180709/sgplassodiagconfobsmod8C1.m1/obs_model/sgplassodiagobsmod8C1.m1.20180709.120000.nc
   /data/project/ARM_Summer_School_2026/data/modeling/lasso/20190517/sgplassodiagconfobsmod4C1.m1/obs_model/sgplassodiagobsmod4C1.m1.20190517.120000.nc
   /data/project/ARM_Summer_School_2026/data/modeling/lasso/20190929/sgplassodiagconfobsmod4C1.m1/obs_model/sgplassodiagobsmod4C1.m1.20190929.120000.nc
ERROR 1: PROJ: proj_create_from_database: Open of /opt/conda/share/proj failed

DP-SCREAM files:
   /data/project/ARM_Summer_School_2026/data/modeling/dpscream/scream_dpxx_LASSO_SGP_2018-07-09.fullfield.AVERAGE.nhours_x1.2018-07-09-43200.nc
   /data/project/ARM_Summer_School_2026/data/modeling/dpscream/scream_dpxx_LASSO_SGP_2019-05-17.fullfield.AVERAGE.nhours_x1.2019-05-17-43200.nc
   /data/project/ARM_Summer_School_2026/data/modeling/dpscream/scream_dpxx_LASSO_SGP_2019-09-29.fullfield.AVERAGE.nhours_x1.2019-09-29-43200.nc

======================================================================
DP-SCREAM variable load status:
======================================================================
  dp_low_cf                           OK
  dp_total_cf                         OK
  dp_lwp                              OK
  T_2m                                OK
  qv_2m                               OK
  pbl_height                          OK
  T_bl (from T_mid/T)                 OK
  qv_bl (from qv/qv_mid)              OK
  RH_surface                          MISSING
  RH_BL                               OK
  real_water_fraction                 OK
  max_RH_below_3km                    OK
  surf_sens_flux                      OK
  surf_lat_flux                       OK
  shoc_T_mid_tend                     OK
  p3_T_mid_tend                       OK
  rrtmgp_T_mid_tend                   OK
  shoc_qv_tend                        OK
  p3_qv_tend                          OK
  tke                                 OK
  eddy_diff_mom                       OK
/opt/conda/lib/python3.11/site-packages/dask/array/reductions.py:324: RuntimeWarning: All-NaN slice encountered
  return np.nanmax(x_chunk, axis=axis, keepdims=keepdims)

======================================================================
Built ml_compare_df: 288 rows
======================================================================
model
LASSO        144
DP-SCREAM    144
Name: count, dtype: int64

===============================================================================================
Training ML for: LASSO
===============================================================================================
Target: cf_bias
Rows:   48
Features:
  T2m_bias                 non-NaN = 48 / 48
  qv2m_bias_gkg            non-NaN = 48 / 48
  rh2m_bias                non-NaN = 48 / 48
  T_bl_bias                non-NaN = 48 / 48
  qv_bl_bias_gkg           non-NaN = 47 / 48
  rh_bl_bias               non-NaN = 47 / 48
  lcl_bias                 non-NaN = 48 / 48
  cbh_bias                 non-NaN = 45 / 48

Leave-one-case-out validation:
Loading...

RF feature importance:
Loading...

Permutation importance:
Loading...
<Figure size 1000x400 with 1 Axes>
<Figure size 1000x400 with 1 Axes>
<Figure size 600x500 with 1 Axes>

===============================================================================================
Training ML for: DP-SCREAM
===============================================================================================
Target: cf_bias
Rows:   45
Features:
  T2m_bias                 non-NaN = 45 / 45
  qv2m_bias_gkg            non-NaN = 45 / 45
  T_bl_bias                non-NaN = 44 / 45
  qv_bl_bias_gkg           non-NaN = 43 / 45
  rh_bl_bias               non-NaN = 43 / 45
  pblh                     non-NaN = 45 / 45
  pblh_minus_lcl           non-NaN = 45 / 45
  max_RH_below_3km         non-NaN = 45 / 45
  real_water_fraction      non-NaN = 45 / 45
  sensible_heat_flux       non-NaN = 45 / 45
  latent_heat_flux         non-NaN = 45 / 45
  shoc_T_tend_Kday         non-NaN = 45 / 45
  p3_T_tend_Kday           non-NaN = 45 / 45
  rad_T_tend_Kday          non-NaN = 45 / 45
  shoc_qv_tend_gkgday      non-NaN = 45 / 45
  p3_qv_tend_gkgday        non-NaN = 45 / 45
  tke                      non-NaN = 45 / 45
  eddy_diff_mom            non-NaN = 45 / 45

Leave-one-case-out validation:
Loading...

RF feature importance:
Loading...

Permutation importance:
Loading...
<Figure size 1000x720 with 1 Axes>
<Figure size 1000x720 with 1 Axes>
<Figure size 600x500 with 1 Axes>
<Figure size 1400x600 with 2 Axes>

Shared-feature comparison:
Loading...

======================================================================
Output files saved in: /user-data-home/model_meets_reality_BODS26/cloud_model_eval_outputs
======================================================================
  02_lwp_and_cf_lwp_scatter.png
  03_surface_fluxes.png
  04_qc_and_cf_profiles_peak.png
  05_pblh_cbh_lcl.png
  06_process_tendencies.png
  07_tke_eddydiff.png
  08_cloud_radiative_effect.png
  09_metrics_heatmap.png
  10_taylor.png
  11_cloud_onset_dissipation_bias.png
  12_pblh_minus_lcl_with_cf.png
  13_diagnostic_cf_vs_real_water_fraction.png
  14_qc_vs_qr_occurrence.png
  15_dp_cloud_base_top_depth.png
  16_pblh_vs_lwp_binned.png
  17_bl_T_qv_colored_by_CF.png
  18_rh_profile_peak_cf.png
  19_bl_mean_process_tendencies.png
  bl_qv_obs_wrf_dp.png
  bl_temperature_obs_wrf_dp.png
  cloud_base_height_and_lcl_obs_wrf.png
  cloud_base_height_obs_vs_wrf.png
  cloud_fraction_tsi_obs_vs_wrf.png
  cloud_onset_dissipation_bias.csv
  dp_cloud_base_top_depth.csv
  lcl_obs_vs_wrf.png
  low_cloud_fraction_arscl_obs_vs_wrf.png
  low_cloud_fraction_obs_wrf_dp.png
  lwp_obs_vs_wrf.png
  lwp_obs_wrf_dp.png
  metrics_all.csv
  ml_01_bias_category_timeline.png
  ml_02_case_bias_summary.png
  ml_03_rf_feature_importance_cf_bias.png
  ml_04_rf_predicted_vs_actual_cf_bias.png
  ml_05_permutation_importance_cf_bias.png
  ml_06_bias_category_confusion_matrix.png
  ml_07_rf_feature_importance_bias_category.png
  ml_08_cloud_regime_clusters_pca.png
  ml_09_cloud_regime_cluster_timeline.png
  ml_10_cluster_physical_fingerprints.png
  ml_DP-SCREAM_common_feature_importance.csv
  ml_LASSO_common_feature_importance.csv
  ml_ambient_only_feature_importance.csv
  ml_ambient_only_feature_importance_long_names.png
  ml_ambient_only_leave_one_case_out.csv
  ml_ambient_only_permutation_importance.csv
  ml_ambient_only_permutation_importance_long_names.png
  ml_ambient_only_predicted_vs_actual.png
  ml_bias_factor_only_feature_importance.csv
  ml_bias_factor_only_feature_importance.png
  ml_bias_factor_only_leave_one_case_out.csv
  ml_bias_factor_only_permutation_importance.csv
  ml_bias_factor_only_permutation_importance.png
  ml_bias_factor_only_predicted_vs_actual.png
  ml_bias_factor_scatter_T2m_bias_vs_cf_bias.png
  ml_bias_factor_scatter_T_bl_bias_vs_cf_bias.png
  ml_bias_factor_scatter_qv2m_bias_gkg_vs_cf_bias.png
  ml_bias_factor_scatter_qv_bl_bias_gkg_vs_cf_bias.png
  ml_case_bias_summary.csv
  ml_cloud_bias_table.csv
  ml_cluster_counts_by_bias_category.csv
  ml_cluster_counts_by_case.csv
  ml_cluster_physical_summary.csv
  ml_common_rf_feature_importance.csv
  ml_common_rf_leave_one_case_out.csv
  ml_compare_01_model_bias_summary.png
  ml_compare_02_bias_category_counts.png
  ml_compare_03_cf_bias_timeline.png
  ml_compare_04_common_feature_importance.png
  ml_compare_04_common_feature_importance_long_names.png
  ml_compare_05_common_rf_predicted_vs_actual.png
  ml_compare_06_DPSCREAM_common_importance.png
  ml_compare_06_LASSO_common_importance.png
  ml_compare_07_dp_full_feature_importance.png
  ml_compare_08_dp_full_permutation_importance.png
  ml_compare_09_bias_category_confusion_matrix.png
  ml_compare_10_bias_category_feature_importance.png
  ml_compare_11_joint_clusters_pca.png
  ml_compare_12_joint_cluster_timeline.png
  ml_compare_shared_features.csv
  ml_compare_wrf_lasso_vs_dp_scream.png
  ml_compare_wrf_lasso_vs_dp_scream_allvars_permutation.csv
  ml_compare_wrf_lasso_vs_dp_scream_allvars_permutation.png
  ml_compare_wrf_lasso_vs_dp_scream_permutation.csv
  ml_compare_wrf_lasso_vs_dp_scream_permutation.png
  ml_dp_full_permutation_importance.csv
  ml_dp_full_rf_feature_importance.csv
  ml_dp_full_rf_leave_one_case_out.csv
  ml_dp_scream_allvars_cv_scores.csv
  ml_dp_scream_allvars_permutation_importance.csv
  ml_dp_scream_allvars_permutation_importance.png
  ml_dp_scream_allvars_predicted_vs_actual.png
  ml_dp_scream_allvars_rf_feature_importance.csv
  ml_dp_scream_allvars_rf_feature_importance.png
  ml_dp_scream_cloud_bias_cv_scores.csv
  ml_dp_scream_cloud_bias_permutation_importance.csv
  ml_dp_scream_cloud_bias_permutation_importance.png
  ml_dp_scream_cloud_bias_predicted_vs_actual.png
  ml_dp_scream_cloud_bias_rf_feature_importance.csv
  ml_dp_scream_cloud_bias_rf_feature_importance.png
  ml_dp_scream_cv_scores.csv
  ml_dp_scream_permutation_importance.csv
  ml_dp_scream_permutation_importance.png
  ml_dp_scream_predicted_vs_actual.png
  ml_dp_scream_rf_feature_importance.csv
  ml_dp_scream_rf_feature_importance.png
  ml_joint_cluster_by_bias_category.csv
  ml_joint_cluster_by_case.csv
  ml_joint_cluster_by_model.csv
  ml_joint_cluster_summary.csv
  ml_model_bias_summary.csv
  ml_model_comparison_bias_table.csv
  ml_next_bias_pathway_summary.png
  ml_next_non_circular_feature_importance.png
  ml_next_scatter_T2m_bias_vs_cf_bias.png
  ml_next_scatter_model_qv2m_gkg_vs_cf_bias.png
  ml_next_scatter_model_qv_bl_gkg_vs_cf_bias.png
  ml_next_scatter_qv_bl_bias_gkg_vs_cf_bias.png
  ml_permutation_importance_cf_bias.csv
  ml_rf_classifier_feature_importance.csv
  ml_rf_regression_feature_importance.csv
  ml_rf_regression_leave_one_case_out.csv
  ml_separate_DPSCREAM_cv_scores.csv
  ml_separate_DPSCREAM_feature_importance.csv
  ml_separate_DPSCREAM_feature_importance.png
  ml_separate_DPSCREAM_permutation_importance.csv
  ml_separate_DPSCREAM_permutation_importance.png
  ml_separate_DPSCREAM_predicted_vs_actual.png
  ml_separate_LASSO_cv_scores.csv
  ml_separate_LASSO_feature_importance.csv
  ml_separate_LASSO_feature_importance.png
  ml_separate_LASSO_permutation_importance.csv
  ml_separate_LASSO_permutation_importance.png
  ml_separate_LASSO_predicted_vs_actual.png
  ml_test_set_average_summary.csv
  ml_wrf_lasso_allvars_cv_scores.csv
  ml_wrf_lasso_allvars_permutation_importance.csv
  ml_wrf_lasso_allvars_permutation_importance.png
  ml_wrf_lasso_allvars_predicted_vs_actual.png
  ml_wrf_lasso_allvars_rf_feature_importance.csv
  ml_wrf_lasso_allvars_rf_feature_importance.png
  ml_wrf_lasso_cloud_bias_cv_scores.csv
  ml_wrf_lasso_cloud_bias_permutation_importance.csv
  ml_wrf_lasso_cloud_bias_permutation_importance.png
  ml_wrf_lasso_cloud_bias_predicted_vs_actual.png
  ml_wrf_lasso_cloud_bias_rf_feature_importance.csv
  ml_wrf_lasso_cloud_bias_rf_feature_importance.png
  ml_wrf_lasso_cv_scores.csv
  ml_wrf_lasso_permutation_importance.csv
  ml_wrf_lasso_permutation_importance.png
  ml_wrf_lasso_predicted_vs_actual.png
  ml_wrf_lasso_rf_feature_importance.csv
  ml_wrf_lasso_rf_feature_importance.png
  surface_qv_obs_wrf_dp.png
  surface_temperature_obs_wrf_dp.png
  temperature_boundary_layer_obs_vs_wrf.png
  temperature_surface_obs_vs_wrf.png
  total_cloud_fraction_tsi_obs_wrf_dp.png
  water_vapor_mixing_ratio_boundary_layer_obs_vs_wrf.png
  water_vapor_mixing_ratio_surface_obs_vs_wrf.png
  wrf_cloud_fraction_curtain.png
  wrf_cloud_onset_metrics.csv
  wrf_vs_obs_bias_model_minus_obs_summary.png
  wrf_vs_obs_correlation_summary.png
  wrf_vs_obs_metrics.csv
  wrf_vs_obs_rmse_summary.png