Enefit - Predict Energy Behavior of Prosumers, part 2

technical
Author

Sameer Sant

Published

July 11, 2025

1 Intro

Show code
# Object-oriented idea from Vitaly Kudelya: https://www.kaggle.com/code/vitalykudelya/enefit-object-oriented-gbdt
class Storage: 
    URLs.LOCAL_PATH = Path('/notebooks/kaggle/enefit')
    path = URLs.LOCAL_PATH/'predict-energy-behavior-of-prosumers'
    
    segment = ['county', 'is_business', 'product_type']
    data_cols = segment + ['is_consumption', 'datetime', 'row_id', 'target']
    client_cols = segment + ['eic_count', 'installed_capacity', 'date']
    forecast_cols = ['latitude', 'longitude', 'hours_ahead',
        'temperature', 'dewpoint', 'cloudcover_high', 'cloudcover_low',
        'cloudcover_mid', 'cloudcover_total', '10_metre_u_wind_component',
        '10_metre_v_wind_component', 'forecast_datetime',
        'direct_solar_radiation', 'surface_solar_radiation_downwards',
        'snowfall', 'total_precipitation']
    historical_cols = ['datetime', 'temperature', 'dewpoint', 'rain', 'snowfall',
        'surface_pressure', 'cloudcover_total', 'cloudcover_low',
        'cloudcover_mid', 'cloudcover_high', 'windspeed_10m',
        'winddirection_10m', 'shortwave_radiation', 'direct_solar_radiation',
        'diffuse_radiation', 'latitude', 'longitude']
    electricity_cols = ['forecast_date', 'euros_per_mwh']
    gas_cols = ['forecast_date', 'lowest_price_per_mwh', 'highest_price_per_mwh']
    location_cols = ['longitude', 'latitude', 'county']
    
    def __init__(self):
        
        # prosumer details
        self.train_df = pd.read_csv(self.path/'train.csv', usecols=self.data_cols, low_memory=False)
        self.client_df = pd.read_csv(self.path/'client.csv', usecols=self.client_cols, low_memory=False)

        # weather
        self.forecast_df = pd.read_csv(self.path/'forecast_weather.csv', usecols=self.forecast_cols, low_memory=False)
        self.historical_df = pd.read_csv(self.path/'historical_weather.csv', usecols=self.historical_cols, low_memory=False)

        # energy prices
        self.electricity_df = pd.read_csv(self.path/'electricity_prices.csv', usecols=self.electricity_cols, low_memory=False)
        self.gas_df = pd.read_csv(self.path/'gas_prices.csv', usecols=self.gas_cols, low_memory=False)

        # location
        self.weather_station_to_county_mapping_df = pd.read_csv(self.path/'weather_station_to_county_mapping.csv', usecols=self.location_cols, low_memory=False)
        self.county_id_to_name_map_df = pd.read_json(self.path/'county_id_to_name_map.json', orient='index')
    
        # cast date columns as datetime type
        dfs = [self.train_df, self.client_df, self.forecast_df, self.historical_df, self.electricity_df, self.gas_df]
        date_cols = ['datetime', 'date', 'forecast_datetime', 'datetime', 'forecast_date', 'forecast_date']
        [self.to_datetime(df, date_cols[i]) for i,df in enumerate(dfs)]
        
    def to_datetime(self, df:pd.DataFrame, date_col:pd.Series):
        """
        casts date_col in df as datetime type
        """
        df[date_col] = pd.to_datetime(df[date_col])
        return df
