Statistiche della paninoteca

Economics
Optimization
R
ita
Author

FR

Published

December 23, 2025

report delle vendite della paninoteca

Si riportano le analisi delle vendite della paninoteca dell’oratorio Regina Pacis di Saronno, negli anni dal 2021 al 2025. Tale servizio si svolge tipicamente in una settimana di fine settembre. Il dataset raccolto, storico_vendite_2021-2025.csv, è disponibile su github. Le analisi sono svolte su R, e il codice è mostrato per completezza, ma non è il principale oggetto di interesse di questo rapporto

Code
library(readr)
library(dplyr)

df = read_csv2('storico_vendite_2021-2025.csv')

summary(df)
       Q                P         n_day_clients     n_day_sells   
 Min.   :  0.00   Min.   :1.000   Min.   : 18.00   Min.   : 49.0  
 1st Qu.:  2.75   1st Qu.:2.500   1st Qu.: 46.00   1st Qu.:115.0  
 Median :  7.00   Median :3.000   Median : 88.00   Median :198.0  
 Mean   : 15.24   Mean   :3.092   Mean   : 84.33   Mean   :216.4  
 3rd Qu.: 15.25   3rd Qu.:4.000   3rd Qu.:115.00   3rd Qu.:302.0  
 Max.   :141.00   Max.   :6.000   Max.   :213.00   Max.   :554.0  
     prod             weekday               year     
 Length:288         Length:288         Min.   :2021  
 Class :character   Class :character   1st Qu.:2022  
 Mode  :character   Mode  :character   Median :2023  
                                       Mean   :2023  
                                       3rd Qu.:2024  
                                       Max.   :2025  

statistiche univariate

Le variabili presenti nel dataset sono 5 (colonne), per 288 osservazioni (righe). Ogni osservazione è una combinazione di un prodotto venduto nella sera di un anno in un giorno della settimana a un dato prezzo.

Ecco le prime 5 righe del dataset, a titolo di esempio:

Code
head(df, 5)
# A tibble: 5 × 7
      Q     P n_day_clients n_day_sells prod            weekday  year
  <dbl> <dbl>         <dbl>       <dbl> <chr>           <chr>   <dbl>
1    28     4            54         131 salamella       mar      2021
2    14     3            54         131 cotto_e_fontina mar      2021
3     2     3            54         131 vegetariano     mar      2021
4     5     3            54         131 speck           mar      2021
5     5     3            54         131 hot_dog         mar      2021

Descrizione sintetica di ogni variabile:

  • Q : quantità venduta (conteggio dei piatti venduti nell’arco della giornata)

  • P : prezzo unitario di vendita

  • year : numero dell’anno corrente

  • weekday : testo di tre lettere indicante il giorno della settimana (Lun-Dom). Viene codificata come una serie di 6 dummy (non 7), ovvero 6 variabili ciascuna pari a 1 se ci troviamo in quel giorno, 0 altrimenti.

  • prod : tipo di prodotto/piatto venduto (panino vegetariano, patatine, birra ecc). questa variabile può essere convertita in dummy, ma è sporca e richiede del preprocessing, in quanto in anni diversi panini simili hanno avuto nomi simili. Ad esempio, si può decidere di accorpare in un’unica classe le categorie hamburger, double burger e hamburger suino, anche se rappresentano prodotti diversi.

  • n_day_sells : somma di tutte le quantità vendute di tutti i piatti nel giorno e nell’anno considerato.

  • n_day_clients : numero complessivo di clienti che hanno acquistato qualsiasi cosa in quel giorno in paninoteca. Questo dato era mancante per l’anno 2022, e per quell’anno è stato imputato con una regressione lineare basata sulle variabili weekday e n_day_sells (la quale si può considerare la migliore proxy)

prodotti venduti

I prodotti venduti, e il numero di osservazioni della quantità venduta presenti per ciascuno, sono:

Code
df$prod[df$prod=='piadine nutella'] = 'piadina_nutella'
table(df$prod)

              acqua      anelli_cipolla             anguria              bibite 
                  6                  12                   1                   9 
              birra             churros     cotto_e_fontina       double_burger 
                 17                   1                  20                   6 
    hamburger_manzo     hamburger_suino             hot_dog              melone 
                  7                   5                  20                   1 
              mozza             nuggets pancetta_e_scamorza          panzerotti 
                 12                   5                   6                  22 
           patatine     piadina_nutella           salamella               speck 
                 28                  20                  20                   8 
       speck_e_brie      speck_e_tomino              spritz   spritz_analcolico 
                 11                  11                   6                   1 
        vegetariano   verdure_grigliate       virgin_mojito  virgin_pina_colada 
                 20                  11                   1                   1 

Si aggiunge qui una sovra-classe, che dice che tipo di cibo si sta vendendo. Soggettivamente, ritengo che queste aggregazioni siano le più sensate: panini, contorni, bibite e dolci.

Code
library(forcats)
panini = c( "salamella",  "cotto_e_fontina",  "vegetariano"     ,   
  "speck" ,  "hot_dog" , "speck_e_tomino"  ,    "speck_e_brie"       ,
"pancetta_e_scamorza", "hamburger_suino"  , "hamburger_manzo" , "double_burger" )

contorni = c("patatine", "nuggets", "anelli_cipolla", "panzerotti", "mozza", "verdure_grigliate")

bibite = c("birra" , "acqua", "spritz", "virgin_mojito" ,"virgin_pina_colada" , "spritz_analcolico",   'bibite')

dolci = c("anguria" ,"melone" , "churros", "piadina_nutella")

classi_cibo = ifelse(df$prod %in% panini, 'panini', 
              ifelse(df$prod %in% contorni, 'contorni',
              ifelse(df$prod %in% bibite, 'bibite', 
                     'dolci')))
              
df$food_type = classi_cibo

table(df$food_type)

  bibite contorni    dolci   panini 
      41       90       23      134 

giorni e anni

Analoga esplorazione possiamo fare per i giorni e per gli anni.

Code
table(df$weekday)

dom gio lun mar mer ven 
 18  56  32  54  56  72 
Code
table(df$year)

2021 2022 2023 2024 2025 
  44   44   76  108   16 

analisi dei prezzi

Code
summary(df$P)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  1.000   2.500   3.000   3.092   4.000   6.000 
Code
hist(df$P, main='prezzi fatti in Repax dal 2021 al 2025')

analisi delle quantità vendute

La variabile Q si presenta distribuita come Gamma o Poisson. Se la si vuole prevedere è conveniente modellizzarla come log-normale, ovvero trasformarla con logaritmo. Siccome in certi casi assume valore 0, si preferisce adottare la trasformazione log(x+1). In questo modo, è più facile che Q assuma una relazione lineare con le altre variabili, e quindi che un modello di previsione preveda bene log(Q+1).

Code
Q = df$Q
summary(Q)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
   0.00    2.75    7.00   15.24   15.25  141.00 
Code
par(mfrow=c(1,3))
hist(Q, main = 'distribuzione della quantità venduta', cex.main=0.9, breaks=50)
hist(log(1+Q), main = 'distribuzione della quantità venduta sotto logaritmo', cex.main=0.8, breaks=50)
boxplot(log(1+Q), main='boxplot di log(1+Q)')

analisi bivariata di P e Q

Spesso assumiamo che esista una relazione inversa tra prezzo e quantità domandata. In realtà molto spesso veniamo smentiti.

Code
plot(df$P, df$Q, xlab='prezzo', ylab='vendite in una sera di un prodotto', main='P vs Q')
#regressione lineare semplice
abline(lm(Q~P, data=df), col='red', lwd=2)
#polinomio di grado 5
mylm = lm(Q~poly(P,5), data=df)
lines(df$P[order(df$P)], fitted(mylm)[order(df$P)] , col='orange', lwd=2)
#spline di grado 1 con 5 nodi
mylm = lm(log(Q+1)~P + (P>1)*P + (P>2)*P + (P>3)*P + (P>4)*P + (P>5)*P, data=df)
lines(df$P[order(df$P)], fitted(mylm)[order(df$P)] , col='blue', lwd=2)

In questo caso l’associazione sembra terribilmente non lineare. Se si prova a fare la trasformazione con logaritmo:

Code
plot(df$P, log(df$Q+1), xlab='prezzo', ylab='log delle vendite in una sera di un prodotto', main='P vs log(Q+1)')
#regressione lineare semplice
abline(lm(log(Q+1)~P, data=df), col='red', lwd=2)
#polinomio di grado 5
mylm = lm(log(Q+1)~poly(P,5), data=df)
lines(df$P[order(df$P)], fitted(mylm)[order(df$P)] , col='orange', lwd=2)
#spline di grado 1 con 5 nodi
mylm = lm(log(Q+1)~P + (P>1)*P + (P>2)*P + (P>3)*P + (P>4)*P + (P>5)*P, data=df)
lines(df$P[order(df$P)], fitted(mylm)[order(df$P)] , col='blue', lwd=2)

