Construyendo un modelo de regresión para predecir las duraciones de entrega: una guía práctica | por Jimin Kang | Diciembre de 2024

Preparación de datos y análisis exploratorio

Ahora que hemos esbozado nuestro enfoque, echemos un vistazo a nuestros datos y con qué tipo de características estamos trabajando.

De lo anterior, vemos que nuestros datos contienen ~ 197,000 entregas, con una variedad de características numéricas y no numéricas. A ninguna de las características le faltan un gran porcentaje de valores (más bajo conteo no nulo ~ 181,000), por lo que probablemente no tendremos que preocuparnos por dejar caer cualquier característica por completo.

Verifiquemos si nuestros datos contienen entregas duplicadas, y si hay alguna observación para las que no podemos calcular el tiempo de entrega.

print(f"Number of duplicates: {df.duplicated().sum()} \n")

print(pd.DataFrame({'Missing Count': df[['created_at', 'actual_delivery_time']].isna().sum()}))

Vemos que todas las entregas son únicas. Sin embargo, hay 7 entregas a las que les faltan un valor para real_delivery_time, lo que significa que no podremos calcular la duración de la entrega para estos pedidos. Como solo hay un puñado de estos, eliminaremos estas observaciones de nuestros datos.

Ahora, creemos nuestro objetivo de predicción. Queremos predecir la duración de la entrega (en segundos), que es el tiempo transcurrido entre cuando el cliente realizó el pedido (‘creation_at’) y cuando recibió el pedido (‘real_delivery_time’).

# convert columns to datetime 
df['created_at'] = pd.to_datetime(df['created_at'], utc=True)
df['actual_delivery_time'] = pd.to_datetime(df['actual_delivery_time'], utc=True)

# create prediction target
df['seconds_to_delivery'] = (df['actual_delivery_time'] - df['created_at']).dt.total_seconds()

Lo último que haremos antes de dividir nuestros datos en tren/prueba es verificar los valores faltantes. Ya vimos los recuentos no nulos para cada característica anterior, pero veamos las proporciones para obtener una mejor imagen.

Vemos que las características del mercado (‘OnHift_Dashers’, ‘Busy_Dashers’, ‘Overstanding_orders’) tienen el mayor porcentaje de valores faltantes (~ 8% falta). La característica con la segunda velocidad de datos faltante más alta es ‘store_primary_category’ (~ 2%). Todas las demás características han perdido <1%.

Dado que ninguna de las características tiene un conteo que falta alto, no eliminaremos ninguna de ellas. Más adelante, analizaremos las distribuciones de características para ayudarnos a decidir cómo lidiar adecuadamente con las observaciones faltantes para cada característica.

Pero primero, dividamos nuestros datos en tren/prueba. Procederemos con una división 80/20, y escribiremos estos datos de prueba en un archivo separado que no tocaremos hasta que evalúe nuestro modelo final.

from sklearn.model_selection import train_test_split
import os

# shuffle
df = df.sample(frac=1, random_state=42)
df = df.reset_index(drop=True)

# split
train_df, test_df = train_test_split(df, test_size=0.2, random_state=42)

# write test data to separate file
directory = 'datasets'
file_name = 'test_data.csv'
file_path = os.path.join(directory, file_name)
os.makedirs(directory, exist_ok=True)
test_df.to_csv(file_path, index=False)

Ahora, vamos a sumergirnos en los detalles de nuestro tren. Estableceremos nuestras características numéricas y categóricas para dejar en claro a qué columnas se están haciendo referencia en pasos exploratorios posteriores.

categorical_feats = [
'market_id',
'store_id',
'store_primary_category',
'order_protocol'
]

numeric_feats = [
'total_items',
'subtotal',
'num_distinct_items',
'min_item_price',
'max_item_price',
'total_onshift_dashers',
'total_busy_dashers',
'total_outstanding_orders',
'estimated_order_place_duration',
'estimated_store_to_consumer_driving_duration'
]

Revisemos las características categóricas con valores faltantes (‘Market_id’, ‘store_primary_category’, ‘Order_protocol’). Dado que había pocos datos faltantes entre esas características (<3%), simplemente imputaremos esos valores faltantes con una categoría "desconocida".

  • De esta manera, no tendremos que eliminar los datos de otras características.
  • Quizás la ausencia de valores de características contiene cierta potencia predictiva para la duración de la entrega, es decir, estas características no son Falta al azar.
  • Además, agregaremos este paso de imputación a nuestra tubería de preprocesamiento durante el modelado, para que no tengamos que duplicar manualmente este trabajo en nuestro conjunto de pruebas.