Show code
class FeatureGenerator:
    
    # these are hardcoded (after previously looking at the skewed columns) to exclude columns you don't want to log-scale, e.g. segment cols, target. 
    # lists in order of client, forecast, historical, electricity, gas dfs
    skewed_cols = [
        ['eic_count', 'installed_capacity'], 
        ['direct_solar_radiation', 'surface_solar_radiation_downwards', 'snowfall', 'total_precipitation'], 
        ['rain', 'snowfall', 'windspeed_10m', 'shortwave_radiation', 'direct_solar_radiation', 'diffuse_radiation'], 
        ['euros_per_mwh'], 
        ['lowest_price_per_mwh', 'highest_price_per_mwh']
    ]
    
    def __init__(self, data_storage:Storage):
        self.data_storage = data_storage
        
    def log_scale_df(self, df:pd.DataFrame, skewed_cols:list):
        # add log scaled columns
        for col in skewed_cols:
            df[f"log_{col}"] = np.log(df[col]+1)

        # drop the original skewed columns
        df.drop(columns=skewed_cols, inplace=True)
        return df

    def log_scale(self, dfs:list, skewed_cols:list):
        # not logging target
        for i, (df, cols) in enumerate(zip(dfs, skewed_cols)):
            self.log_scale_df(df, cols)
    
    def add_client_features(self, features_df:pd.DataFrame, client_df:pd.DataFrame): 
    
        # to align w/ forecast horizon, shift dates 2 days forward (equivalent to 48h lag)
        client_df.loc[:, 'date'] = client_df.loc[:, 'date'] + pd.Timedelta(days=2)

        # create date column in train_df to join on (will drop after)
        features_df['date'] = pd.to_datetime(features_df['datetime'].dt.date)

        # join
        features_df = features_df.merge(client_df, how='left', on=['county', 'is_business', 'product_type', 'date'])
        features_df.drop(columns=['date'], axis=1, inplace=True)
        
        # indicate lag
        features_df.rename(columns={'log_eic_count': 'log_eic_count_48h', 'log_installed_capacity': 'log_installed_capacity_48h'}, inplace=True)

        return features_df

    def _reduce_float64(self, df:pd.DataFrame):
        float64_cols = df.select_dtypes(include=['float64']).columns.tolist()
        for col in float64_cols:
            df[col] = df[col].astype('float32')
        return df
    
    def _add_lagged_targets(self, df:pd.DataFrame, target_cols:list, lags:list):
        """
        df (DataFrame): revealed targets
        target_cols (list): relevant columns to generate the lags and left join
        lags (list): hours to lag
        """
        next_df = df.loc[:, target_cols]
        for lag in lags:
            next_df.loc[:, 'datetime'] = next_df.loc[:, 'datetime'] + pd.Timedelta(hours=lag)
            df = df.merge(next_df, how='left', on=['county', 
                                               'is_business', 
                                                'product_type', 
                                                'is_consumption', 
                                                'datetime'], suffixes=("", f"_{lag}h"))
            # subtract the lag added (reset the datetime back to original)
            next_df.loc[:, 'datetime'] = next_df.loc[:, 'datetime'] - pd.Timedelta(hours=lag)
        
        del next_df
        gc.collect()
        
        return df

    def _add_lagged_target_stats(self, df:pd.DataFrame, cols:list, agg_col:str, stat:str, lags:list):
        """
        df (DataFrame): summary stats {mean,std} generated from revealed targets wrt {is_consumption,county,product_type,is_business} 
        cols (list): relevant columns to generate the lags and left join
        agg_col (str): columns over which the summary stat was generated via a groupby
        stat (str): indicate the summary stat to lag
        lags (list): hours to lag
        """
        next_df = df.loc[:, cols]
        for lag in lags:
            next_df.loc[:, 'datetime'] = next_df.loc[:, 'datetime'] + pd.Timedelta(hours=lag)
            df = df.merge(next_df, how='left', on=cols[:-1], suffixes=("", f"_{agg_col}_{stat}_{lag}h"))
            # subtract the lag added (reset the datetime back to original)
            next_df.loc[:, 'datetime'] = next_df.loc[:, 'datetime'] - pd.Timedelta(hours=lag)
            
        del next_df
        gc.collect()
        
        return df
        
    def _add_target_features(self, features_df:pd.DataFrame):
        
        ## prelims
        
        ############################################################
        
        ### calculate aggregated stats ###
        
        # mean production/consumption over all segments (county-is_business-product_type) for each datetime
        cons_cols = ['datetime', 'is_consumption', 'target']
        is_cons_mean = features_df.drop(columns=['row_id']).groupby(['datetime', 'is_consumption'], as_index=False).mean()[cons_cols]
        is_cons_std = features_df.drop(columns=['row_id']).groupby(['datetime', 'is_consumption'], as_index=False).std()[cons_cols]
        
        ## following capture more rows per aggregated stat than grouping over each segment ##
        
        # mean production/consumption over all segments for each county for each datetime
        county_cols = ['datetime', 'county', 'is_consumption', 'target']
        county_mean = features_df.drop(columns=['row_id']).groupby(['datetime', 'county', 'is_consumption'], as_index=False).mean()[county_cols]
        county_std = features_df.drop(columns=['row_id']).groupby(['datetime', 'county', 'is_consumption'], as_index=False).std()[county_cols]
        
        # mean production/consumption over all businesses/on-businesses for each datetime 
        business_cols = ['datetime', 'is_business', 'is_consumption','target']
        is_business_mean = features_df.drop(columns=['row_id']).groupby(['datetime', 'is_business', 'is_consumption'], as_index=False).mean()[business_cols]
        is_business_std = features_df.drop(columns=['row_id']).groupby(['datetime', 'is_business', 'is_consumption'], as_index=False).std()[business_cols]
        
        # mean production/consumption over all product types for each datetime 
        product_cols = ['datetime', 'product_type', 'is_consumption','target']
        product_type_mean = features_df.drop(columns=['row_id']).groupby(['datetime', 'product_type', 'is_consumption'], as_index=False).mean()[product_cols]
        product_type_std = features_df.drop(columns=['row_id']).groupby(['datetime', 'product_type', 'is_consumption'], as_index=False).std()[product_cols]
        
        ### add lags for 2, 3, 4, 5, 6, 7, and 14 days ###       
        # cannot add 24h lag b/c minimum lag available (revealed targets) is 48h; 24h lag would introduce leakage.
        is_cons_mean = self._add_lagged_target_stats(is_cons_mean, cons_cols, "is_cons", "mean", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        is_cons_std = self._add_lagged_target_stats(is_cons_std, cons_cols, "is_cons", "std", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        
        county_mean = self._add_lagged_target_stats(county_mean, county_cols, "county", "mean", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        county_std = self._add_lagged_target_stats(county_std, county_cols, "county", "std", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        
        is_business_mean = self._add_lagged_target_stats(is_business_mean, business_cols, "is_business", "mean", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        is_business_std = self._add_lagged_target_stats(is_business_std, business_cols, "is_business", "std", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        
        product_type_mean = self._add_lagged_target_stats(product_type_mean, product_cols, "product_type", "mean", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        product_type_std = self._add_lagged_target_stats(product_type_std, product_cols, "product_type", "std", [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        
        dfs = [is_cons_mean, is_cons_std, county_mean, county_std, is_business_mean, is_business_std, product_type_mean, product_type_std]
        
        for df in dfs:
            df = self._reduce_float64(df)
            # drop target column from the stats b/c mean of same day target introduces leakage
            df.drop(columns=['target'], axis=1, inplace=True)
        
        ############################################################
        
        ## add revealed targets; lagged 2, 3, 4, 5, 6, 7, and 14 days ##
        target_cols = ['target', 'county', 'is_business', 'product_type', 'is_consumption', 'datetime']
        features_df = self._add_lagged_targets(features_df, target_cols, [2 * 24, 3 * 24, 4 * 24, 5 * 24, 6 * 24, 7 * 24, 14 * 24])
        
        ############################################################
        
        ## add lagged stats
        features_df = features_df.merge(is_cons_mean, how='left', on=cons_cols[:-1])
        features_df = features_df.merge(is_cons_std, how='left', on=cons_cols[:-1])
        
        features_df = features_df.merge(county_mean, how='left', on=county_cols[:-1])
        features_df = features_df.merge(county_std, how='left', on=county_cols[:-1])
        
        features_df = features_df.merge(is_business_mean, how='left', on=business_cols[:-1])
        features_df = features_df.merge(is_business_std, how='left', on=business_cols[:-1])
        
        features_df = features_df.merge(product_type_mean, how='left', on=product_cols[:-1])
        features_df = features_df.merge(product_type_std, how='left', on=product_cols[:-1])

        del is_cons_mean, is_cons_std, county_mean, county_std, is_business_mean, is_business_std, product_type_mean, product_type_std
        gc.collect()

        return features_df
    
    def _add_lagged_weather_stats(self, df:pd.DataFrame, cols:list, join_cols:list, orig_lag:int, suffix:str, lags:list):
        """
        df (dataframe): summary stats df (mean, std, median) - the result of a grouby - whose lags you want to generate.
        cols (list): relevant columns to generate the lags and left join
        join_cols (list): columns to left join on
        orig_lag (int): original lag based on what data is available for a forecast. may be 0. 
        suffix (str): formatted string w/ the summary stat and whether forecast or historical "{mean,std,med}_{fd,hd}"
        lags (list): hours to lag
        """
        next_df = df.loc[:, cols]
        exclude_cols = join_cols 
        
        for i, lag in enumerate(lags):
            next_df.loc[:, 'datetime'] = next_df.loc[:, 'datetime'] + pd.Timedelta(hours=lag)
            if i == 0:
                if lag == 0:
                    df = df.rename(columns={col: col + f"_{suffix}_0h" for col in df.columns.tolist() if col not in exclude_cols})
                else:
                    df = df.merge(next_df, how='left', on=join_cols, suffixes=(f"_{suffix}_{orig_lag}h", f"_{suffix}_{lag}h"))        
            # after the first iteration, the columns in next_df won't overlap w/ those in df b/c they've already got a suffix
            else:
                next_df = next_df.rename(columns={col: col + f"_{suffix}_{lag}h" for col in next_df.columns.tolist() if col not in exclude_cols})
                df = df.merge(next_df, how='left', on=join_cols)
            # subtract the lag added (reset the datetime back to original)
            next_df.loc[:, 'datetime'] = next_df.loc[:, 'datetime'] - pd.Timedelta(hours=lag)
        
        del next_df
        gc.collect()

        return df
    
    def add_forecast_weather_features(self, features_df:pd.DataFrame, forecast_df:pd.DataFrame, weather_station_to_county_mapping_df:pd.DataFrame):
        # setup
        # rename forecast_datetime left-join w/ features_df on datetime
        forecast_df.rename(columns={'forecast_datetime': 'datetime'}, inplace=True)

        # to align w/ forecast horizon, filter by 22-45 hours ahead 
        forecast_df = forecast_df.loc[(forecast_df['hours_ahead'] >= 22) & (forecast_df['hours_ahead'] <= 45)].copy()
        forecast_df.drop(columns=['hours_ahead'], axis=1, inplace=True)

        # add county ID by merging w/ weather_station_to_county_mapping_df
        forecast_df = forecast_df.merge(
            weather_station_to_county_mapping_df, how='left', on=['longitude', 'latitude'])

        # fill NaN counties w/ 12: code for 'Unknown' county according to county_id_to_name_map.json
        forecast_df['county'] = forecast_df['county'].fillna(12).astype('int64') 

        # drop longitude, latitude
        forecast_df.drop(columns=['longitude', 'latitude'], axis=1, inplace=True)
        
        # relevant columns to generate date/local summary stats and lagged features
        forecast_cols = ['county', 
                         'datetime', 
                         'temperature', 
                         'dewpoint', 
                         'cloudcover_high', 
                         'cloudcover_low', 
                         'cloudcover_mid', 
                         'cloudcover_total', 
                         '10_metre_u_wind_component', 
                         '10_metre_v_wind_component', 
                         'log_direct_solar_radiation', 
                         'log_surface_solar_radiation_downwards', 
                         'log_snowfall', 
                         'log_total_precipitation']

        ####### date stats: stats over all counties (and all weather stations in each county) on each unique datetime #######
        # date - mean over all counties on each unique date. drop county b/c left-joining back on 'datetime' only
        date_mean = forecast_df.groupby(['datetime'], as_index=False).mean()
        date_mean.drop(columns=['county'], axis=1, inplace=True) 
        # std
        date_std = forecast_df.groupby(['datetime'], as_index=False).std()
        date_std.drop(columns=['county'], axis=1, inplace=True)
        # date stats lagged 7 days from the original forecast_df datetimes
        date_mean = self._add_lagged_weather_stats(date_mean, forecast_cols[1:], ['datetime'], 0, "mean", [0, 7 * 24])
        date_std = self._add_lagged_weather_stats(date_std, forecast_cols[1:], ['datetime'], 0, "std", [0, 7 * 24])
        
        ####### local stats: stats over a county on each unique datetime (mean of county N on date YY-MM-DD) #######
        # filter where counties are known each time first and cast county as int to enable left-join
        # mean
        local_mean = forecast_df[~forecast_df['county'].isna()].groupby(['county', 'datetime'], as_index=False).mean()
        local_mean['county'] = local_mean['county'].fillna(12).astype('int64')
        # std
        local_std = forecast_df[~forecast_df['county'].isna()].groupby(['county', 'datetime'], as_index=False).std()
        local_std['county'] = local_std['county'].fillna(12).astype('int64')
        # local stats lagged 7 days from the original forecast_df datetimes
        local_mean = self._add_lagged_weather_stats(local_mean, forecast_cols, ['county', 'datetime'], 0, "mean", [0, 7 * 24])
        local_std = self._add_lagged_weather_stats(local_std, forecast_cols, ['county', 'datetime'], 0, "std", [0, 7 * 24])

        # reduce memory usage of intermediate dfs by casting float64 as float32
        [self._reduce_float64(df) for df in [date_mean, date_std, local_mean, local_std]]

        # join date stats
        features_df = features_df.merge(date_mean, how='left', on=['datetime'])
        features_df = features_df.merge(date_std, how='left', on=['datetime'])

        # join local stats
        features_df = features_df.merge(local_mean, how='left', on=['county', 'datetime'], suffixes=("_fd", "_fl"))
        features_df = features_df.merge(local_std, how='left', on=['county', 'datetime'], suffixes=("_fd", "_fl"))

        del date_mean, date_std, local_mean, local_std
        gc.collect()

        return features_df

    def add_historical_weather_features(self, features_df:pd.DataFrame, historical_df:pd.DataFrame, weather_station_to_county_mapping_df:pd.DataFrame):

        ## setup
        # add county ID by merging w/ weather_station_to_county_mapping_df
        historical_df = historical_df.merge(
            weather_station_to_county_mapping_df, how='left', on=['longitude', 'latitude'])

        # fill NaN counties w/ 12: code for Unknown county according to county_id_to_name_map.json
        historical_df['county'] = historical_df['county'].fillna(12).astype('int64')

        # drop longitude, latitude 
        historical_df.drop(columns=['longitude', 'latitude'], axis=1, inplace=True)

        # filter rows to include 12am-10am only for a given day
        historical_df = historical_df.loc[historical_df['datetime'].dt.hour <= 10]
        
        # relevant columns to generate date/local summary stats and lagged features        
        historical_cols = ['county', 
                           'datetime', 
                           'temperature', 
                           'dewpoint', 
                           'log_rain', 
                           'log_snowfall', 
                           'surface_pressure', 
                           'cloudcover_total', 
                           'cloudcover_low',
                           'cloudcover_mid', 
                           'cloudcover_high', 
                           'log_windspeed_10m',
                           'winddirection_10m', 
                           'log_shortwave_radiation', 
                           'log_direct_solar_radiation', 
                           'log_diffuse_radiation']

        ####### date stats: stats over all counties on each unique date #######
        ## base date stats. drop county b/c joining on 'datetime' only ##
        # mean
        date_mean = historical_df.groupby(['datetime'], as_index=False).mean()
        date_mean.drop(columns=['county'], axis=1, inplace=True) 
        # std
        date_std = historical_df.groupby(['datetime'], as_index=False).std()
        date_std.drop(columns=['county'], axis=1, inplace=True)
        # 1-day, 2-day, and 7-day lags for date stats
        date_mean = self._add_lagged_weather_stats(date_mean, historical_cols[1:], ['datetime'], 24, "mean", [2 * 24, 7 * 24])
        date_std = self._add_lagged_weather_stats(date_std, historical_cols[1:], ['datetime'], 24, "std", [2 * 24, 7 * 24])

        ####### local stats: stats over a county on each unique date (mean of county N on date YY-MM-DD) #######
        ## base local stats. to join on county, datetime: filter where counties are known each time first and cast county as int ## 
        # mean
        local_mean = historical_df[~historical_df['county'].isna()].groupby(['county', 'datetime'], as_index=False).mean()
        local_mean['county'] = local_mean['county'].fillna(12).astype('int64')
        # std
        local_std = historical_df[~historical_df['county'].isna()].groupby(['county', 'datetime'], as_index=False).std()
        local_std['county'] = local_std['county'].fillna(12).astype('int64')
        # 1-day and 7-day lags for local stats
        local_mean = self._add_lagged_weather_stats(local_mean, historical_cols, ['county', 'datetime'], 24, "mean", [2 * 24, 7 * 24])
        local_std = self._add_lagged_weather_stats(local_std, historical_cols, ['county', 'datetime'], 24, "std", [2 * 24, 7 * 24])

        # cast float64 as float32 to reduce memory usage
        [self._reduce_float64(df) for df in [date_mean, date_std, local_mean, local_std]]

        # join all date stats
        features_df = features_df.merge(date_mean, how='left', on=['datetime'])
        features_df = features_df.merge(date_std, how='left', on=['datetime'])

        # join all local stats
        features_df = features_df.merge(local_mean, how='left', on=['county', 'datetime'], suffixes=('_hd', '_hl'))
        features_df = features_df.merge(local_std, how='left', on=['county', 'datetime'], suffixes=('_hd', '_hl'))

        del date_mean, date_std, local_mean, local_std
        gc.collect()
        return features_df
    
    def add_electricity_prices_features(self, features_df:pd.DataFrame, electricity_df:pd.DataFrame):
        ## setup
        # rename forecast_date left-join w/ features_df on 'datetime'
        electricity_df.rename(columns={'forecast_date': 'datetime'}, inplace=True)

        # to align w/ forecast horizon, shift dates forward 1 day (equivalent to 24h lag)
        electricity_df.loc[:, 'datetime'] = electricity_df.loc[:, 'datetime'] + pd.Timedelta(days=1)
        ##

        # join
        features_df = features_df.merge(electricity_df, how='left', on=['datetime'])
        
        # indicate lag
        features_df.rename(columns={'log_euros_per_mwh': 'log_euros_per_mwh_24h'}, inplace=True)

        return features_df

    def add_gas_prices_features(self, features_df:pd.DataFrame, gas_df:pd.DataFrame):
        ## setup:
        # create date column in features_df to join on (will drop after) 
        features_df['date'] = pd.to_datetime(features_df['datetime'].dt.date)

        # rename forecast_date in gas_df to join on
        gas_df.rename(columns={'forecast_date': 'date'}, inplace=True)

        # to align w/ forecast horizon, shift dates forward 1 day (equivalent to 24h lag)
        gas_df.loc[:, 'date'] = gas_df.loc[:, 'date'] + pd.Timedelta(days=1)
        ##

        # join
        features_df = features_df.merge(gas_df, how='left', on=['date'])
        # indicate lag
        features_df.rename(columns={'log_lowest_price_per_mwh': 'log_lowest_price_per_mwh_24h', 'log_highest_price_per_mwh': 'log_highest_price_per_mwh_24h'}, inplace=True)

        # drop date
        features_df.drop(columns=['date'], axis=1, inplace=True)

        return features_df
    
    def add_is_holiday_flag(self, features_df:pd.DataFrame):
        # supports years 2021-2026, up to 2 years past competition's approx forecast horizon--approx May 2024
        estonian_holidays = holidays.country_holidays('EE', years=[2021, 2022, 2023, 2024, 2025, 2026])
        features_df['is_holiday'] = [date in estonian_holidays for date in features_df['datetime'].dt.date]

        return features_df

    def add_is_weekend_flag(self, features_df:pd.DataFrame):
        """
        must call after calling add_datepart
        add_datepart encodes Dayofweek as #'s b/w 0-6: 
        {0: monday, 1: tuesday ... 5: saturday, 6: sunday}
        """
        weekends = [5, 6]
        features_df['is_weekend'] = [day in weekends for day in features_df['datetimeDayofweek']]

        return features_df

    def add_date_features(self, features_df:pd.DataFrame):
        # add Year, Month, Week (of year), Day (of month), Dayof{week, year}, is_{start, end} of {month, quarter, year}
        features_df = add_datepart(features_df, 'datetime', drop=False)

        # add is_weekend flag
        features_df = self.add_is_weekend_flag(features_df)

        # add is_holiday flag
        features_df = self.add_is_holiday_flag(features_df)

        # capture orthogonal components (peaks/troughs vs phase shift) of Dayofyear, Hour
        # helps distinguish different months in the same year, hours in the same day
        # sin emphasizes vertical diplacement (peaks and troughs)
        # cos emphasizes horizontal displacement (phase shift)
        features_df['datetimeHour'] = features_df['datetime'].dt.hour
        features_df['sin(datetimeDayofyear)'] = np.sin(np.pi * features_df['datetimeDayofyear'] / 183)
        features_df['sin(datetimeHour)'] = np.sin(np.pi * features_df['datetimeHour'] / 12) 
        features_df['cos(datetimeDayofyear)'] = np.sin(np.pi * features_df['datetimeDayofyear'] / 183)
        features_df['cos(datetimeHour)'] = np.sin(np.pi * features_df['datetimeHour'] / 12) 

        return features_df
        
    def generate_features(self):
        
        # log scale skewed columns
        dfs = [self.data_storage.client_df, self.data_storage.forecast_df, self.data_storage.historical_df, self.data_storage.electricity_df, self.data_storage.gas_df]
        self.log_scale(dfs, self.skewed_cols)
        
        # merge the tables and add lagged weather data
        features_df = self.add_client_features(self.data_storage.train_df, self.data_storage.client_df)
        features_df = self.add_forecast_weather_features(features_df, self.data_storage.forecast_df, self.data_storage.weather_station_to_county_mapping_df)
        features_df = self.add_historical_weather_features(features_df, self.data_storage.historical_df, self.data_storage.weather_station_to_county_mapping_df)
        features_df = self.add_electricity_prices_features(features_df, self.data_storage.electricity_df)
        features_df = self.add_gas_prices_features(features_df, self.data_storage.gas_df)
        
        # add lagged targets
        features_df = self._add_target_features(features_df)
        
        # add enriched dates
        features_df = self.add_date_features(features_df)
        
        # filter missing targets
        dep_var = 'target'
        features_df = features_df[features_df[dep_var].notnull()]
        
        # fill otherwise missing values w/ column modes
        modes = features_df.mode().iloc[0].copy()
        features_df.fillna(modes, inplace=True)
        
        return features_df

In part 1, we made a first pass at modeling prosumers’ energy behavior. We added basic date features like DayofWeek, is_weekend, is_holiday, etc) and modeled both consumption and production together. Since the hardest examples were all consumption targets, we’ll dig into consumption behavior in part 2.

The guiding principle remains the same: investigate the data with simple models and iterate rapidly. To go further, we’ll add new features from revealed_targets, forecast_weather, and historical_weather tables.

To profile usage, I created the following features from revealed_targets:

  1. for the national vs local picture:
    1. mean and standard deviation over all segments for each datetime
    2. mean and standard deviation over all segments for each county for each datetime
  2. for how usage varies across energy contracts and across residential vs industrial clients:
    1. mean and standard deviation for each product_type for each datetime
    2. mean and standard deviation over all businesses and over all non-businesses for each datetime
  3. for day-to-day and weekly seasonality:
    1. 2, 3, 4, 5, 6, 7, and 14-day lags of both the targets and the above statistics

To capture the weather’s weekly seasonality, I created:

  1. 7-day lags from forecast_weather
  2. 1 and 7-day lags from historical_weather
Note

With time-series data, we can inadvertently leak information into the would-be training set before a train/test split, which will skew our performance. We must respect what’s actually knowable at forecast time.

In part 1, that risk was posed when merging the tables. Here, it’s posed when creating the revealed_targets’ summary statistics and the lag features. But we can rest assured because the statistics were added for each datetime and the lags were added based on what’s available at forecast time.

Finally, I streamlined the data processing with Storage and FeatureGenerator classes (see code above). Now we can have clean data with only a few lines of code:

Hide code
%%time
d = Storage()
feature_generator = FeatureGenerator(d)
CPU times: user 14.9 s, sys: 3.46 s, total: 18.4 s
Wall time: 18.8 s
Hide code
%%time 
features_df = feature_generator.generate_features()
CPU times: user 2min 3s, sys: 52.7 s, total: 2min 56s
Wall time: 2min 56s
Hide code
features_df.shape
(2017824, 359)

Let’s make sure we’re looking only at consumption here on.

Hide code
is_consumption_toggle = 1
features_df = features_df.loc[features_df["is_consumption"] == is_consumption_toggle]
Hide code
print(features_df.loc[features_df["is_consumption"] == 0].shape, 
      features_df.loc[features_df["is_consumption"] == 1].shape)
(0, 359) (1008912, 359)

We’ll split the data:

Show code
from numpy import random
random.seed(42)

URLs.LOCAL_PATH = Path('/notebooks/kaggle/enefit')
save_path = URLs.LOCAL_PATH/'predict-energy-behavior-of-prosumers/pkl_files'
pid_lookup_df = load_pickle(save_path/'pid_lookup.pkl')

# includes dates of revealed_targets (2 days behind first eval day feb 1, 2023)
train_dates = {
    'part': [pd.to_datetime('2022-11-01 00:00:00'), pd.to_datetime('2023-01-29 23:00:00')],
    'full': [pd.to_datetime('2021-09-01 00:00:00'), pd.to_datetime('2023-01-29 23:00:00')]
}

eval_dates = {'feb23': [pd.to_datetime('2023-02-01 00:00:00'), pd.to_datetime('2023-02-28 23:00:00')],
              'mar23': [pd.to_datetime('2023-03-01 00:00:00'), pd.to_datetime('2023-03-31 23:00:00')],
              'apr23': [pd.to_datetime('2023-04-01 00:00:00'), pd.to_datetime('2023-04-30 23:00:00')],
              'may23': [pd.to_datetime('2023-05-01 00:00:00'), pd.to_datetime('2023-05-31 23:00:00')]
             }

def get_endpoints(is_partial, features_df, eval_month:str):

    if is_partial:
        train_dates_conds = [features_df['datetime'] == train_dates['part'][0], 
                             features_df['datetime'] == train_dates['part'][1]
                            ]
    else:
        train_dates_conds = [features_df['datetime'] == train_dates['full'][0], 
                             features_df['datetime'] == train_dates['full'][1]
                            ]
    
    train_endpoints = [features_df['datetime'][train_dates_conds[0]].index[0], 
                       features_df['datetime'][train_dates_conds[1]].index[-1]
                      ]

    valid_dates_cond = [features_df['datetime'] == eval_dates[eval_month][0], 
                        features_df['datetime'] == eval_dates[eval_month][1]
                       ]

    valid_endpoints = [features_df['datetime'][valid_dates_cond[0]].index[0], 
                       features_df['datetime'][valid_dates_cond[1]].index[-1]
                      ]

    return train_endpoints, valid_endpoints

def get_splits(is_partial, features_df, eval_month:str):
    train_endpoints, valid_endpoints = get_endpoints(is_partial, features_df, eval_month)
    train = features_df.iloc[train_endpoints[0]:train_endpoints[1]+1].index.tolist()
    valid = features_df.iloc[valid_endpoints[0]:valid_endpoints[1]+1].index.tolist()
    splits = (train, valid)
    return splits

def drop_pids_from_train(features_df, splits, pid_lookup_df):
    
    max_n_pids_to_drop = 7
    training_pids = set(features_df.iloc[splits[0]].merge(pid_lookup_df, how='left', on=['row_id'])['prediction_unit_id'].unique().tolist())
    valid_pids = set(features_df.iloc[splits[1]].merge(pid_lookup_df, how='left', on=['row_id'])['prediction_unit_id'].unique().tolist())
    intersection_pids = list(training_pids.intersection(valid_pids))
    
    # pids drop list may not be continuous or sorted.
    intersection_pids.sort() 

    # drop from those not already missing in the valid fold, THIS ROUND.
    # to get the number of pids to drop THIS ROUND, pick a random int b/w 1 and the max allowed to drop.
    n_pids_drop = random.randint(1, max_n_pids_to_drop) 

    # to get the pids to drop, index intersection_pids w/ a random int b/w 0 and its last index. repeat for the n_pids_drop chosen for THIS ROUND. 
    pids_to_drop = [random.randint(0, len(intersection_pids)-1) for i in range(n_pids_drop)]

    # drop only from train (slice features_df by training dates from the endpoints, join to lookup pids, mask, apply the mask to the slice)
    # get its indices
    mask = features_df.iloc[splits[0]].merge(pid_lookup_df, how='left', on=['row_id'])['prediction_unit_id'].isin(pids_to_drop)

    return mask, n_pids_drop, pids_to_drop

def setup_tabular_pandas(features_df, procs, max_card, splits, dep_var='target'):  
    # variables
    cont, cat = cont_cat_split(features_df, max_card, dep_var=dep_var)

    # setup object
    return TabularPandas(features_df, procs, cat, cont, y_names=dep_var, splits=splits)

def setup_training(is_partial, features_df, pid_lookup_df, eval_month, procs, max_card):

    features_df = features_df.reset_index(drop=True)      
    tmp_splits = get_splits(is_partial, features_df, eval_month)

    # _get_splits -> _get_pids_drop_mask needs row_id to join pid_lookup_df
    pid_mask, n_pids_dropped, pids_dropped = drop_pids_from_train(features_df, tmp_splits, pid_lookup_df)
    valid_buffer = pd.Series(np.zeros(len(tmp_splits[1]), dtype='bool'))
    pid_mask = pd.concat([pid_mask, valid_buffer])
    pid_mask.index = tmp_splits[0] + tmp_splits[1]
    tmp_df = features_df.iloc[tmp_splits[0]+tmp_splits[1]][~pid_mask]
    features_df_final = tmp_df.reset_index(drop=True)

    # re-compute the splits after applying pid_mask.
    final_splits = get_splits(is_partial, features_df_final, eval_month)
    del tmp_splits, tmp_df
    gc.collect()

    # setup training
    procs = procs
    max_card = max_card
    to = setup_tabular_pandas(features_df_final, procs, max_card, final_splits)

    print(f"n_pids_dropped from train: {n_pids_dropped}")
    print(f"pids_dropped from train: {pids_dropped}")
    
    return to, final_splits
Hide code
is_partial = False
eval_month = 'feb23'
procs = [Categorify]
max_card = 20

to, final_splits = setup_training(is_partial, features_df, pid_lookup_df, eval_month, procs, max_card)
n_pids_dropped from train: 4
pids_dropped from train: [14, 60, 20, 23]
Hide code
def get_train_and_valid(to):
    xs, y = to.train.xs, to.train.y
    valid_xs, valid_y = to.valid.xs, to.valid.y
    
    return xs, y, valid_xs, valid_y

xs, y, valid_xs, valid_y = get_train_and_valid(to)
print(len(xs), len(y), len(valid_xs), len(valid_y))
765510 765510 44904 44904

2 Modeling with trees

We’ll train a random forest and look at its feature importances:

Hide code
def mae(preds, y): return round((preds-y).abs().mean(), 6)
def m_mae(m, xs, y): return mae(m.predict(xs), y)
Hide code
def rf(xs, y, n_estimators=40, max_features=0.5, min_samples_leaf=5, **kwargs):
    return RandomForestRegressor(n_jobs=-1, 
                                 n_estimators=n_estimators,
                                 max_samples=int(0.5*len(xs)), 
                                 max_features=max_features,
                                 min_samples_leaf=min_samples_leaf, 
                                 oob_score=True).fit(xs, y)
Hide code
%%time
m = rf(xs, y)
CPU times: user 1h 7min 12s, sys: 3.15 s, total: 1h 7min 15s
Wall time: 11min 46s
Hide code
print("train mae: ", m_mae(m, xs, y))
print("valid mae: ", m_mae(m, valid_xs, valid_y))
print("OOB error: ", mae(m.oob_prediction_, y))
train mae:  25.167187
valid mae:  58.477451
OOB error:  35.488559
Hide code
def rf_feat_importance(m, df):
    return pd.DataFrame({'cols':df.columns, 'imp':m.feature_importances_}
                       ).sort_values('imp', ascending=False)

def plot_fi(fi):
    return fi.plot('cols', 'imp', 'barh', figsize=(12,7), legend=False)
Hide code
fi = rf_feat_importance(m, xs)
fi[:10]
cols imp
291 target_168h 0.439617
292 target_336h 0.222098
290 target_144h 0.148621
286 target_48h 0.092684
289 target_120h 0.038081
319 target_county_std_168h 0.016042
320 target_county_std_336h 0.005730
287 target_72h 0.003078
312 target_county_mean_168h 0.002745
313 target_county_mean_336h 0.001813
Hide code
plot_fi(fi[:30]);
Figure 1: Plotting random forest feature importances

The targets’ 7-14 day history (particularly in relation to the county), forecasted sun exposure, installed capacity are most influential. The date and product type also appear among the top 30. In part 1, we discovered that the hardest examples were all consumption targets in Harjumaa, between 1-5pm on weekdays in Feb 2023. So far, the importances match intuition and prior observations.

Let’s zero-in on the most important features. With 358 columns, we’ll try keeping the top-scoring 30-40 features to keep things managable to study, which works out to a score of 0.0003 or more.

Hide code
fi[:40]
cols imp
291 target_168h 0.439617
292 target_336h 0.222098
290 target_144h 0.148621
286 target_48h 0.092684
289 target_120h 0.038081
... ... ...
22 cloudcover_low_mean_0h_fd 0.000321
19 temperature_mean_0h_fd 0.000310
39 log_direct_solar_radiation_mean_168h_fd 0.000298
352 datetimeElapsed 0.000297
20 dewpoint_mean_0h_fd 0.000286

40 rows × 2 columns

Hide code
to_keep = fi[fi.imp>0.0003].cols
len(xs.columns), len(to_keep)
(358, 37)
Hide code
xs_imp = xs[to_keep]
valid_xs_imp = valid_xs[to_keep]
Hide code
%%time
m = rf(xs_imp, y)
CPU times: user 9min 54s, sys: 948 ms, total: 9min 54s
Wall time: 1min 50s
Hide code
print("train mae: ", m_mae(m, xs_imp, y))
print("valid mae: ", m_mae(m, valid_xs_imp, valid_y))
print("OOB error: ", mae(m.oob_prediction_, y))
train mae:  27.49771
valid mae:  59.288315
OOB error:  36.503003

There’s minimal dropoff. We’ll remove redundancy next.

Hide code
cluster_columns(xs_imp, figsize=(12, 11))
Figure 2: Visualizing feature similarity

In the figure above, the most similar features are merged early, far from the tree’s root on the left.

The following appear closely related since they merged earliest:

  • datetime and row_id
  • cos(datetimeDayofyear) and sin(datetimeDayofyear)

Let’s see what we else can remove without losing accuracy. We’ll quickly train a small random forest and evaluate against OOB score; 1.0 for a perfect model and 0.0 for a random model.

Hide code
print("max_samples: ", int(0.05*len(xs_imp)))
def get_oob(df):
    m = RandomForestRegressor(n_estimators=40, min_samples_leaf=15,
        max_samples=int(0.05*len(xs_imp)), max_features=0.5, n_jobs=-1, oob_score=True)
    m.fit(df, y)
    return m.oob_score_
max_samples:  38275

Here’s our baseline:

Hide code
# baseline
get_oob(xs_imp)
0.9767854155799938

Dropping potentially redundant features one at a time first:

Hide code
%%time 
{c:get_oob(xs_imp.drop(c, axis=1)) for c in ('sin(datetimeDayofyear)', 'cos(datetimeDayofyear)', 'datetime', 'row_id',)
}
CPU times: user 4min 8s, sys: 333 ms, total: 4min 9s
Wall time: 1min 14s
{'sin(datetimeDayofyear)': 0.9767255145912113,
 'cos(datetimeDayofyear)': 0.9771046092088024,
 'datetime': 0.9764694897184758,
 'row_id': 0.9766699850153128}

Now multiple at once, one from each closely related pair:

Hide code
to_drop = ['cos(datetimeDayofyear)', 'row_id']
get_oob(xs_imp.drop(to_drop, axis=1))
0.9766544138948776

Looks like we can remove them with minimal impact. Let’s do so and train another model:

Hide code
xs_final = xs_imp.drop(to_drop, axis=1)
valid_xs_final = valid_xs_imp.drop(to_drop, axis=1)
Hide code
%%time
m = rf(xs_final, y)
CPU times: user 9min 7s, sys: 780 ms, total: 9min 8s
Wall time: 1min 40s
Hide code
print("train mae: ", m_mae(m, xs_final, y))
print("valid mae: ", m_mae(m, valid_xs_final, valid_y))
print("OOB error: ", mae(m.oob_prediction_, y))
train mae:  27.638821
valid mae:  61.716182
OOB error:  36.696784
Hide code
len(xs.columns), len(xs_final.columns)
(358, 35)

We can near match our initial performance with just 35 features.

3 Partial dependence analysis

We saw that the top two predictors were target_168h and target_336h. To see how they vary with the target, we’ll first note where the bulk of the data falls:

Hide code
valid_xs_final['target_168h'].hist()

Hide code
valid_xs_final['target_168h'].describe()
count    44904.000000
mean       645.946106
std       1511.926025
min          0.000000
25%         75.920498
50%        208.003502
75%        599.245728
max      14455.158203
Name: target_168h, dtype: float64
Hide code
valid_xs_final['target_336h'].hist()

Hide code
valid_xs_final['target_336h'].describe()
count    44904.000000
mean       648.020203
std       1518.707153
min          0.000000
25%         76.580250
50%        213.181000
75%        601.848007
max      15213.572266
Name: target_336h, dtype: float64

In both cases, most of the values are between 0 and 600.

Hide code
from sklearn.inspection import partial_dependence
from sklearn.inspection import PartialDependenceDisplay

fig,ax = plt.subplots(figsize=(12, 4))
PartialDependenceDisplay.from_estimator(m, valid_xs_final, ['target_168h', 'target_336h'], 
                                        grid_resolution=40, ax=ax)
Figure 3: Partial dependence plots of the 2 strongest predictors

Over most of the observations, the model is more responsive to the 7-day lag (left) than to the 14-day lag (right). The jump in lag‑7 around 625 suggests a threshold where it has greater influence on the prediction, up to twice that of lag‑14. The jump around 1500 may be unreliable since the data there is sparse.

4 Out-of-Domain analysis

Next, we’ll try to improve by alleviating domain shift. To find features with high domain shift, we’ll re-label the data such that the model learns to predict whether a row belongs to the training or validation set and then look at its feature importances:

Hide code
%time
df_dom = pd.concat([xs_final, valid_xs_final])
is_valid = np.array([0]*len(xs_final) + [1]*len(valid_xs_final))

m_dom = rf(df_dom, is_valid)
rf_feat_importance(m_dom, df_dom)[:10]
CPU times: user 4 µs, sys: 0 ns, total: 4 µs
Wall time: 9.3 µs
cols imp
31 datetime 0.825639
12 datetimeDayofyear 0.066974
25 sin(datetimeDayofyear) 0.051183
17 datetimeWeek 0.018788
22 target_is_business_std_336h 0.014452
18 target_is_business_std_168h 0.006258
30 target_is_business_mean_336h 0.004764
34 temperature_mean_0h_fd 0.004439
23 target_is_cons_std_168h 0.003322
32 is_business 0.001758

datetime shifts the most since it is incremented most frequently. datetimeDayofyear, sin(datetimeDayofyear) and datetimeWeek increment less frequently and within a narrower range of values, so it makes sense why they shift less.

Let’s see if removing them helps, one at a time:

Hide code
%%time
m = rf(xs_final, y)
print('baseline', m_mae(m, valid_xs_final, valid_y))

for c in ('datetime', 'datetimeDayofyear', 'sin(datetimeDayofyear)', 'datetimeWeek', 'target_is_business_std_336h'):
    m = rf(xs_final.drop(c,axis=1), y)
    print(c, m_mae(m, valid_xs_final.drop(c,axis=1), valid_y))
baseline 59.820467
datetime 54.874721
datetimeDayofyear 59.782336
sin(datetimeDayofyear) 60.921511
datetimeWeek 61.63401
target_is_business_std_336h 59.599748
CPU times: user 54min 46s, sys: 3.39 s, total: 54min 50s
Wall time: 10min 9s

Looks like we can drop datetime, datetimeDayofyear, and target_is_business_std_336h safely. Let’s see:

Hide code
%%time
time_vars = ['datetime', 'datetimeDayofyear', 'target_is_business_std_336h']
xs_final_time = xs_final.drop(time_vars, axis=1)
valid_xs_time = valid_xs_final.drop(time_vars, axis=1)

m = rf(xs_final_time, y)
print("train mae: ", m_mae(m, xs_final_time, y))
print("valid mae: ", m_mae(m, valid_xs_time, valid_y))
print("OOB error: ", mae(m.oob_prediction_, y))
train mae:  28.161286
valid mae:  54.342945
OOB error:  37.281973
CPU times: user 8min 51s, sys: 586 ms, total: 8min 52s
Wall time: 1min 39s

Looking good. Lastly, it may help to not use old data. Let’s see whether dropping data prior to Jan 2022 helps:

Hide code
filt = xs['datetimeYear']>1
xs_filt = xs_final_time[filt]
y_filt = y[filt]
Hide code
%%time
m = rf(xs_filt, y_filt)
print("train mae: ", m_mae(m, xs_filt, y_filt))
print("valid mae: ", m_mae(m, valid_xs_time, valid_y))
print("OOB error: ", mae(m.oob_prediction_, y_filt))
train mae:  28.955334
valid mae:  53.214851
OOB error:  38.403713
CPU times: user 6min 18s, sys: 509 ms, total: 6min 19s
Wall time: 1min 11s
Hide code
len(xs.columns), len(xs_filt.columns)
(358, 32)
Hide code
len(xs), len(xs_filt)
(765510, 594257)

Our initial model, using 358 features and the whole dataset, achieved a 58.477 validation error. Our simplified model uses 32 features and 22% fewer records yet improves to a 53.214 validation error.

5 Modeling with a neural network

Show code
from numpy import random
random.seed(42)

is_partial = False
eval_month = 'feb23'
procs = [Categorify, Normalize]
max_card = 9000
dep_var = 'target'

# need ['datetime', 'row_id'] for the splits, which are not in the final feature set. Dropped afterward. 
to_nn, final_splits = setup_training(is_partial, 
                                     features_df[list(xs_final_time.columns) + ['datetime', 'row_id', dep_var]], 
                                     pid_lookup_df, 
                                     eval_month, 
                                     procs, 
                                     max_card)
n_pids_dropped from train: 4
pids_dropped from train: [14, 60, 20, 23]

Let’s confirm that TabularPandas correctly identified continuous and categorical variables:

Hide code
to_nn.cont_names.remove('row_id')
to_nn.cat_names.remove('datetime')

{'cont_names': list(to_nn.cont_names), 
 'cat_names': list(to_nn.cat_names)
}
{'cont_names': ['target_168h',
  'target_336h',
  'target_144h',
  'target_48h',
  'target_120h',
  'target_county_std_168h',
  'target_county_std_336h',
  'target_72h',
  'target_county_mean_168h',
  'target_county_mean_336h',
  'target_96h',
  'log_surface_solar_radiation_downwards_mean_0h_fd',
  'log_installed_capacity_48h',
  'log_direct_solar_radiation_mean_0h_fd',
  'log_eic_count_48h',
  'target_is_business_std_168h',
  'target_county_std_144h',
  'target_is_cons_std_168h',
  'sin(datetimeDayofyear)',
  'target_product_type_std_168h',
  'target_county_std_48h',
  'log_direct_solar_radiation_std_0h_fd',
  'log_surface_solar_radiation_downwards_mean_168h_fd',
  'target_is_business_mean_336h',
  'cloudcover_low_mean_0h_fd',
  'temperature_mean_0h_fd'],
 'cat_names': ['county',
  'datetimeWeek',
  'is_holiday',
  'product_type',
  'datetimeDayofweek',
  'is_business']}

Looks good. Now we’ll train the network.

Hide code
def nn_mae(preds, y): return torch.round((preds - y).abs().mean(), decimals=6)
Hide code
bs = 1024
dls = to_nn.dataloaders(bs)
Hide code
y = to_nn.train.y
y.min(),y.max()
(0.0, 15480.274)
Hide code
y_max_factor = 3
np.ceil(y_max_factor*y.max())
46441.0
Hide code
y_max = np.ceil(y_max_factor*y.max())
layers = [1000, 500]
Hide code
learn = tabular_learner(dls, y_range=(0, y_max), layers=layers,
                        n_out=1, loss_func=F.l1_loss)
Hide code
learn.lr_find(suggest_funcs=[slide, valley])
SuggestedLRs(slide=6.309573450380412e-07, valley=0.001737800776027143)

We’ll use a 1e-3 learning rate and train for a few epochs:

Hide code
learn.fit_one_cycle(5, 1e-3)
epoch train_loss valid_loss time
0 867.123108 617.088562 00:10
1 94.476021 90.209175 00:10
2 59.981285 67.862297 00:11
3 50.621361 59.680435 00:11
4 47.086075 57.954315 00:10

Let’s see some predictions (scroll to the right end):

Hide code
learn.show_results()
county datetimeWeek is_holiday product_type datetimeDayofweek is_business target_168h target_336h target_144h target_48h target_120h target_county_std_168h target_county_std_336h target_72h target_county_mean_168h target_county_mean_336h target_96h log_surface_solar_radiation_downwards_mean_0h_fd log_installed_capacity_48h log_direct_solar_radiation_mean_0h_fd log_eic_count_48h target_is_business_std_168h target_county_std_144h target_is_cons_std_168h sin(datetimeDayofyear) target_product_type_std_168h target_county_std_48h log_direct_solar_radiation_std_0h_fd log_surface_solar_radiation_downwards_mean_168h_fd target_is_business_mean_336h cloudcover_low_mean_0h_fd temperature_mean_0h_fd target target_pred
0 15.0 8.0 1.0 2.0 6.0 2.0 -0.262886 -0.239930 -0.260615 -0.253527 -0.262182 -0.202138 -0.307286 -0.247566 -0.058346 -0.111946 -0.253991 -0.889688 -0.362890 -0.827848 -0.497701 0.772622 -0.282687 0.029818 1.397399 -0.679453 -0.069211 -0.751869 -0.883614 0.964591 1.459237 -1.211010 127.239998 134.697464
1 4.0 7.0 1.0 2.0 7.0 2.0 -0.330954 -0.323535 -0.334922 -0.338295 -0.335445 -0.187287 -0.164677 -0.336010 -0.142387 -0.084139 -0.337699 -0.889688 -0.912775 -0.827848 -0.941712 0.653363 -0.223687 -0.136800 1.306189 -0.697084 -0.247657 -0.751869 -0.883614 0.893931 0.495124 -0.811021 30.806000 46.891251
2 5.0 5.0 1.0 2.0 4.0 1.0 -0.344467 -0.342138 -0.347831 -0.347422 -0.344598 -0.340768 -0.363358 -0.347488 -0.202386 -0.229166 -0.345382 -0.889688 -0.594519 -0.827848 -0.497701 -0.594282 -0.368845 0.176550 0.988584 -0.697989 -0.412845 -0.751869 -0.883614 -0.523382 1.371129 -0.838103 19.188999 25.702089
3 3.0 7.0 1.0 2.0 7.0 2.0 -0.289490 -0.279803 -0.255431 -0.248302 -0.272753 -0.446600 -0.421871 -0.239236 -0.453071 -0.407710 -0.268927 1.030597 -1.226184 0.143757 -1.027643 1.120253 -0.424511 0.571177 1.306189 -0.669880 -0.322436 2.449245 1.079126 0.928739 1.283659 -0.868476 96.105003 73.928062
4 16.0 8.0 1.0 2.0 7.0 2.0 -0.183543 -0.193427 -0.194822 -0.215773 -0.188846 -0.686955 -0.673423 -0.192416 -0.496016 -0.502450 -0.170561 -0.889688 0.245607 -0.827848 -0.201525 0.982311 -0.657861 0.407525 1.411405 -0.635931 -0.685927 -0.751869 -0.883614 0.968042 -0.691459 -1.517333 188.632004 208.133240
5 4.0 7.0 1.0 2.0 4.0 2.0 -0.330865 -0.320998 -0.334936 -0.338640 -0.330539 -0.167406 -0.119311 -0.336651 -0.143195 -0.080749 -0.332357 -0.889688 -0.912775 -0.827848 -0.941712 1.373603 -0.117017 1.038111 1.256177 -0.603400 -0.202103 -0.751869 -0.883614 1.533619 1.582296 -0.634451 46.298000 53.170086
6 4.0 5.0 1.0 4.0 4.0 2.0 0.575863 0.502346 0.570968 0.550353 0.558134 -0.204478 -0.250005 0.511721 -0.218346 -0.252642 0.532916 -0.889688 1.269762 -0.827848 0.525585 0.855744 -0.211454 0.152174 0.988584 1.158699 -0.226203 -0.751869 -0.883614 0.865195 1.446751 -0.840503 1089.301025 1143.673706
7 11.0 6.0 1.0 2.0 6.0 1.0 -0.285181 -0.302084 -0.304559 -0.306237 -0.307852 -0.487169 -0.495147 -0.307560 -0.255986 -0.339585 -0.306730 -0.889688 -0.087960 -0.827848 0.423409 -0.373181 -0.508577 0.262175 1.166752 -0.644339 -0.466170 -0.751869 -0.883614 -0.397293 -1.234372 -1.086801 70.140999 70.644394
8 16.0 6.0 1.0 2.0 7.0 1.0 -0.296332 -0.299581 -0.313292 -0.314098 -0.311906 -0.637992 -0.634026 -0.332257 -0.458398 -0.461850 -0.308999 0.983235 -0.584762 0.296804 0.348233 -0.691865 -0.314547 0.217005 1.185220 -0.742475 -0.406558 2.658769 1.114219 -0.522843 -0.189756 -0.665068 61.251999 75.555504

These look reasonable. Let’s train a few more epochs:

Hide code
learn = tabular_learner(dls, y_range=(0, y_max), layers=layers,
                        n_out=1, loss_func=F.l1_loss)

learn.fit_one_cycle(10, 1e-3)
epoch train_loss valid_loss time
0 1498.909546 1356.150757 00:11
1 256.420807 394.731262 00:11
2 77.300156 69.955940 00:10
3 67.845467 101.109375 00:11
4 58.168907 81.163322 00:11
5 49.652458 77.220787 00:10
6 45.482182 66.377060 00:11
7 42.340202 59.934906 00:10
8 41.456375 59.453957 00:11
9 40.070374 60.974846 00:11
Hide code
preds, targs = learn.get_preds()
print("nn valid mae: ", nn_mae(preds, targs))
nn valid mae:  tensor(60.9748)

The neural net didn’t beat our random forest’s 53.214 error on its own, but let’s see if ensembling can take us further.

6 Ensembling

Hide code
rf_preds = m.predict(valid_xs_time)
ens_preds = (to_np(preds.squeeze()) + rf_preds) /2

Let’s check the error:

Hide code
(ens_preds - valid_y).abs().mean()
51.8291125209257

Nice!

7 Entity embeddings in a random forest

Let’s see if we can improve still using the network’s categorical embeddings in a random forest.

Show code
def embed_features(learner, xs):
    # deep copy (creates new object w/ a copy of indices and data of original)
    xs = xs.copy()
    for i, feature in enumerate(learn.dls.cat_names):
        
        # get the embedding (move it to CPU first)
        # .cpu() see nn.ModuleList.cpu?
        # for learn.model.embeds, see learn.model??
        emb = learn.model.embeds[i].cpu()
        
        # embed the categorical feature and return a df using the indices of xs
        # for embedding_dim see nn.Embedding??
        # join previously failed b/c col names overlap for each embedding feature
        # they were previously all to be named feature_0, feature_1, etc.
        new_feat = pd.DataFrame(emb(tensor(xs[feature], dtype=torch.int64)), 
                                index=xs.index, 
                                columns=[f"{feature}_{j}" for j in range(emb.embedding_dim)])
        
        # drop the categorical feature
        xs.drop(columns=feature, inplace=True)
        
        # left join the embedded features w/ xs
        # returns nn's cat embeddings concatenated w/ original cont variables
        # the cont variables will be the same for the rf and the nn
        xs = xs.join(new_feat)
    return xs
Hide code
embedded_xs = embed_features(learn, learn.dls.train.xs)
embedded_valid_xs = embed_features(learn, learn.dls.valid.xs)
embedded_xs.shape, embedded_valid_xs.shape
((765510, 64), (44904, 64))
Hide code
embedded_xs.columns
Index(['target_168h', 'target_336h', 'target_144h', 'target_48h',
       'target_120h', 'target_county_std_168h', 'target_county_std_336h',
       'target_72h', 'target_county_mean_168h', 'target_county_mean_336h',
       'target_96h', 'log_surface_solar_radiation_downwards_mean_0h_fd',
       'log_installed_capacity_48h', 'log_direct_solar_radiation_mean_0h_fd',
       'log_eic_count_48h', 'target_is_business_std_168h',
       'target_county_std_144h', 'target_is_cons_std_168h',
       'sin(datetimeDayofyear)', 'target_product_type_std_168h',
       'target_county_std_48h', 'log_direct_solar_radiation_std_0h_fd',
       'log_surface_solar_radiation_downwards_mean_168h_fd',
       'target_is_business_mean_336h', 'cloudcover_low_mean_0h_fd',
       'temperature_mean_0h_fd', 'county_0', 'county_1', 'county_2',
       'county_3', 'county_4', 'county_5', 'county_6', 'county_7',
       'datetimeWeek_0', 'datetimeWeek_1', 'datetimeWeek_2', 'datetimeWeek_3',
       'datetimeWeek_4', 'datetimeWeek_5', 'datetimeWeek_6', 'datetimeWeek_7',
       'datetimeWeek_8', 'datetimeWeek_9', 'datetimeWeek_10',
       'datetimeWeek_11', 'datetimeWeek_12', 'datetimeWeek_13',
       'datetimeWeek_14', 'is_holiday_0', 'is_holiday_1', 'is_holiday_2',
       'product_type_0', 'product_type_1', 'product_type_2', 'product_type_3',
       'datetimeDayofweek_0', 'datetimeDayofweek_1', 'datetimeDayofweek_2',
       'datetimeDayofweek_3', 'datetimeDayofweek_4', 'is_business_0',
       'is_business_1', 'is_business_2'],
      dtype='object')

Let’s see how we do:

Hide code
%%time
m_cat_emb = rf(embedded_xs, y)
# pass m_cat_emb for the model. includes both cont variables and nn's cat embeddings
# pass the embedded xs for both train and valid errors
print("train mae: ", m_mae(m_cat_emb, embedded_xs, y))
print("valid mae: ", m_mae(m_cat_emb, embedded_valid_xs, valid_y))
print("OOB mae: ", mae(m_cat_emb.oob_prediction_, y))
train mae:  25.805595
valid mae:  57.233348
OOB mae:  34.437786
CPU times: user 12min 19s, sys: 3.13 s, total: 12min 22s
Wall time: 2min 16s

Not this time, but still interesting to note that it’s better than the neural network and our initial random forest on their own!

8 Next steps

In part 3, we’ll model production.