La relazione sembra ancora non lineare, ma si nota che la relazione tende a essere negativa per prezzi maggiori di 4, assente o quasi per prezzi inferiori. Di sicuro non è possibile prevedere adeguatamente la quantità domandata basandosi esclusivamente sul prezzo.

analisi bivariata di prod e food_type con Q

Di seguito, l’analisi bivariata tra la variabile Q e le altre verrà fatta senza usare la trasformazione logaritmo, per essere più interpretabile. L’addestramento del modello per prevederla invece utilizzerà tale trasformazione.

analisi boxplot

Questo grafico è ciò che si vorrebbe comprendere in questo capitolo:

Code
library(ggplot2)

df %>% ggplot(aes(x=prod, y=Q, fill=prod)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta vs prodotti') + 
  theme_bw()

Non è facilissimo interpretarlo. Conviene spezzarlo nelle sue componenti.

Code
df  %>% ggplot(aes(x=food_type, y=Q, fill=food_type)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta vs tipi di prodotti') + 
  theme_bw()

conviene rivedere lo stesso grafico per log(Q+1), per stabilire qual’è la vera gerarchia tra classi con Q maggiore o minore.

Code
df %>% filter() %>% ggplot(aes(x=food_type, y=log(Q+1), fill=food_type)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta sotto log vs tipi di prodotti') + 
  theme_bw()

Come si distribuiscono queste quantità per ogni singola classe di prodotto?

Code
df %>% filter(food_type=='panini') %>% ggplot(aes(x=prod, y=Q, fill=prod)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta vs panini') + 
  theme_bw()

Code
df %>% filter(food_type=='contorni') %>% ggplot(aes(x=prod, y=Q, fill=prod)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta vs contorni') + 
  theme_bw()

Code
df %>% filter(food_type=='bibite') %>% ggplot(aes(x=prod, y=Q, fill=prod)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta vs bibite') + 
  theme_bw()

Si noti che le ultime 3 bibite sono state vendute una sera sola.

Code
df %>% filter(food_type=='dolci') %>% ggplot(aes(x=prod, y=Q, fill=prod)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta vs dolci') + 
  theme_bw()

Si noti che la classe bibite all’interno di bibite va letta come altre bibite. L’anguria è promettente in alcune stagioni. Non è ancora stata inserita nel dataset la variabile mese, che potrebbe spiegare che certi prodotti vanno meglio in certi periodi dell’anno.

analisi tabelle delle statistiche descrittive raggruppate

I boxplot sono esteticamente gradevoli ma a volte abbiamo bisogno di informazioni di dettaglio.

Ecco le statistiche descrittive per ogni prodotto della quantità giornaliera venduta:

Code
tab = df %>% group_by(prod) %>%
  summarise(min = min(Q), q25=round(quantile(Q, 0.25)) , median=round(median(Q)), 
            mean = round(mean(Q)), q75=round(quantile(Q, 0.75)), max=max(Q),  
            std_dev = round(sd(Q)), n = n()) %>%
  as.data.frame() 


print.data.frame(tab)
                  prod min q25 median mean q75 max std_dev  n
1                acqua   0   0      2    2   4   7       3  6
2       anelli_cipolla   0   1      4    5   8  13       5 12
3              anguria  14  14     14   14  14  14      NA  1
4               bibite   0   0      2    4   2  21       7  9
5                birra   0  14     29   29  43  95      23 17
6              churros  11  11     11   11  11  11      NA  1
7      cotto_e_fontina   2   5      7    7   8  15       3 20
8        double_burger   0   2      5    6   9  14       5  6
9      hamburger_manzo   4   6      7   14  18  39      12  7
10     hamburger_suino   1   3     17   11  17  19       9  5
11             hot_dog   0   6      9   12  14  38      10 20
12              melone   3   3      3    3   3   3      NA  1
13               mozza   1   4      6    8   9  31       8 12
14             nuggets   0   2      4    7  12  16       7  5
15 pancetta_e_scamorza   0   1      2    3   6   7       3  6
16          panzerotti   0   2      8    9  15  34       8 22
17            patatine   6  18     38   49  67 141      38 28
18     piadina_nutella   2   5      9    8  11  17       5 20
19           salamella   9  25     45   46  54 125      29 20
20               speck   2   7     12   16  22  37      13  8
21        speck_e_brie   0   2      3    3   3   9       2 11
22      speck_e_tomino   1   2      5    7  10  17       6 11
23              spritz   0   0      4    7   9  27      11  6
24   spritz_analcolico   3   3      3    3   3   3      NA  1
25         vegetariano   0   2      3    4   4  13       3 20
26   verdure_grigliate   0   0      0    1   2   4       1 11
27       virgin_mojito  13  13     13   13  13  13      NA  1
28  virgin_pina_colada   9   9      9    9   9   9      NA  1

Ecco il dataset aggregato sulle quantità vendute per tipo di prodotto

Code
tab = df %>% aggregate(Q~food_type+weekday+year, FUN=sum)

tab
   food_type weekday year   Q
1     bibite     gio 2021  14
2   contorni     gio 2021  77
3      dolci     gio 2021  13
4     panini     gio 2021  83
5     bibite     mar 2021  12
6   contorni     mar 2021  55
7      dolci     mar 2021  10
8     panini     mar 2021  54
9     bibite     mer 2021   0
10  contorni     mer 2021  60
11     dolci     mer 2021   2
12    panini     mer 2021  54
13    bibite     ven 2021  37
14  contorni     ven 2021 141
15     dolci     ven 2021   4
16    panini     ven 2021 131
17    bibite     gio 2022  29
18  contorni     gio 2022  93
19     dolci     gio 2022  10
20    panini     gio 2022  93
21    bibite     mar 2022  30
22  contorni     mar 2022  64
23     dolci     mar 2022   5
24    panini     mar 2022 114
25    bibite     mer 2022  43
26  contorni     mer 2022 114
27     dolci     mer 2022   9
28    panini     mer 2022 131
29    bibite     ven 2022  95
30  contorni     ven 2022 224
31     dolci     ven 2022  15
32    panini     ven 2022 220
33    bibite     gio 2023  45
34  contorni     gio 2023  98
35     dolci     gio 2023   9
36    panini     gio 2023 113
37  contorni     lun 2023  43
38     dolci     lun 2023  11
39    panini     lun 2023  38
40  contorni     mar 2023  18
41     dolci     mar 2023   3
42    panini     mar 2023  28
43    bibite     mer 2023  45
44  contorni     mer 2023 129
45     dolci     mer 2023  15
46    panini     mer 2023 113
47    bibite     ven 2023  49
48  contorni     ven 2023 104
49     dolci     ven 2023   9
50    panini     ven 2023 101
51    bibite     dom 2024  76
52  contorni     dom 2024 137
53     dolci     dom 2024  11
54    panini     dom 2024 136
55    bibite     gio 2024  19
56  contorni     gio 2024  88
57     dolci     gio 2024   8
58    panini     gio 2024  83
59    bibite     lun 2024   7
60  contorni     lun 2024  28
61     dolci     lun 2024   2
62    panini     lun 2024  31
63    bibite     mar 2024  17
64  contorni     mar 2024  44
65     dolci     mar 2024   5
66    panini     mar 2024  49
67    bibite     mer 2024  15
68  contorni     mer 2024  57
69     dolci     mer 2024   5
70    panini     mer 2024  52
71    bibite     ven 2024  44
72  contorni     ven 2024 183
73     dolci     ven 2024  17
74    panini     ven 2024 167
75    bibite     ven 2025  25
76  contorni     ven 2025  20
77     dolci     ven 2025  32
78    panini     ven 2025  24

Ecco le statistiche descrittive per ogni tipo di prodotto della quantità giornaliera venduta:

Code
tab = tab %>% group_by(food_type) %>%
  summarise(min = min(Q), q25=round(quantile(Q, 0.25)) , median=round(median(Q)), 
            mean = round(mean(Q)), q75=round(quantile(Q, 0.75)), max=max(Q),  
            std_dev = round(sd(Q)), n = n()) %>%
  as.data.frame() 


print.data.frame(tab)
  food_type min q25 median mean q75 max std_dev  n
1    bibite   0  16     30   33  45  95      24 18
2  contorni  18  52     82   89 118 224      54 20
3     dolci   2   5      9   10  12  32       7 20
4    panini  24  51     88   91 118 220      51 20

analisi bivariata di weekday con Q

Ripeto le stesse analisi fatte prima, ma sui giorni della settimana.

Si ricorda che la variabile n_day_sells è semplicemente Q aggregato come somma rispetto ai prodotti, ovvero è il numero totale di prodotti venduti in un anno e in una sera, di qualsiasi tipo. Inoltre la variabile n_day_clients è molto correlata con n_day_sells.

Code
df %>% mutate(weekday = factor(weekday, levels=c('lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom')))%>% 
  ggplot(aes(x=weekday, y=n_day_sells, fill=weekday)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta totale vs giorni della settimana') + 
  theme_bw()

Si vede che se il numero complessivo di ordini in certi giorni della settimana è ben confinato in un intervallo.

Code
df %>% mutate(weekday = factor(weekday, levels=c('lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom')))%>% 
  ggplot(aes(x=weekday, y=n_day_clients, fill=weekday)) + 
  geom_boxplot() + 
  ggtitle('numero di clienti vs giorni della settimana') + 
  theme_bw()

nel dettaglio, la distribuzione del totale delle vendite:

Code
tab = df %>% mutate(weekday = factor(weekday, levels=c('lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom')))%>%
  group_by(weekday) %>%
  summarise(min = min(n_day_sells), q25=round(quantile(n_day_sells, 0.25)) , median=round(median(n_day_sells)), 
            mean = round(mean(n_day_sells)), q75=round(quantile(n_day_sells, 0.75)), max=max(n_day_sells),  
            std_dev = round(sd(n_day_sells)), n = n()) %>%
  as.data.frame() 


cat('summary of n_day_sells by weekday')
summary of n_day_sells by weekday
Code
cat('\n')
Code
cat('\n')
Code
print.data.frame(tab)
  weekday min q25 median mean q75 max std_dev  n
1     lun  68  68     68   78  92  92      12 32
2     mar  49  66    115  121 131 213      56 54
3     mer 116 129    129  209 302 302      89 56
4     gio 187 198    198  220 265 265      31 56
5     ven 101 263    313  316 411 554     149 72
6     dom 360 360    360  360 360 360       0 18

nel dettaglio, la distribuzione del numero di clienti:

Code
tab = df %>% mutate(weekday = factor(weekday, levels=c('lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom')))%>%
  group_by(weekday) %>%
  summarise(min = min(n_day_clients), q25=round(quantile(n_day_clients, 0.25)) , median=round(median(n_day_clients)), 
            mean = round(mean(n_day_clients)), q75=round(quantile(n_day_clients, 0.75)), max=max(n_day_clients),  
            std_dev = round(sd(n_day_clients)), n = n()) %>%
  as.data.frame() 


cat('summary of n_day_clients by weekday')
summary of n_day_clients by weekday
Code
cat('\n')
Code
cat('\n')
Code
print.data.frame(tab)
  weekday min q25 median mean q75 max std_dev  n
1     lun  18  18     18   34  54  54      18 32
2     mar  22  28     45   49  54  83      21 54
3     mer  36  36     46   81 132 132      44 56
4     gio  61  88     90   85  94  94      12 56
5     ven  57  92     98  126 178 213      57 72
6     dom 125 125    125  125 125 125       0 18

relazione tra numero dei clienti e totale delle vendite

Si vede che n_day_clients e n_day_sells sono molto correlati. Ma qual’è il numero di prodotti pro capite acquistato dai clienti? Posso vedere il loro rapporto come un indice giornaliero, e quindi studiarne la distribuzione

Code
df = df %>% mutate(sells_clients_ratio = n_day_sells/n_day_clients)


summary(df$sells_clients_ratio)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  1.704   2.288   2.557   2.634   2.880   3.778 
Code
hist(df$sells_clients_ratio, main='numero di piatti pro capite (n_day_sells/n_day_clients)', breaks=20)

Si può quindi dire che in generale un cliente mangia tra i 2 e i 4 prodotti in una sera. La media è 2.6, per cui se si conosce il numero di clienti si sa che più o meno si avranno 2.6 volte lo stesso numerod prodotti acquistati.

Questo è il loro rapporto, ma forse una relazione lineare, con un termine di intercetta, potrebbe descrivere meglio questa relazione. Utilizzo allora due regressioni lineari per prevedere n_day_sells e n_day_clients, e dire se questo rapporto è affidabile o meno (è affidabile se il modello senza intercetta funziona meglio di quello con).

Code
reg_ratio = lm(n_day_sells ~ n_day_clients + 0, data=df)
summary(reg_ratio)

Call:
lm(formula = n_day_sells ~ n_day_clients + 0, data = df)

Residuals:
    Min      1Q  Median      3Q     Max 
-45.145 -30.575   1.505  26.266  79.346 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
n_day_clients   2.5397     0.0192   132.3   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 31.74 on 287 degrees of freedom
Multiple R-squared:  0.9839,    Adjusted R-squared:  0.9838 
F-statistic: 1.749e+04 on 1 and 287 DF,  p-value: < 2.2e-16
Code
reg_intercept = lm(n_day_sells ~ n_day_clients, data=df)
summary(reg_intercept)

Call:
lm(formula = n_day_sells ~ n_day_clients, data = df)

Residuals:
    Min      1Q  Median      3Q     Max 
-49.759 -31.700  -0.426  24.810  77.731 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
(Intercept)    8.87741    3.70929   2.393   0.0173 *  
n_day_clients  2.46078    0.03809  64.605   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 31.48 on 286 degrees of freedom
Multiple R-squared:  0.9359,    Adjusted R-squared:  0.9356 
F-statistic:  4174 on 1 and 286 DF,  p-value: < 2.2e-16

Si vede che il primo modello ha un R^2 di 0.98, mentre il secondo di 0.93, per cui è meglio il primo, ovvero è meglio assumere che ci sia un rapporto costante tra il numero di clienti e il numero di ordini, piuttosto che usare un modello lineare classico con l’intercetta. Si nota che la miglior stima del loro rapporto, dato il numero di clienti, è 2.53, e non 2.63

Code
plot(n_day_sells ~ n_day_clients, data=df, main='numero di clienti e numero di acquisti')
abline(reg_ratio, col='red', lwd=1.7)
abline(reg_intercept, col='blue', lwd=1.7)
legend('topleft', legend=c('modello senza intercetta', 'modello con intercetta'), col=c('red', 'blue'), lwd=1)

In effetti, se non ci sono clienti, la quantità venduta totale dovrebbe essere 0, per cui l’intercetta della retta dovrebbe essere 0.

relazione tra year e Q

In anni diversi vorrei che la quantità domandata non cambiasse. Questo perché gli anni futuri non mi sono noti, e di conseguenza se sono utili a prevedere il volume delle vendite fare tale previsione sarebbe più difficile.

Code
df %>% mutate(year = as.factor(year)) %>% ggplot(aes(x=year, y=Q, fill=year)) + 
  geom_boxplot() + 
  ggtitle('quantità venduta vs anni') + 
  theme_bw()

si sono avute più vendite nel 2021 o nel 2024? Per rispondere di nuovo conviene usare log(Q+1) per visualizzare meglio.

Code
df %>% mutate(year = as.factor(year)) %>% ggplot(aes(x=year, y=log(Q+1), fill=year)) + 
  geom_boxplot() + 
  ggtitle('log quantità venduta vs anni') + 
  theme_bw()

modelli per prevedere di Q: stime dirette

Ora che sappiamo interpretare la relazione tra ogni singola variabile e la variabile Q, possiamo costruire un modello/oracolo che tenga insieme tutte le informazioni e fornisca la previsione più accurata possibile. La variabile da prevedere probabilmente non dovrà essere Q, ma log(Q+1), per rendere lineare la relazione tra Q e le altre variabili (le previsioni dovranno quindi essere trasformate con exp(x)-1).

Le variabili di input sicuramente da includere saranno prod, food_type (che è una generalizzazione di prod), price e weekday. La variabile year non presenta un andamento di trend lineare nel tempo, ma potrebbe servire ad aiutare l’apprendimento l’inclusione di una dummy per l’anno 2021 (in cui le vendite erano ridotte a causa del covid). La variabile n_day_clients non è effettivamente nota, per cui non andrebbe inclusa. Si potrebbe essere tentati di stimarla attraverso weekday, ma poi se si includesse anche weekday nel modello si avrebbero due variabili perfettamente correlate. Un approccio alternativo consente di includere queste variabili, e viene illustrato nel prossimo capitolo.

comparazione tra i modelli

Per poter effettivamente mettere a confronto diversi modelli, bisogna valutare le loro performance non sui dati su cui sono stati addestrati, ma su un dataset che non gli è mai stato mostrato. Qesto ha un costo in termini di dati di training, ma consente di fornire delle stime dell’errore che non siano condizionate da overfitting (fenomeno per cui un modello prevede molto bene i dati su cui è stato addestrato ma poi non è in grado di fare previsioni adeguate su nuovi dati).

I dati nel test verranno estratti casualmente da tutte le osservazioni disponibili, nella misura del 10% (per cui si avranno circa 30 osservazioni nel test e circa 260 osservazioni nel train).

La metrica di errore è il MAE (mean absolute error).

Code
N = nrow(df)

df = df %>% mutate(prod=as.factor(prod), food_type=as.factor(food_type), weekday=as.factor(weekday))

df$prod <- factor(df$prod, levels = unique(df$prod))

train_prop = 0.9
train_n = round(N*train_prop)

set.seed(42)
id_train = sample(1:N, train_n)

## per modelli semplici
train_set = df[id_train,]
test_set = df[-id_train,]

test_set$prod <- factor(test_set$prod, levels = levels(train_set$prod))


## per modelli non semplici
library(xgboost)
library(Matrix)

X_sparse = df %>% dplyr::select(c(Q,P, weekday, prod))
X_sparse = sparse.model.matrix(Q~., data=X_sparse)

train_x = X_sparse[id_train,]
test_x = X_sparse[-id_train,]
train_y = df$Q[id_train]
test_y = df$Q[-id_train]



MAE = function(pred, actual){mean(abs(pred-actual))}

cat('train obs:', nrow(train_set), '\n')
train obs: 259 
Code
cat('test obs:', nrow(test_set))
test obs: 29

modello 1: regressione lineare OLS su Q

Questo modello è sbagliato, perché non usa log(Q+1). Lo faccio comunque per mostrare che performa meglio quando utilizziamo tale trasformazione

Code
ols1 = lm(Q ~ weekday + prod + food_type + P, data=train_set)

summary(ols1)

Call:
lm(formula = Q ~ weekday + prod + food_type + P, data = train_set)

Residuals:
    Min      1Q  Median      3Q     Max 
-43.956  -6.184   0.000   4.627  85.166 

Coefficients: (3 not defined because of singularities)
                        Estimate Std. Error t value Pr(>|t|)    
(Intercept)               72.063     12.538   5.748 2.93e-08 ***
weekdaygio                -6.291      4.952  -1.270 0.205329    
weekdaylun               -12.481      5.301  -2.354 0.019414 *  
weekdaymar               -12.227      4.941  -2.474 0.014082 *  
weekdaymer                -6.020      4.942  -1.218 0.224447    
weekdayven                 4.376      4.819   0.908 0.364868    
prodcotto_e_fontina      -41.329      5.371  -7.695 4.40e-13 ***
prodvegetariano          -44.435      5.436  -8.174 2.17e-14 ***
prodspeck                -33.938      7.044  -4.818 2.67e-06 ***
prodhot_dog              -36.396      5.275  -6.900 5.22e-11 ***
prodpatatine              -7.560      7.241  -1.044 0.297625    
prodbirra                -22.324      5.920  -3.771 0.000208 ***
prodmozza                -47.750      8.291  -5.759 2.76e-08 ***
prodpiadina_nutella      -45.478      6.658  -6.831 7.79e-11 ***
prodpanzerotti           -41.926      6.082  -6.894 5.42e-11 ***
prodspeck_e_tomino       -40.557      6.754  -6.005 7.61e-09 ***
prodspeck_e_brie         -44.288      6.282  -7.050 2.18e-11 ***
prodpancetta_e_scamorza  -42.619      9.970  -4.275 2.83e-05 ***
prodhamburger_suino      -36.757     10.011  -3.672 0.000301 ***
prodbibite               -59.607      9.986  -5.969 9.20e-09 ***
prodanelli_cipolla       -48.718      7.385  -6.597 2.97e-10 ***
prodnuggets              -45.688      9.019  -5.066 8.48e-07 ***
prodverdure_grigliate    -52.737      7.520  -7.013 2.70e-11 ***
prodhamburger_manzo      -27.933      7.301  -3.826 0.000169 ***
proddouble_burger        -29.682      9.226  -3.217 0.001485 ** 
prodacqua                -58.904     11.077  -5.318 2.53e-07 ***
prodspritz               -43.634      8.024  -5.438 1.40e-07 ***
prodanguria              -57.221     18.233  -3.138 0.001926 ** 
prodmelone               -68.221     18.233  -3.742 0.000232 ***
prodchurros              -52.393     16.849  -3.110 0.002116 ** 
prodvirgin_mojito        -50.393     16.849  -2.991 0.003092 ** 
prodvirgin_pina_colada   -54.393     16.849  -3.228 0.001431 ** 
prodspritz_analcolico    -60.393     16.849  -3.584 0.000414 ***
food_typecontorni             NA         NA      NA       NA    
food_typedolci                NA         NA      NA       NA    
food_typepanini               NA         NA      NA       NA    
P                         -5.218      2.581  -2.022 0.044364 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 15.78 on 225 degrees of freedom
Multiple R-squared:  0.5678,    Adjusted R-squared:  0.5044 
F-statistic: 8.958 on 33 and 225 DF,  p-value: < 2.2e-16

Si noti che non è possibile usare food_type e prod insieme, in quanto ogni dummy di food_type è in realtà una combinazione lineare delle dummy di prod. R^2 sul train è 0.51, e forse si può fare di meglio Ripeto usando food_type al posto di prod

Code
ols2 = lm(Q ~ weekday + food_type + P, data=train_set)

summary(ols2)

Call:
lm(formula = Q ~ weekday + food_type + P, data = train_set)

Residuals:
    Min      1Q  Median      3Q     Max 
-28.185 -12.257  -5.874   3.029 111.815 

Coefficients:
                   Estimate Std. Error t value Pr(>|t|)
(Intercept)        10.78967    8.66129   1.246    0.214
weekdaygio         -0.07944    6.65385  -0.012    0.990
weekdaylun        -10.23195    7.20131  -1.421    0.157
weekdaymar         -5.53357    6.64746  -0.832    0.406
weekdaymer          1.32823    6.61788   0.201    0.841
weekdayven          9.76991    6.44914   1.515    0.131
food_typecontorni   6.88873    4.30251   1.601    0.111
food_typedolci     -5.91190    5.84679  -1.011    0.313
food_typepanini     0.18560    4.97370   0.037    0.970
P                   0.69486    1.96179   0.354    0.723

Residual standard error: 21.62 on 249 degrees of freedom
Multiple R-squared:  0.102, Adjusted R-squared:  0.06954 
F-statistic: 3.142 on 9 and 249 DF,  p-value: 0.001316

Si vede che prod contiene delle dummy molto utili, e che quando viene rimosso il modello peggiora. Quindi food_type è una variabile utile ai fini dell’interpretazione, ma non ai fini della previsione.

Gli errori sono i seguenti:

Code
cat('mae of ols1 on train:'  ,MAE(predict(ols1, train_set), train_set$Q), '\n')
mae of ols1 on train: 8.842142 
Code
cat('mae of ols1 on test:'  ,MAE(predict(ols1, test_set), test_set$Q), '\n')
mae of ols1 on test: 10.25349 
Code
cat('mae of ols2 on train:'  ,MAE(predict(ols2, train_set), train_set$Q), '\n')
mae of ols2 on train: 13.94395 
Code
cat('mae of ols2 on test:'  ,MAE(predict(ols2, test_set), test_set$Q), '\n')
mae of ols2 on test: 14.45603 

Si vedono 2 cose importanti: 1- il modello che usa prod funziona meglio del modello che usa food_type 2- il rischio di overfitting è presente, anche per un modello molto semplice come ols

modello 2: regressione lineare OLS su log(Q+1) (regressione poisson-gamma)

Includo ora la trasfomazione logaritmica.

Code
ols3 = lm(log(Q+1) ~ weekday + prod + P, data=train_set)

summary(ols3)

Call:
lm(formula = log(Q + 1) ~ weekday + prod + P, data = train_set)

Residuals:
     Min       1Q   Median       3Q      Max 
-3.07048 -0.38108  0.03888  0.49070  1.66785 

Coefficients:
                        Estimate Std. Error t value Pr(>|t|)    
(Intercept)               5.4220     0.5759   9.414  < 2e-16 ***
weekdaygio               -0.5070     0.2275  -2.229 0.026817 *  
weekdaylun               -1.2499     0.2435  -5.133 6.17e-07 ***
weekdaymar               -1.1163     0.2270  -4.918 1.69e-06 ***
weekdaymer               -0.6353     0.2270  -2.798 0.005581 ** 
weekdayven               -0.1732     0.2214  -0.782 0.434851    
prodcotto_e_fontina      -1.7992     0.2467  -7.293 5.11e-12 ***
prodvegetariano          -2.3606     0.2497  -9.453  < 2e-16 ***
prodspeck                -1.3426     0.3236  -4.149 4.73e-05 ***
prodhot_dog              -1.5169     0.2423  -6.261 1.92e-09 ***
prodpatatine             -0.6054     0.3326  -1.820 0.070075 .  
prodbirra                -1.0066     0.2719  -3.702 0.000269 ***
prodmozza                -2.3010     0.3809  -6.042 6.26e-09 ***
prodpiadina_nutella      -1.9736     0.3058  -6.453 6.63e-10 ***
prodpanzerotti           -1.9268     0.2794  -6.897 5.31e-11 ***
prodspeck_e_tomino       -1.9034     0.3102  -6.135 3.79e-09 ***
prodspeck_e_brie         -2.5492     0.2886  -8.834 2.93e-16 ***
prodpancetta_e_scamorza  -2.5205     0.4580  -5.504 1.01e-07 ***
prodhamburger_suino      -1.5494     0.4599  -3.369 0.000887 ***
prodbibite               -3.8546     0.4587  -8.403 4.94e-15 ***
prodanelli_cipolla       -2.7324     0.3392  -8.055 4.63e-14 ***
prodnuggets              -2.3548     0.4143  -5.684 4.06e-08 ***
prodverdure_grigliate    -3.6223     0.3454 -10.487  < 2e-16 ***
prodhamburger_manzo      -0.9921     0.3354  -2.958 0.003424 ** 
proddouble_burger        -1.6024     0.4238  -3.781 0.000200 ***
prodacqua                -3.5435     0.5088  -6.964 3.59e-11 ***
prodspritz               -2.6553     0.3686  -7.205 8.69e-12 ***
prodanguria              -2.2569     0.8375  -2.695 0.007575 ** 
prodmelone               -3.5787     0.8375  -4.273 2.85e-05 ***
prodchurros              -2.0543     0.7740  -2.654 0.008514 ** 
prodvirgin_mojito        -1.9002     0.7740  -2.455 0.014841 *  
prodvirgin_pina_colada   -2.2366     0.7740  -2.890 0.004231 ** 
prodspritz_analcolico    -3.1529     0.7740  -4.074 6.41e-05 ***
P                        -0.2838     0.1186  -2.394 0.017483 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 0.7249 on 225 degrees of freedom
Multiple R-squared:  0.6715,    Adjusted R-squared:  0.6234 
F-statistic: 13.94 on 33 and 225 DF,  p-value: < 2.2e-16

Si vede che R^2 è salito di 10 punti percentuali, e che alcune variabili, prima considerate trascurabili, ora sono significative. Le previsioni ora vanno riconvertite.

Code
cat('mae of ols3 on train:'  ,MAE(exp(predict(ols3, train_set))-1, train_set$Q), '\n')
mae of ols3 on train: 7.373755 
Code
cat('mae of ols3 on test:'  ,MAE(exp(predict(ols3, test_set))-1, test_set$Q), '\n')
mae of ols3 on test: 8.696639 

Sia sul train sia sul test l’errore è ridotto.

modello 3: regressione Lasso su log(Q+1)

Il modello Lasso è un modello di regressione lineare penalizzata. In alcuni casi performa meglio di ols.

Code
library(glmnet)

lasso = glmnet(train_x, log(train_y+1), alpha=0, lambda=0.01)

cat('mae of lasso on train:'  ,MAE(exp(predict(lasso, train_x))-1, train_set$Q), '\n')
mae of lasso on train: 7.567575 
Code
cat('mae of lasso on test:'  ,MAE(exp(predict(lasso, test_x))-1, test_set$Q), '\n')
mae of lasso on test: 8.79128 

In questo dataset la penalizzazione non aiuta

modello 4: knn su log(Q+1)

Questo modello prevede la variabile risposta semplicemente facendo la media delle k osservazioni più simili.

Code
library(caret)

myknn <- train(
  log(Q+1) ~ weekday + prod + P,data = train_set,
  method = 'knn' ,tuneGrid=data.frame(k=10))

cat('mae of knn on train:'  ,MAE(exp(predict(myknn, train_set))-1, train_set$Q), '\n')
mae of knn on train: 10.36519 
Code
cat('mae of knn on test:'  ,MAE(exp(predict(myknn, test_set))-1, test_set$Q), '\n')
mae of knn on test: 10.38283 

Davvero pessimo. Senza prod:

Code
myknn <- train(
  log(Q+1) ~ weekday +  + P,data = train_set,
  method = 'knn' ,tuneGrid=data.frame(k=10))

cat('mae of knn on train:'  ,MAE(exp(predict(myknn, train_set))-1, train_set$Q), '\n')
mae of knn on train: 11.90822 
Code
cat('mae of knn on test:'  ,MAE(exp(predict(myknn, test_set))-1, test_set$Q), '\n')
mae of knn on test: 11.67995 

modello 5: albero di decisione su log(Q+1)

Code
library(rpart)
library(rpart.plot)

rtree = rpart(log(Q+1) ~ weekday + prod + P, 
              data=train_set, 
              control=rpart.control(maxdepth = 3))

rpart.plot(rtree)

Code
cat('mae of tree on train:'  ,MAE(exp(predict(rtree, train_set))-1, train_set$Q), '\n')
mae of tree on train: 8.205877 
Code
cat('mae of tree on test:'  ,MAE(exp(predict(rtree, test_set))-1, test_set$Q), '\n')
mae of tree on test: 8.190583 

Si vede che l’albero di decisione funziona peggio di ols sul train, ma va meglio sul test e generalizza abbastanza bene l’errore. A differenza dei modelli lineari, i modelli tree based possono migliorare particolarmente quando utilizzati con tecniche di ensemble learning, come bagging e boosting.

Senza prod:

Code
rtree = rpart(log(Q+1) ~ weekday + P, 
              data=train_set, 
              control=rpart.control(maxdepth = 3))

cat('mae of tree on train:'  ,MAE(exp(predict(rtree, train_set))-1, train_set$Q), '\n')
mae of tree on train: 11.99486 
Code
cat('mae of tree on test:'  ,MAE(exp(predict(rtree, test_set))-1, test_set$Q), '\n')
mae of tree on test: 11.16833 

modello 6: random forest su log(Q+1)

Il modello random forest è un’applicazione del bagging agli alberi, ovvero se ne addestrano tanti, ciascuno su una parte dei dati di train, e poi si prevede la media delle loro previsioni.

Code
library(randomForest)

rf = randomForest(log(Q+1) ~ weekday + prod + P, 
                  data=train_set, ntree=500, mtry=3, replace=F, sampsize=255)

importance(rf)
        IncNodePurity
weekday      47.46497
prod        220.92408
P            24.02284
Code
cat('mae of rf on train:'  ,MAE(exp(predict(rf, train_set))-1, train_set$Q), '\n')
mae of rf on train: 5.345208 
Code
cat('mae of rf on test:'  ,MAE(exp(predict(rf, test_set))-1, test_set$Q), '\n')
mae of rf on test: 6.872888 

Si vede che il modello va troppo in overfitting per molti iperparametri, ma per particolari valori di sampsize alti (più del 90% delle osservazioni di train), riesce a migliorare molto sul test rispetto al singolo albero.

Senza prod:

Code
rf = randomForest(log(Q+1) ~ weekday + P, 
                  data=train_set, ntree=500, mtry=2, replace=F, sampsize=255)

importance(rf)
        IncNodePurity
weekday      45.76145
P            25.34505
Code
cat('\n')
Code
cat('mae of rf on train:'  ,MAE(exp(predict(rf, train_set))-1, train_set$Q), '\n')
mae of rf on train: 11.55263 
Code
cat('mae of rf on test:'  ,MAE(exp(predict(rf, test_set))-1, test_set$Q), '\n')
mae of rf on test: 13.27381 

Con prod e con n_day_clients:

Code
rf = randomForest(log(Q+1) ~ weekday + prod + P + n_day_clients, 
                  data=train_set, ntree=500, mtry=3, replace=F, sampsize=250)

importance(rf)
              IncNodePurity
weekday            13.95808
prod              209.86326
P                  15.44490
n_day_clients      77.36745
Code
cat('\n')
Code
cat('mae of rf on train:'  ,MAE(exp(predict(rf, train_set))-1, train_set$Q), '\n')
mae of rf on train: 3.486898 
Code
cat('mae of rf on test:'  ,MAE(exp(predict(rf, test_set))-1, test_set$Q), '\n')
mae of rf on test: 9.722372 

Anche con il modello migliore, l’inclusione di n_day_clients non sembra aiutare.

modello 7: xgboost su log(Q+1)

Code
library(xgboost)


xgb = xgboost(data=train_x, label=log(train_y+1), verbose=0,
    nrounds=50, params=list(booster='gbtree', eta=0.1, max_depth=3))

cat('mae of xgb on train:'  ,MAE(exp(predict(xgb, train_x))-1, train_set$Q), '\n')
mae of xgb on train: 9.025689 
Code
cat('mae of xgb on test:'  ,MAE(exp(predict(xgb, test_x))-1, test_set$Q), '\n')
mae of xgb on test: 9.300064 

Di solito il boosting funziona meglio di qualsiasi altra cosa, ma non è questo il caso.

modelli migliori

Alla fine i modelli idonei a fare stime su Q sembrano essere, in ordine di funzionalità:

  • il random forest su log(Q+1)

  • un albero di regressione su log(Q+1)

  • la regressione poisson (lineare su log(Q+1))

modelli per prevedere Q: stime indirette

La variabile n_day_clients non è effettivamente nota, per cui non andrebbe inclusa. Si potrebbe essere tentati di stimarla attraverso weekday, ma poi se si includesse anche weekday nel modello si avrebbero due variabili perfettamente correlate. Si è visto inoltre che il numero totale delle vendite è molto correlato con la quantità di prodotti complessivamente venduta (praticamente lo stesso valore moltiplicato per 2.53).

Perciò, un’idea alternativa rispetto a modelli che prevedono direttamente Q, potrebbe essere la seguente: - 1 stimare n_day_clients semplicemente attraverso la sua media storica giornaliera, e quindi attraverso weekday. - 2 stimare n_day_sells come la previsione di n_day_clients * 2.53. Si avrà quindi una stima costante per ogni giorno della settimana - 3 stimare per ogni prodotto non Q, bensì Q/n_day_sells, dove al posto di n_day_sells si pone 2.53*n_day_clients

Al punto 3, per fare in modo che le previsioni risultino percentuali comprese tra 0 e 1, bisognerà adottare una trasformazione logit al rapporto, prevederla con il modello, e poi ritrasformare le previsioni con logit inversa o sigmoide

Questo approccio è decisamente più articolato del precedente, ma forse risulterà più efficace. Alla fine stiamo facendo una specie di regressione binomiale, e una binomiale è molto vicina a una poisson.

Per le analisi, si tengono gli stessi dati di test e di train della sezione precedente.

step 1: stima del numero di clienti con weekday

Volendo prendere una media per giorno, bisogna di nuovo fare un modello senza intercetta. Non avendo osservazioni sul sabato questo comporta che non si potrà usare questo modello per prevedere quel giorno.

Con intercetta

Code
ols_n_clients = lm(n_day_clients~weekday, data=train_set)
summary(ols_n_clients)

Call:
lm(formula = n_day_clients ~ weekday, data = train_set)

Residuals:
   Min     1Q Median     3Q    Max 
-71.39 -28.49   0.00  22.15  84.61 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)  125.000     10.036  12.456  < 2e-16 ***
weekdaygio   -40.740     11.354  -3.588 0.000400 ***
weekdaylun   -93.154     12.448  -7.484 1.20e-12 ***
weekdaymar   -76.400     11.354  -6.729 1.14e-10 ***
weekdaymer   -44.327     11.306  -3.921 0.000114 ***
weekdayven     3.388     11.034   0.307 0.759063    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 37.55 on 253 degrees of freedom
Multiple R-squared:  0.4478,    Adjusted R-squared:  0.4368 
F-statistic: 41.03 on 5 and 253 DF,  p-value: < 2.2e-16

Senza intercetta

Code
ols_n_clients = lm(n_day_clients~weekday+0, data=train_set)
summary(ols_n_clients)

Call:
lm(formula = n_day_clients ~ weekday + 0, data = train_set)

Residuals:
   Min     1Q Median     3Q    Max 
-71.39 -28.49   0.00  22.15  84.61 

Coefficients:
           Estimate Std. Error t value Pr(>|t|)    
weekdaydom  125.000     10.036  12.456  < 2e-16 ***
weekdaygio   84.260      5.310  15.867  < 2e-16 ***
weekdaylun   31.846      7.364   4.324  2.2e-05 ***
weekdaymar   48.600      5.310   9.152  < 2e-16 ***
weekdaymer   80.673      5.207  15.492  < 2e-16 ***
weekdayven  128.388      4.587  27.987  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 37.55 on 253 degrees of freedom
Multiple R-squared:  0.8583,    Adjusted R-squared:  0.855 
F-statistic: 255.4 on 6 and 253 DF,  p-value: < 2.2e-16

L’indice R^2 è doppio quando si esclude l’intercetta.

step 2: stima del numero di ordini come numero di clienti * 2.53

Code
ols_n_sells = lm(n_day_sells ~ n_day_clients + 0, data=train_set)
summary(ols_n_sells)

Call:
lm(formula = n_day_sells ~ n_day_clients + 0, data = train_set)

Residuals:
    Min      1Q  Median      3Q     Max 
-44.646 -29.744   2.317  22.451  80.195 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
n_day_clients  2.53048    0.01956   129.4   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 31.04 on 258 degrees of freedom
Multiple R-squared:  0.9848,    Adjusted R-squared:  0.9848 
F-statistic: 1.674e+04 on 1 and 258 DF,  p-value: < 2.2e-16

step 3: stima dei valori percentuali dei prodotti

Utilizzo dato r = Q/totQ, si vede che quando Q=0 anche r = 0, e logit(r) va a infinito. Perciò introduco un termine logit(r+0.001) per non avere infinito.

Code
train_set = train_set %>% mutate(prod_sells_ratio = Q/n_day_sells)
test_set = test_set %>% mutate(prod_sells_ratio = Q/n_day_sells)

logit = function(x) {log(x/(1-x))}
sigmoid = function(x) {1/(1+exp(-x))}

g = 0.001

ols_ratio = lm(logit(prod_sells_ratio+g) ~ P + weekday 
               + prod, data=train_set)

tree_ratio = rpart(logit(prod_sells_ratio+g) ~ P + weekday + prod, 
              data=train_set, 
              control=rpart.control(maxdepth = 3))

rf_ratio = randomForest(logit(prod_sells_ratio+g) ~ P + weekday + prod, data=train_set,
                        ntree=500, mtry=3, replace=F, sampsize=255)



summary(ols_ratio)

Call:
lm(formula = logit(prod_sells_ratio + g) ~ P + weekday + prod, 
    data = train_set)

Residuals:
    Min      1Q  Median      3Q     Max 
-4.5841 -0.4098  0.0642  0.6273  2.6224 

Coefficients:
                        Estimate Std. Error t value Pr(>|t|)    
(Intercept)               0.3436     0.8040   0.427  0.66956    
P                        -0.3492     0.1655  -2.110  0.03598 *  
weekdaygio               -0.2041     0.3176  -0.643  0.52106    
weekdaylun               -0.3095     0.3399  -0.910  0.36359    
weekdaymar               -0.5648     0.3169  -1.783  0.07600 .  
weekdaymer               -0.2732     0.3169  -0.862  0.38964    
weekdayven               -0.1408     0.3090  -0.456  0.64913    
prodcotto_e_fontina      -2.0757     0.3444  -6.027 6.77e-09 ***
prodvegetariano          -2.8976     0.3486  -8.312 8.93e-15 ***
prodspeck                -1.8197     0.4517  -4.029 7.68e-05 ***
prodhot_dog              -1.9658     0.3382  -5.812 2.10e-08 ***
prodpatatine             -0.7613     0.4643  -1.640  0.10250    
prodbirra                -1.5202     0.3796  -4.005 8.43e-05 ***
prodmozza                -2.8872     0.5317  -5.430 1.45e-07 ***
prodpiadina_nutella      -2.3614     0.4269  -5.531 8.80e-08 ***
prodpanzerotti           -2.4327     0.3900  -6.238 2.18e-09 ***
prodspeck_e_tomino       -2.3033     0.4331  -5.318 2.52e-07 ***
prodspeck_e_brie         -3.3060     0.4028  -8.206 1.76e-14 ***
prodpancetta_e_scamorza  -2.9627     0.6393  -4.634 6.07e-06 ***
prodhamburger_suino      -2.1338     0.6420  -3.324  0.00104 ** 
prodbibite               -5.2043     0.6403  -8.127 2.91e-14 ***
prodanelli_cipolla       -3.5733     0.4736  -7.546 1.11e-12 ***
prodnuggets              -3.3047     0.5784  -5.714 3.48e-08 ***
prodverdure_grigliate    -5.2343     0.4822 -10.855  < 2e-16 ***
prodhamburger_manzo      -1.0165     0.4682  -2.171  0.03097 *  
proddouble_burger        -2.3462     0.5916  -3.966 9.84e-05 ***
prodacqua                -4.6703     0.7103  -6.575 3.35e-10 ***
prodspritz               -4.0688     0.5145  -7.908 1.17e-13 ***
prodanguria              -1.6721     1.1692  -1.430  0.15406    
prodmelone               -3.3058     1.1692  -2.827  0.00511 ** 
prodchurros              -1.4215     1.0805  -1.316  0.18963    
prodvirgin_mojito        -1.2334     1.0805  -1.142  0.25487    
prodvirgin_pina_colada   -1.6422     1.0805  -1.520  0.12994    
prodspritz_analcolico    -2.7821     1.0805  -2.575  0.01067 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 1.012 on 225 degrees of freedom
Multiple R-squared:  0.6245,    Adjusted R-squared:  0.5694 
F-statistic: 11.34 on 33 and 225 DF,  p-value: < 2.2e-16
Code
importance(rf_ratio)
        IncNodePurity
P            45.22177
weekday      44.10310
prod        412.26477

Si vede che tutti e tre i modelli utilizzano praticamente solo il tipo di prodotto, ed escludono le altre variabili.

step 4: metto tutto insieme e stimo gli errori dei modelli

con totale delle vendite noto

Code
## totale vendite preso dai dati
qtot_train = train_set$n_day_sells
qtot_test = test_set$n_day_sells

##prevedi percentuali di vendita
ratio_ols_train = sigmoid(predict(ols_ratio, train_set))-g
ratio_ols_test = sigmoid(predict(ols_ratio, test_set))-g
ratio_tree_train = sigmoid(predict(tree_ratio, train_set))-g
ratio_tree_test = sigmoid(predict(tree_ratio, test_set))-g
ratio_rf_train = sigmoid(predict(rf_ratio, train_set))-g
ratio_rf_test = sigmoid(predict(rf_ratio, test_set))-g

##stima errori

cat('ols error on train', MAE(ratio_ols_train*qtot_train, train_set$Q), '\n')
ols error on train 6.091627 
Code
cat('ols error on test', MAE(ratio_ols_test*qtot_test, test_set$Q), '\n')
ols error on test 8.461542 
Code
cat('tree error on train', MAE(ratio_tree_train*qtot_train, train_set$Q), '\n')
tree error on train 6.328254 
Code
cat('tree error on test', MAE(ratio_tree_test*qtot_test, test_set$Q), '\n')
tree error on test 8.206998 
Code
cat('rf error on train', MAE(ratio_rf_train*qtot_train, train_set$Q), '\n')
rf error on train 4.3609 
Code
cat('rf error on test', MAE(ratio_rf_test*qtot_test, test_set$Q), '\n')
rf error on test 6.391048 

Si vede il nuovo record del random forest sul test!

con totale delle vendite non noto

Code
##prevedi numero clienti
cli_train = predict(ols_n_clients, train_set)
cli_test = predict(ols_n_clients, test_set)

##prevedi totale vendite
qtot_train = predict(ols_n_sells, data.frame(n_day_clients=cli_train))
qtot_test = predict(ols_n_sells,  data.frame(n_day_clients=cli_test))


##prevedi percentuali di vendita
ratio_ols_train = sigmoid(predict(ols_ratio, train_set))-g
ratio_ols_test = sigmoid(predict(ols_ratio, test_set))-g
ratio_tree_train = sigmoid(predict(tree_ratio, train_set))-g
ratio_tree_test = sigmoid(predict(tree_ratio, test_set))-g
ratio_rf_train = sigmoid(predict(rf_ratio, train_set))-g
ratio_rf_test = sigmoid(predict(rf_ratio, test_set))-g

##stima errori

cat('ols error on train', MAE(ratio_ols_train*qtot_train, train_set$Q), '\n')
ols error on train 7.983078 
Code
cat('ols error on test', MAE(ratio_ols_test*qtot_test, test_set$Q), '\n')
ols error on test 9.219924 
Code
cat('tree error on train', MAE(ratio_tree_train*qtot_train, train_set$Q), '\n')
tree error on train 8.363345 
Code
cat('tree error on test', MAE(ratio_tree_test*qtot_test, test_set$Q), '\n')
tree error on test 8.923287 
Code
cat('rf error on train', MAE(ratio_rf_train*qtot_train, train_set$Q), '\n')
rf error on train 6.681963 
Code
cat('rf error on test', MAE(ratio_rf_test*qtot_test, test_set$Q), '\n')
rf error on test 7.127911 

L’errore cresce a causa della stima sbagliata dei totali.

scelta finale

Tra i metodi di previsione diretta e il metodo di previsione indiretta c’è una lieve differenza in termini di performance. Se vi fosse una perfetta conoscenza del numero di clienti presenti in una sera, probabilmente il metodo indiretto mostrerebbe performance previsive superiori rispetto al metodo diretto. I metodi di previsione indiretta sono costruiti sulla base della conoscenza intrinseca del fenomeno, e questo li rende molto eleganti, e consentono di scomporre il problema di stima della quantità venduta in 3 diversi problemi di stima: 1 - stima del numero di clienti 2 - stima del numero di prodotti pro capite 3 - stima della percentuale di vendita di un prodotto

Alla fine però, questa decomposizione del processo di stima porta a una somma di errori di previsione complessivamente superiore a quella degli errori dei metodo diretto, che a discapito dell’interpretabilità resta più performante. La semplicità e l’ingnoranza sembra vincere, con i dati a disposizione, sulla reale natura del fenomeno. Se si includessero altre variabili, come i dati del meteo (temperatura, umidità, pioggia ecc), probabilmente l’approccio indiretto risulterebbe più performante. Sarebbe anche stato possibile omettere lo step di stima del numero di clienti, e stimare direttamente il totale delle vendite con il giorno della settimana. Infine va detto che la funzione sigmoide consente di rispettare il vincolo per cui le percentuali stimate sono comprese tra 0 e 1, ma non il vincolo per cui tali percentuali sommino a 1. Un’altra possibile miglioria potrebbe quindi essere quella di rinormalizzare le previsioni sotto sigmoide attraverso la loro somma, per ottenere delle percentuali che sommino a 1.

modelli addestrati su tutti i dati disponibili

Qui vengono addestrati i modelli finali, che verranno concretamente usati per fare previsioni. Si scelgono ols e random forest per prevedere separatamente la quantità stimata e la percentuale di venduto sul totale.

modelli ols

Code
df = df %>% mutate(ratio = Q / n_day_sells)
g = 0.001

ols_lq = lm(log(1+Q)~prod+P+weekday, data=df)
summary(ols_lq)

Call:
lm(formula = log(1 + Q) ~ prod + P + weekday, data = df)

Residuals:
     Min       1Q   Median       3Q      Max 
-3.09051 -0.39430  0.03401  0.49450  1.61150 

Coefficients:
                        Estimate Std. Error t value Pr(>|t|)    
(Intercept)               5.4387     0.5512   9.867  < 2e-16 ***
prodcotto_e_fontina      -1.7167     0.2377  -7.222 5.95e-12 ***
prodvegetariano          -2.4474     0.2438 -10.039  < 2e-16 ***
prodspeck                -1.3242     0.3267  -4.053 6.73e-05 ***
prodhot_dog              -1.5169     0.2438  -6.222 2.01e-09 ***
prodpatatine             -0.5792     0.3213  -1.803 0.072583 .  
prodbirra                -0.9106     0.2624  -3.470 0.000611 ***
prodmozza                -2.3144     0.3771  -6.137 3.21e-09 ***
prodpiadina_nutella      -1.9649     0.3010  -6.527 3.62e-10 ***
prodpanzerotti           -2.0086     0.2707  -7.421 1.74e-12 ***
prodspeck_e_tomino       -1.9302     0.2828  -6.826 6.38e-11 ***
prodspeck_e_brie         -2.4963     0.2828  -8.828  < 2e-16 ***
prodpancetta_e_scamorza  -2.6523     0.3520  -7.535 8.59e-13 ***
prodhamburger_suino      -1.5248     0.3790  -4.023 7.58e-05 ***
prodbibite               -3.4637     0.4344  -7.973 5.27e-14 ***
prodanelli_cipolla       -2.6496     0.3316  -7.992 4.68e-14 ***
prodnuggets              -2.3387     0.4168  -5.612 5.24e-08 ***
prodverdure_grigliate    -3.5991     0.3380 -10.648  < 2e-16 ***
prodhamburger_manzo      -1.0303     0.3399  -3.031 0.002688 ** 
proddouble_burger        -1.6134     0.4038  -3.995 8.47e-05 ***
prodacqua                -3.5114     0.5023  -6.991 2.40e-11 ***
prodspritz               -2.6628     0.3719  -7.160 8.70e-12 ***
prodanguria              -2.1616     0.8452  -2.558 0.011121 *  
prodmelone               -3.4833     0.8452  -4.122 5.10e-05 ***
prodchurros              -1.9887     0.7877  -2.525 0.012186 *  
prodvirgin_mojito        -1.8346     0.7877  -2.329 0.020639 *  
prodvirgin_pina_colada   -2.1710     0.7877  -2.756 0.006271 ** 
prodspritz_analcolico    -3.0873     0.7877  -3.920 0.000114 ***
P                        -0.2640     0.1142  -2.312 0.021595 *  
weekdaygio               -0.5829     0.2055  -2.836 0.004938 ** 
weekdaylun               -1.2639     0.2203  -5.738 2.72e-08 ***
weekdaymar               -1.1644     0.2066  -5.636 4.61e-08 ***
weekdaymer               -0.7776     0.2055  -3.783 0.000193 ***
weekdayven               -0.3051     0.2014  -1.514 0.131150    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 0.7425 on 254 degrees of freedom
Multiple R-squared:  0.6528,    Adjusted R-squared:  0.6076 
F-statistic: 14.47 on 33 and 254 DF,  p-value: < 2.2e-16
Code
cat('train MAE:', MAE(exp(predict(ols_lq, df))-1, df$Q))
train MAE: 7.528234
Code
ols_ratio = lm(logit(g+ratio)~prod+P+weekday, data=df)
summary(ols_ratio)

Call:
lm(formula = logit(g + ratio) ~ prod + P + weekday, data = df)

Residuals:
    Min      1Q  Median      3Q     Max 
-4.6099 -0.3962  0.0995  0.6141  2.8597 

Coefficients:
                        Estimate Std. Error t value Pr(>|t|)    
(Intercept)               0.2720     0.7817   0.348 0.728134    
prodcotto_e_fontina      -2.0276     0.3371  -6.015 6.25e-09 ***
prodvegetariano          -3.0407     0.3458  -8.794 2.23e-16 ***
prodspeck                -1.7770     0.4634  -3.835 0.000158 ***
prodhot_dog              -1.9487     0.3458  -5.636 4.61e-08 ***
prodpatatine             -0.6973     0.4556  -1.530 0.127148    
prodbirra                -1.3558     0.3721  -3.643 0.000327 ***
prodmozza                -2.8597     0.5348  -5.347 1.99e-07 ***
prodpiadina_nutella      -2.3288     0.4269  -5.455 1.16e-07 ***
prodpanzerotti           -2.5297     0.3838  -6.590 2.52e-10 ***
prodspeck_e_tomino       -2.3054     0.4010  -5.749 2.57e-08 ***
prodspeck_e_brie         -3.1890     0.4010  -7.952 6.03e-14 ***
prodpancetta_e_scamorza  -3.2660     0.4992  -6.543 3.31e-10 ***
prodhamburger_suino      -1.8376     0.5375  -3.419 0.000732 ***
prodbibite               -4.5609     0.6161  -7.403 1.95e-12 ***
prodanelli_cipolla       -3.4137     0.4702  -7.260 4.72e-12 ***
prodnuggets              -3.2526     0.5911  -5.503 9.11e-08 ***
prodverdure_grigliate    -5.2652     0.4794 -10.984  < 2e-16 ***
prodhamburger_manzo      -1.0649     0.4820  -2.209 0.028049 *  
proddouble_burger        -2.2997     0.5727  -4.016 7.81e-05 ***
prodacqua                -4.5725     0.7123  -6.419 6.67e-10 ***
prodspritz               -4.0495     0.5275  -7.677 3.50e-13 ***
prodanguria              -1.4988     1.1986  -1.250 0.212288    
prodmelone               -3.1325     1.1986  -2.613 0.009498 ** 
prodchurros              -1.3071     1.1171  -1.170 0.243073    
prodvirgin_mojito        -1.1189     1.1171  -1.002 0.317476    
prodvirgin_pina_colada   -1.5277     1.1171  -1.368 0.172650    
prodspritz_analcolico    -2.6676     1.1171  -2.388 0.017671 *  
P                        -0.3099     0.1620  -1.914 0.056801 .  
weekdaygio               -0.2931     0.2915  -1.005 0.315636    
weekdaylun               -0.3076     0.3124  -0.985 0.325677    
weekdaymar               -0.5647     0.2930  -1.927 0.055070 .  
weekdaymer               -0.4384     0.2915  -1.504 0.133858    
weekdayven               -0.2819     0.2857  -0.987 0.324772    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 1.053 on 254 degrees of freedom
Multiple R-squared:  0.5941,    Adjusted R-squared:  0.5414 
F-statistic: 11.27 on 33 and 254 DF,  p-value: < 2.2e-16
Code
cat('train MAE:', MAE(sigmoid(predict(ols_ratio, df))-g, df$ratio))
train MAE: 0.029564

modelli random forest

Code
rf_ratio = randomForest(logit(g+ratio)~prod+P+weekday, data=df,
                  ntree=500, mtry=3, replace=F, sampsize=255)
importance(rf_ratio)
        IncNodePurity
prod        391.84633
P            45.30873
weekday      52.79477
Code
cat('\n')
Code
cat('train MAE:', MAE(sigmoid(predict(rf_ratio, df))-g, df$ratio))
train MAE: 0.01978633
Code
rf_lq = randomForest(log(1+Q)~prod+P+weekday, data=df,
                  ntree=500, mtry=3, replace=F, sampsize=255)
importance(rf_lq)
        IncNodePurity
prod        217.49096
P            25.97918
weekday      48.93387
Code
cat('\n')
Code
cat('train MAE:', MAE(exp(predict(rf_lq, df))-1, df$Q))
train MAE: 5.475897

modello per stimare il totale di ordini in base al giorno

Questo modello è probabilmente più performante della soluzione a due stadi proposta prima (al posto che stimare n_day_clients con weekday e n_day_sells con la stima di n_day_clients stimo direttamente n_day_sells con weekday).

Code
ols_n_sells = lm(n_day_sells~weekday+0, data=df)
summary(ols_n_sells)

Call:
lm(formula = n_day_sells ~ weekday + 0, data = df)

Residuals:
     Min       1Q   Median       3Q      Max 
-215.097  -53.097   -3.097   44.714  237.903 

Coefficients:
           Estimate Std. Error t value Pr(>|t|)    
weekdaydom   360.00      20.96  17.178  < 2e-16 ***
weekdaygio   220.29      11.88  18.540  < 2e-16 ***
weekdaylun    78.50      15.72   4.994 1.04e-06 ***
weekdaymar   121.11      12.10  10.009  < 2e-16 ***
weekdaymer   208.88      11.88  17.580  < 2e-16 ***
weekdayven   316.10      10.48  30.166  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 88.91 on 282 degrees of freedom
Multiple R-squared:  0.8755,    Adjusted R-squared:  0.8728 
F-statistic: 330.5 on 6 and 282 DF,  p-value: < 2.2e-16
Code
cat('\n')
Code
cat('train MAE:', MAE(fitted(ols_n_sells), df$n_day_sells))
train MAE: 61.53053

TABELLE DI PREVISIONE

Volendo fornire quindi le previsioni a un utente finale, si fornisce la seguente tavola. Ogni riga rappresenta una combinazione di prezzo, giorno, prodotto e conseguentemente fornisce le stime della percentuale di vendita (dai modelli indiretti) e della quantità venduta (dal modello diretto).

I modelli ols_q, rf_q, ols_iq e rf_iq stimano tutti la quantità venduta di un prodotto, dati il prodotto, il giorno e il prezzo (considerato da 1 a 6 con passo 0.5). I modelli ols_r e rf_r stimano il rapporto tra la quantità venduta del prodotto come percentuale del totale delle vendite del giorno. Il modello p_sells (di tipo ols) stima, dato il giorno, il totale delle vendite di tutti i prodotti. Sotto le colonne ols_iq e rf_iq, rispettivamente, sono dati i prodotti tra le colonne ols_r e rf_r e la colonna p_sells.

Importante:

  • nessuna delle 4 stime è un oracolo perfetto

  • neanche la media delle diverse stime è necessariamente una buona stima, ed è più frequente che ce ne sia solo una più giusta delle altre.

  • la regressione lineare è una retta continua, per cui può muoversi su un dominio infinito, mentre il modello random forest (rf) è bloccato sugli intervalli osservati nei dati. La regressione lineare è più adatta per prevedere fenomeni eccezionali, ma nella maggior parte dei casi il modello rf funziona meglio.

  • Il modello p_sells dipende solo dai giorni, e di conseguenza fornisce sempre la stessa stima per lo stesso giorno.

La colonna p_sells viene rinominata ps.

Ecco le previsioni (con modelli addestrati su tutti i dati disponibili):

Code
newdf = expand.grid(P = seq(1,6, by=0.5),
                    weekday = levels(df$weekday), 
                     prod = levels(df$prod))
newdf = newdf[, c('prod', 'weekday', 'P')]

newdf = newdf %>%
  mutate(
    ols_q = round(exp(predict(ols_lq, newdf))-1),
    rf_q = round(exp(predict(rf_lq, newdf))-1),
    ols_r = round(sigmoid(predict(ols_ratio, newdf))-g, 3),
    rf_r = round(sigmoid(predict(rf_ratio, newdf))-g, 3),
    p_sells = round(predict(ols_n_sells,newdf))
  )%>%mutate(
    ols_iq = round(p_sells*ols_r),
    rf_iq = round(p_sells*rf_r),
    ols_r = paste0(ols_r*100,'%'),
    rf_r = paste0(rf_r*100,'%')
  )


newdf = newdf[,c('prod',  'weekday', 'P', 'ols_q', 'rf_q', 'ols_iq', 'rf_iq',
                 'ols_r', 'rf_r', 'p_sells')]

colnames(newdf)[ncol(newdf)] = 'ps'


#options(max.print=1000000)
#print.data.frame(newdf)

DT::datatable(newdf)