missing_cols_categorical = ['market_id', 'store_primary_category', 'order_protocol']

train_df[missing_cols_categorical] = train_df[missing_cols_categorical].fillna("unknown")

Veamos nuestras características categóricas.

pd.DataFrame({'Cardinality': train_df[categorical_feats].nunique()}).rename_axis('Feature')

Dado que ‘Market_id’ & ‘Order_Protocol’ tiene baja cardinalidad, podemos visualizar sus distribuciones fácilmente. Por otro lado, ‘store_id’ & ‘store_primary_category’ son características de alta cardinalidad. Echaremos un vistazo más profundo a los más tarde.

import seaborn as sns
import matplotlib.pyplot as plt

categorical_feats_subset = [
'market_id',
'order_protocol'
]

# Set up the grid
fig, axes = plt.subplots(1, len(categorical_feats_subset), figsize=(13, 5), sharey=True)

# Create barplots for each variable
for i, col in enumerate(categorical_feats_subset):
sns.countplot(x=col, data=train_df, ax=axes[i])
axes[i].set_title(f"Frequencies: {col}")

# Adjust layout
plt.tight_layout()
plt.show()

Algunas cosas clave a tener en cuenta:

  • ~ 70% de los pedidos realizados tienen ‘Market_id’ de 1, 2, 4
  • <1% de los pedidos tienen 'Order_Protocol' de 6 o 7

Desafortunadamente, no tenemos ninguna información adicional sobre estas variables, como qué valores ‘Market_id’ están asociados con las cuales las ciudades/ubicaciones, y qué representa cada número ‘Order_Protocol’. En este punto, solicitar datos adicionales sobre esta información puede ser una buena idea, ya que puede ayudar a investigar las tendencias en la duración de la entrega en categorizaciones de región/ubicación más amplias.

Veamos nuestras características categóricas de cardinalidad más alta. ¿Quizás cada ‘store_primary_category’ tiene un rango asociado ‘store_id’? Si es así, es posible que no necesitemos ‘store_id’, como ‘store_primary_category’ ya encapsularía mucha información sobre la tienda que se está ordenando.

store_info = train_df[['store_id', 'store_primary_category']]

store_info.groupby('store_primary_category')['store_id'].agg(['min', 'max'])

Claramente no es el caso: vemos que los rangos ‘store_id’ se superponen a través de los niveles de ‘store_primary_category’.

Un vistazo rápido a los valores distintos y las frecuencias asociadas para ‘store_id’ & ‘store_primary_category’ muestra que estas características tienen alta cardinalidad y se distribuyen escasamente. En general, las características categóricas de alta cardinalidad pueden ser problemáticas en las tareas de regresión, particularmente para los algoritmos de regresión que requieren datos únicamente numéricos. Cuando estas características de alta cardinalidad están codificadas, pueden ampliar drásticamente el espacio de características, haciendo que los datos disponibles sean escasos y disminuyendo la capacidad del modelo para generalizar a nuevas observaciones en ese espacio de características. Para una explicación mejor y más profesional de los fenómenos, puede leer más al respecto. aquí.

Tengamos una idea de cuán distribuidas escasamente están estas características.

store_id_values = train_df['store_id'].value_counts()

# Plot the histogram
plt.figure(figsize=(8, 5))
plt.bar(store_id_values.index, store_id_values.values, color='skyblue')

# Add titles and labels
plt.title('Value Counts: store_id', fontsize=14)
plt.xlabel('store_id', fontsize=12)
plt.ylabel('Frequency', fontsize=12)
plt.xticks(rotation=45) # Rotate x-axis labels for better readability
plt.tight_layout()
plt.show()

Vemos que hay un puñado de tiendas que tienen cientos de órdenes, pero la mayoría de ellas tienen mucho menos de 100.

Para manejar la alta cardinalidad de ‘store_id’, crearemos otra característica, ‘store_id_freq’, que agrupa los valores ‘store_id’ por frecuencia.

  • Agruparemos los valores ‘store_id’ en cinco contenedores de percentiles diferentes que se muestran a continuación.
  • ‘store_id_freq’ tendrá una cardinalidad mucho menor que ‘store_id’, pero conservará la información relevante sobre la popularidad de la tienda de la que se ordenó la entrega.
  • Para obtener más inspiración detrás de esta lógica, mira esto hilo.
def encode_frequency(freq, percentiles) -> str:
if freq < percentiles[0]:
return '[0-50)'
elif freq < percentiles[1]:
return '[50-75)'
elif freq < percentiles[2]:
return '[75-90)'
elif freq < percentiles[3]:
return '[90-99)'
else:
return '99+'

value_counts = train_df['store_id'].value_counts()
percentiles = np.percentile(value_counts, [50, 75, 90, 99])

# apply encode_frequency to each store_id based on their number of orders
train_df['store_id_freq'] = train_df['store_id'].apply(lambda x: encode_frequency(value_counts[x], percentiles))

pd.DataFrame({'Count':train_df['store_id_freq'].value_counts()}).rename_axis('Frequency Bin')

Nuestra codificación nos muestra que se ordenaron ~ 60,000 entregas de las tiendas catgorizadas en el percentil 90-99 en términos de popularidad, mientras que se ordenaron ~ 12,000 entregas de tiendas que estaban en el percentil 0-50 de popularidad.

Ahora que hemos (intentamos) capturar información relevante ‘store_id’ en una dimensión inferior, intentemos hacer algo similar con ‘store_primary_category’.

Veamos los niveles más populares de ‘store_primary_category’.

Una mirada rápida nos muestra que muchos de estos niveles ‘store_primary_category’ no son exclusivos entre sí (ex: ‘americano’ y ‘hamburguesa’). Una investigación adicional muestra muchos más ejemplos de este tipo de superposición.

Por lo tanto, intentemos mapear estas categorías distintas de la tienda en algunos grupos básicos y abarrotables.

store_category_map = {
'american': ['american', 'burger', 'sandwich', 'barbeque'],
'asian': ['asian', 'chinese', 'japanese', 'indian', 'thai', 'vietnamese', 'dim-sum', 'korean',
'sushi', 'bubble-tea', 'malaysian', 'singaporean', 'indonesian', 'russian'],
'mexican': ['mexican'],
'italian': ['italian', 'pizza'],
}

def map_to_category_type(category: str) -> str:
for category_type, categories in store_category_map.items():
if category in categories:
return category_type
return "other"

train_df['store_category_type'] = train_df['store_primary_category'].apply(lambda x: map_to_category_type(x))

value_counts = train_df['store_category_type'].value_counts()

# Plot pie chart
plt.figure(figsize=(6, 6))
value_counts.plot.pie(autopct='%1.1f%%', startangle=90, cmap='viridis', labels=value_counts.index)
plt.title('Category Distribution')
plt.ylabel('') # Hide y-axis label for aesthetics
plt.show()

Esta agrupación es probablemente brutalmente simple, y puede haber una mejor manera de agrupar estas categorías de tiendas. Proceda con él por ahora por simplicidad.

Hemos realizado una gran cantidad de investigación sobre nuestras características categóricas. Veamos las distribuciones de nuestras características numéricas.

# Create grid for boxplots
fig, axes = plt.subplots(nrows=5, ncols=2, figsize=(12, 15)) # Adjust figure size
axes = axes.flatten() # Flatten the 5x2 axes into a 1D array for easier iteration

# Generate boxplots for each numeric feature
for i, column in enumerate(numeric_feats):
sns.boxplot(y=train_df[column], ax=axes[i])
axes[i].set_title(f"Boxplot for {column}")
axes[i].set_ylabel(column)

# Remove any unused subplots (if any)
for i in range(len(numeric_feats), len(axes)):
fig.delaxes(axes[i])

# Adjust layout for better spacing
plt.tight_layout()
plt.show()

Phaplets para un subconjunto de nuestras características numéricas

Muchas de las distribuciones parecen estar más bien sesgadas de lo que se deben a la presencia de valores atípicos.

En particular, parece haber un pedido con más de 400 artículos. Esto parece extraño ya que el siguiente pedido más grande es menos de 100 artículos.

Veamos más en ese pedido de más de 400 artículos.

train_df[train_df['total_items']==train_df['total_items'].max()]