<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Time Series | Nitin Gupta</title>
    <link>https://www.nitingupta.com/tags/time-series/</link>
      <atom:link href="https://www.nitingupta.com/tags/time-series/index.xml" rel="self" type="application/rss+xml" />
    <description>Time Series</description>
    <generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><copyright>© Nitin Gupta. All Rights Reserved.</copyright><lastBuildDate>Sun, 18 Oct 2026 00:00:00 +0000</lastBuildDate>
    <image>
      <url>https://www.nitingupta.com/img/icon-192.png</url>
      <title>Time Series</title>
      <link>https://www.nitingupta.com/tags/time-series/</link>
    </image>
    
    <item>
      <title>Bank Marketing - Part 3 - Keeping up with the times - Sliding Window Models</title>
      <link>https://www.nitingupta.com/casestudies/bank-marketing-part3-sliding-window/</link>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +0000</pubDate>
      <guid>https://www.nitingupta.com/casestudies/bank-marketing-part3-sliding-window/</guid>
      <description>


&lt;div id=&#34;other-posts-in-this-series&#34; class=&#34;section level4&#34;&gt;
&lt;h4&gt;Other posts in this series:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part1-eda/&#34;&gt;Bank Marketing - Part 1 - Making the call - An Exploratory Data Analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part2-models/&#34;&gt;Bank Marketing - Part 2 - Who will say yes? - Building Classification Models&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In &lt;a href=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part1-eda/&#34;&gt;Part 1&lt;/a&gt;, we found that the subscription rate changed a lot between 2008 and 2010, as interest rates fell and the bank changed its campaigns. In &lt;a href=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part2-models/&#34;&gt;Part 2&lt;/a&gt;, we built models on a random sample of the calls and tested them on the other calls from the same period. They did well. But when we trained them on the earlier calls and tested them on the later calls, they did much worse.&lt;/p&gt;
&lt;p&gt;The calls in this dataset are independent of each other: one client’s answer does not change another client’s answer. But they are not independent of &lt;em&gt;time&lt;/em&gt;. What makes a client say yes, and how often, depends on the economy and on the campaign at the time. A model learned from the past only works for the near future.&lt;/p&gt;
&lt;p&gt;So in this post, we do what a bank would do in practice. We take the calls of the last 12 months, choose the hyperparameters and train the models on them, and use the models to predict the calls of the next 3 months. Then we move forward by 3 months, and repeat. This is called a sliding window (or walk-forward) evaluation.&lt;/p&gt;
&lt;/div&gt;
&lt;div id=&#34;the-timeline-of-the-calls&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;The Timeline of the Calls&lt;/h2&gt;
&lt;p&gt;In Part 1, we recovered the year of each call from the order of the calls. Here are the calls and the subscription rate in each month:&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;p1 &amp;lt;- ggplot(dfMonthly, aes(x = year_month, y = calls)) +
  geom_col(fill = &amp;quot;#00AED8&amp;quot;) +
  scale_y_continuous(labels = scales::label_comma()) +
  scale_x_date(date_breaks = &amp;quot;3 months&amp;quot;, date_labels = &amp;quot;%b %Y&amp;quot;) +
  labs(x = NULL, y = &amp;quot;Calls&amp;quot;, title = &amp;quot;Number of calls in each month&amp;quot;)
p2 &amp;lt;- ggplot(dfMonthly, aes(x = year_month, y = rate)) +
  geom_line(color = &amp;quot;#F37735&amp;quot;) + geom_point(aes(size = calls), color = &amp;quot;#F37735&amp;quot;, show.legend = FALSE) +
  scale_y_continuous(labels = scales::label_percent()) +
  scale_x_date(date_breaks = &amp;quot;3 months&amp;quot;, date_labels = &amp;quot;%b %Y&amp;quot;) +
  labs(x = NULL, y = &amp;quot;Subscription rate&amp;quot;, title = &amp;quot;Subscription rate in each month (point size: number of calls)&amp;quot;)
cowplot::plot_grid(plotlist = list(p1, p2), ncol = 1, align = &amp;quot;v&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part3-sliding-window/index_files/figure-html/timeline-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;&lt;/p&gt;
&lt;p&gt;Most of the calls were made in 2008, when few clients said yes. From the middle of 2009, the bank made far fewer calls, and about half of them succeeded. So a model trained on the past learns from a very different situation from the one that it has to predict.&lt;/p&gt;
&lt;/div&gt;
&lt;div id=&#34;how-the-sliding-windows-work&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;How the Sliding Windows Work&lt;/h2&gt;
&lt;p&gt;Each cycle has two parts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;training window&lt;/strong&gt; of the 12 calendar months before the testing period. We choose the hyperparameters with cross validation on these calls only, and train the final model on all of them.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;testing period&lt;/strong&gt; of the next 3 months. The model predicts these calls, which it has never seen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the window moves forward by 3 months. The first testing period starts in June 2009, the first month with 12 full months of calls before it, and the last one ends in November 2010, the last month of the data. Some months have no calls, so a window can have fewer than 12 months of calls.&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;dfCycles %&amp;gt;%
  transmute(Cycle = cycle,
            `Training months` = paste(format(train_first, &amp;quot;%b %Y&amp;quot;), &amp;quot;-&amp;quot;, format(train_last, &amp;quot;%b %Y&amp;quot;)),
            `Training calls` = scales::label_comma()(train_calls),
            `Training rate` = scales::label_percent(accuracy = 0.1)(train_rate),
            `Testing months` = if_else(test_first == test_last, format(test_first, &amp;quot;%b %Y&amp;quot;),
                                       paste(format(test_first, &amp;quot;%b %Y&amp;quot;), &amp;quot;-&amp;quot;, format(test_last, &amp;quot;%b %Y&amp;quot;))),
            `Testing calls` = scales::label_comma()(test_calls),
            `Testing rate` = scales::label_percent(accuracy = 0.1)(test_rate)) %&amp;gt;%
  knitr::kable()&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;colgroup&gt;
&lt;col width=&#34;5%&#34; /&gt;
&lt;col width=&#34;19%&#34; /&gt;
&lt;col width=&#34;14%&#34; /&gt;
&lt;col width=&#34;13%&#34; /&gt;
&lt;col width=&#34;19%&#34; /&gt;
&lt;col width=&#34;13%&#34; /&gt;
&lt;col width=&#34;12%&#34; /&gt;
&lt;/colgroup&gt;
&lt;thead&gt;
&lt;tr class=&#34;header&#34;&gt;
&lt;th align=&#34;right&#34;&gt;Cycle&lt;/th&gt;
&lt;th align=&#34;left&#34;&gt;Training months&lt;/th&gt;
&lt;th align=&#34;left&#34;&gt;Training calls&lt;/th&gt;
&lt;th align=&#34;left&#34;&gt;Training rate&lt;/th&gt;
&lt;th align=&#34;left&#34;&gt;Testing months&lt;/th&gt;
&lt;th align=&#34;left&#34;&gt;Testing calls&lt;/th&gt;
&lt;th align=&#34;left&#34;&gt;Testing rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr class=&#34;odd&#34;&gt;
&lt;td align=&#34;right&#34;&gt;1&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Jun 2008 - May 2009&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;28,452&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;7.7%&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Jun 2009 - Aug 2009&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;1,662&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;35.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;even&#34;&gt;
&lt;td align=&#34;right&#34;&gt;2&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Oct 2008 - Aug 2009&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;13,886&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;13.8%&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Sep 2009 - Nov 2009&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;1,070&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;42.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;odd&#34;&gt;
&lt;td align=&#34;right&#34;&gt;3&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Dec 2008 - Nov 2009&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;11,274&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;19.0%&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Dec 2009&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;172&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;51.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;even&#34;&gt;
&lt;td align=&#34;right&#34;&gt;4&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Mar 2009 - Dec 2009&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;11,436&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;19.5%&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Mar 2010 - May 2010&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;650&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;56.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;odd&#34;&gt;
&lt;td align=&#34;right&#34;&gt;5&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Jun 2009 - May 2010&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;3,554&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;42.3%&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Jun 2010 - Aug 2010&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;773&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;52.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;even&#34;&gt;
&lt;td align=&#34;right&#34;&gt;6&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Sep 2009 - Aug 2010&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;2,665&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;49.3%&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;Sep 2010 - Nov 2010&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;635&lt;/td&gt;
&lt;td align=&#34;left&#34;&gt;47.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;dfCycles %&amp;gt;%
  select(cycle, train_first, train_last, test_first, test_last) %&amp;gt;%
  pivot_longer(-cycle, names_to = c(&amp;quot;part&amp;quot;, &amp;quot;.value&amp;quot;), names_sep = &amp;quot;_&amp;quot;) %&amp;gt;%
  mutate(part = if_else(part == &amp;quot;train&amp;quot;, &amp;quot;training window&amp;quot;, &amp;quot;testing period&amp;quot;),
         last = last %m+% months(1)) %&amp;gt;%
  ggplot(aes(y = factor(cycle), xmin = first, xmax = last, color = part)) +
  geom_linerange(linewidth = 6) +
  scale_y_discrete(limits = rev) +
  scale_x_date(date_breaks = &amp;quot;3 months&amp;quot;, date_labels = &amp;quot;%b %Y&amp;quot;) +
  scale_color_manual(values = c(&amp;quot;training window&amp;quot; = &amp;quot;#00AED8&amp;quot;, &amp;quot;testing period&amp;quot; = &amp;quot;#F37735&amp;quot;), name = NULL) +
  labs(x = NULL, y = &amp;quot;Cycle&amp;quot;, title = &amp;quot;Training windows and testing periods&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part3-sliding-window/index_files/figure-html/windows_plot-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;&lt;/p&gt;
&lt;div id=&#34;whats-notable&#34; class=&#34;section level4&#34;&gt;
&lt;h4&gt;What’s notable?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;The training windows shrink from 28,452 calls in the first cycle to 2,665 in the last, because the bank made fewer calls each year.&lt;/li&gt;
&lt;li&gt;In the first cycles, the models learn from a very different situation from the one that they predict: in cycle 1, 7.7% of the training calls succeeded, but 35.7% of the testing calls did. By the last cycles, the training windows have similar rates to the testing periods.&lt;/li&gt;
&lt;li&gt;Cycle 3 tests only December 2009, because there were no calls in January and February 2010.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In each cycle, the code checks that every training call was made before every testing call, and that no call is in both. Everything that the model learns, including the hyperparameters and the preparation of the features (such as the dummy variables and which features are skewed), comes from the training window only.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div id=&#34;feature-sets&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;Feature Sets&lt;/h2&gt;
&lt;p&gt;We compare three sets of features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;client and campaign&lt;/strong&gt;: the features of the client and of the call, such as &lt;code&gt;age&lt;/code&gt;, &lt;code&gt;job&lt;/code&gt;, &lt;code&gt;contact&lt;/code&gt;, &lt;code&gt;month&lt;/code&gt; and the history of previous campaigns. No economic features.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;+ economic levels&lt;/strong&gt;: the same features plus the five economic features, as in Part 2.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;+ economic trends&lt;/strong&gt;: the client and campaign features plus three features that describe how things changed, instead of their levels: &lt;code&gt;euribor_change&lt;/code&gt;, the change of the Euribor rate; &lt;code&gt;employed_change&lt;/code&gt;, the change of the number of employees; and &lt;code&gt;prev_month_rate&lt;/code&gt;, the subscription rate in the last month that the model knows.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For the calls in a testing period, the trend features compare with the last month of the training window, the last month that the bank knows when it trains the model. So the model does not use the results of other calls in the testing period.&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;p1 &amp;lt;- ggplot(dfMonthly, aes(x = year_month, y = euribor_mean)) +
  geom_line(color = &amp;quot;#00AED8&amp;quot;) + geom_point(color = &amp;quot;#00AED8&amp;quot;) +
  scale_x_date(date_breaks = &amp;quot;3 months&amp;quot;, date_labels = &amp;quot;%b %Y&amp;quot;) +
  labs(x = NULL, y = &amp;quot;3 month Euribor (%)&amp;quot;, title = &amp;quot;Average 3 month Euribor in each month&amp;quot;) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
p2 &amp;lt;- ggplot(dfMonthly %&amp;gt;% filter(!is.na(prev_month_rate)), aes(x = rate, y = prev_month_rate)) +
  geom_abline(lty = 2) +
  geom_point(aes(size = calls), color = &amp;quot;#F37735&amp;quot;, alpha = 0.7, show.legend = FALSE) +
  scale_x_continuous(labels = scales::label_percent()) +
  scale_y_continuous(labels = scales::label_percent()) +
  labs(x = &amp;quot;Subscription rate in the month&amp;quot;, y = &amp;quot;Rate in the previous month with calls&amp;quot;,
       title = &amp;quot;Subscription rate vs the previous month&amp;quot;)
cowplot::plot_grid(plotlist = list(p1, p2), ncol = 2)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part3-sliding-window/index_files/figure-html/trend_features-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div id=&#34;results&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;div id=&#34;all-the-testing-periods-together&#34; class=&#34;section level3&#34;&gt;
&lt;h3&gt;All the Testing Periods Together&lt;/h3&gt;
&lt;p&gt;We put the predictions of all the testing periods together, 4,962 calls in total, and measure each model and feature set. We also measure the ROC AUC within each month and take the average. This shows how well a model ranks the clients &lt;em&gt;within&lt;/em&gt; a month, which is what a bank needs to decide whom to call that month.&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;dfOverall &amp;lt;- dfSlidingPred %&amp;gt;%
  group_by(model, feature_set) %&amp;gt;%
  summarise(roc_auc = roc_auc_vec(y, .pred_yes),
            pr_auc = pr_auc_vec(y, .pred_yes),
            mn_log_loss = mn_log_loss_vec(y, .pred_yes),
            mean_predicted = mean(.pred_yes),
            actual_rate = mean(y == &amp;quot;yes&amp;quot;),
            .groups = &amp;quot;drop&amp;quot;) %&amp;gt;%
  left_join(dfSlidingPred %&amp;gt;%
              group_by(model, feature_set, year_month) %&amp;gt;%
              summarise(month_roc_auc = roc_auc_vec(y, .pred_yes), .groups = &amp;quot;drop&amp;quot;) %&amp;gt;%
              group_by(model, feature_set) %&amp;gt;%
              summarise(monthly_roc_auc = mean(month_roc_auc), .groups = &amp;quot;drop&amp;quot;),
            by = c(&amp;quot;model&amp;quot;, &amp;quot;feature_set&amp;quot;)) %&amp;gt;%
  mutate(feature_set = factor(feature_set, levels = names(lFeatureSets))) %&amp;gt;%
  arrange(model, feature_set)

dfOverall %&amp;gt;%
  mutate(across(c(roc_auc, pr_auc, mn_log_loss, monthly_roc_auc), ~ round(.x, 3)),
         across(c(mean_predicted, actual_rate), ~ scales::label_percent(accuracy = 0.1)(.x))) %&amp;gt;%
  select(model, feature_set, roc_auc, monthly_roc_auc, pr_auc, mn_log_loss, mean_predicted, actual_rate) %&amp;gt;%
  # in the HTML table, each cell&amp;#39;s text is on a line of its own, and Hugo would turn a line that starts with &amp;quot;+&amp;quot; into a
  # bulleted list, so the text is wrapped in a &amp;lt;span&amp;gt;
  mutate(feature_set = paste0(&amp;quot;&amp;lt;span&amp;gt;&amp;quot;, feature_set, &amp;quot;&amp;lt;/span&amp;gt;&amp;quot;)) %&amp;gt;%
  # an HTML table, so that the browser sizes the columns to their contents, and the headers break where they should;
  # text columns are aligned left, and number columns right, with their headers
  knitr::kable(format = &amp;quot;html&amp;quot;, escape = FALSE, align = c(&amp;quot;l&amp;quot;, &amp;quot;l&amp;quot;, &amp;quot;r&amp;quot;, &amp;quot;r&amp;quot;, &amp;quot;r&amp;quot;, &amp;quot;r&amp;quot;, &amp;quot;r&amp;quot;, &amp;quot;r&amp;quot;),
               col.names = c(&amp;quot;Model&amp;quot;, &amp;quot;Features&amp;quot;, &amp;quot;ROC&amp;lt;br&amp;gt;AUC&amp;quot;, &amp;quot;Average ROC AUC&amp;lt;br&amp;gt;within a month&amp;quot;, &amp;quot;PR&amp;lt;br&amp;gt;AUC&amp;quot;, &amp;quot;Log&amp;lt;br&amp;gt;loss&amp;quot;,
                             &amp;quot;Average predicted&amp;lt;br&amp;gt;probability&amp;quot;, &amp;quot;Actual&amp;lt;br&amp;gt;subscription rate&amp;quot;))&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&#34;text-align:left;&#34;&gt;
Model
&lt;/th&gt;
&lt;th style=&#34;text-align:left;&#34;&gt;
Features
&lt;/th&gt;
&lt;th style=&#34;text-align:right;&#34;&gt;
ROC&lt;br&gt;AUC
&lt;/th&gt;
&lt;th style=&#34;text-align:right;&#34;&gt;
Average ROC AUC&lt;br&gt;within a month
&lt;/th&gt;
&lt;th style=&#34;text-align:right;&#34;&gt;
PR&lt;br&gt;AUC
&lt;/th&gt;
&lt;th style=&#34;text-align:right;&#34;&gt;
Log&lt;br&gt;loss
&lt;/th&gt;
&lt;th style=&#34;text-align:right;&#34;&gt;
Average predicted&lt;br&gt;probability
&lt;/th&gt;
&lt;th style=&#34;text-align:right;&#34;&gt;
Actual&lt;br&gt;subscription rate
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
glmnet
&lt;/td&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
&lt;span&gt;client and campaign&lt;/span&gt;
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.655
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.678
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.621
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.762
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
27.4%
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
44.5%
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
glmnet
&lt;/td&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
&lt;span&gt;+ economic levels&lt;/span&gt;
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.596
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.683
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.519
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
1.337
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
50.7%
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
44.5%
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
glmnet
&lt;/td&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
&lt;span&gt;+ economic trends&lt;/span&gt;
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.588
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.678
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.521
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.902
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
45.9%
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
44.5%
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
xgboost
&lt;/td&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
&lt;span&gt;client and campaign&lt;/span&gt;
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.650
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.677
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.615
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.764
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
27.7%
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
44.5%
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
xgboost
&lt;/td&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
&lt;span&gt;+ economic levels&lt;/span&gt;
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.638
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.669
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.598
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.721
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
31.7%
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
44.5%
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
xgboost
&lt;/td&gt;
&lt;td style=&#34;text-align:left;&#34;&gt;
&lt;span&gt;+ economic trends&lt;/span&gt;
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.573
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.650
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.519
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
0.716
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
47.1%
&lt;/td&gt;
&lt;td style=&#34;text-align:right;&#34;&gt;
44.5%
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;overall &amp;lt;- function(model, feature_set, metric) dfOverall[[metric]][dfOverall$model == model &amp;amp; dfOverall$feature_set == feature_set]
# the best model and feature set, by ROC AUC over all the testing periods together
best_row &amp;lt;- dfOverall %&amp;gt;% slice_max(roc_auc, n = 1)&lt;/code&gt;&lt;/pre&gt;
&lt;div id=&#34;whats-notable-1&#34; class=&#34;section level4&#34;&gt;
&lt;h4&gt;What’s notable?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;On calls that they have not seen, from the next 3 months, the models do much worse than in Part 2. The best ROC AUC is 0.655, for glmnet with the client and campaign features, compared with about 0.80 on the random testing set of Part 2.&lt;/li&gt;
&lt;li&gt;Within a month, all the models and feature sets rank the clients about equally well, with an average ROC AUC of 0.650 to 0.683. The differences in the overall ROC AUC come mostly from how well the models predict the level of each month: a model that gives every client in a good month a higher probability ranks them higher overall.&lt;/li&gt;
&lt;li&gt;The economic features do not help. With their levels, &lt;code&gt;glmnet&lt;/code&gt; has an overall ROC AUC of only 0.596 and the worst log loss, 1.337. The trend features do not help either.&lt;/li&gt;
&lt;li&gt;Without the economic features, the models expect far fewer subscribers than there are: an average predicted probability of about 27%, compared with the actual rate of 44%. They learned from windows with lower subscription rates.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div id=&#34;cycle-by-cycle&#34; class=&#34;section level3&#34;&gt;
&lt;h3&gt;Cycle by Cycle&lt;/h3&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;dfCycleResults &amp;lt;- dfSlidingPred %&amp;gt;%
  group_by(cycle, model, feature_set) %&amp;gt;%
  summarise(roc_auc = roc_auc_vec(y, .pred_yes), .groups = &amp;quot;drop&amp;quot;) %&amp;gt;%
  mutate(feature_set = factor(feature_set, levels = names(lFeatureSets)))

ggplot(dfCycleResults, aes(x = cycle, y = roc_auc, color = feature_set)) +
  geom_hline(yintercept = 0.5, lty = 2) +
  geom_line() + geom_point(size = 2) +
  facet_wrap(~ model) +
  scale_x_continuous(breaks = dfCycles$cycle) +
  scale_color_quant(palette = &amp;quot;metro&amp;quot;, name = &amp;quot;Features&amp;quot;) +
  labs(x = &amp;quot;Cycle&amp;quot;, y = &amp;quot;ROC AUC on the testing period&amp;quot;, title = &amp;quot;ROC AUC in each testing period&amp;quot;,
       subtitle = &amp;quot;The dashed line is a random ranking&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part3-sliding-window/index_files/figure-html/cycle_results-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;&lt;/p&gt;
&lt;div id=&#34;whats-notable-2&#34; class=&#34;section level4&#34;&gt;
&lt;h4&gt;What’s notable?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;In every cycle, the models and feature sets have similar ROC AUCs. The period matters much more than the model or the features.&lt;/li&gt;
&lt;li&gt;The ROC AUC rises from about 0.57 in cycle 1 to about 0.76 in the last cycle. In the first cycle, the training window is mostly calls from 2008, when few clients said yes. In the last cycles, the training window is from the same situation as the testing period, so what the models learned still applies.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div id=&#34;predicted-and-actual-subscription-rates&#34; class=&#34;section level3&#34;&gt;
&lt;h3&gt;Predicted and Actual Subscription Rates&lt;/h3&gt;
&lt;p&gt;A bank also needs to know how many subscribers to expect, for example to plan its staff. Here is the average predicted probability in each testing month, compared with the actual subscription rate:&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;dfSlidingPred %&amp;gt;%
  mutate(feature_set = factor(feature_set, levels = names(lFeatureSets))) %&amp;gt;%
  group_by(model, feature_set, year_month) %&amp;gt;%
  summarise(predicted = mean(.pred_yes), actual = mean(y == &amp;quot;yes&amp;quot;), .groups = &amp;quot;drop&amp;quot;) %&amp;gt;%
  ggplot(aes(x = year_month)) +
  geom_line(aes(y = actual), color = &amp;quot;black&amp;quot;, linewidth = 1) +
  geom_line(aes(y = predicted, color = feature_set)) +
  geom_point(aes(y = predicted, color = feature_set), size = 1.5) +
  facet_wrap(~ model) +
  scale_y_continuous(labels = scales::label_percent()) +
  scale_x_date(date_breaks = &amp;quot;3 months&amp;quot;, date_labels = &amp;quot;%b %Y&amp;quot;) +
  scale_color_quant(palette = &amp;quot;metro&amp;quot;, name = &amp;quot;Features&amp;quot;) +
  labs(x = NULL, y = &amp;quot;Subscription rate&amp;quot;, title = &amp;quot;Predicted (colors) and actual (black) subscription rate in each testing month&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part3-sliding-window/index_files/figure-html/calibration-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;&lt;/p&gt;
&lt;div id=&#34;whats-notable-3&#34; class=&#34;section level4&#34;&gt;
&lt;h4&gt;What’s notable?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Without the economic features, the models predict too few subscribers in the first cycles, and catch up as the training windows include more months with high rates.&lt;/li&gt;
&lt;li&gt;With the levels of the economic features, &lt;code&gt;glmnet&lt;/code&gt; swings widely: in September to November 2009, it predicts that almost every client will say yes. In those months, interest rates were lower than in any month of its training window, so it extended a straight-line trend that no longer applied (as in Part 2).&lt;/li&gt;
&lt;li&gt;With the trend features, the average prediction is closer to the actual rate over the whole period, but it jumps from one cycle to the next, because the change of the economy and last month’s rate are only a rough guide to the next 3 months.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div id=&#34;the-hyperparameters-change-from-cycle-to-cycle&#34; class=&#34;section level3&#34;&gt;
&lt;h3&gt;The Hyperparameters Change from Cycle to Cycle&lt;/h3&gt;
&lt;p&gt;These are the hyperparameters of &lt;code&gt;xgboost&lt;/code&gt; chosen in each cycle, with the client and campaign features:&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;dfCycleParams %&amp;gt;%
  filter(model == &amp;quot;xgboost&amp;quot;, feature_set == best_row$feature_set) %&amp;gt;%
  unnest(params) %&amp;gt;%
  transmute(Cycle = cycle, min_n, tree_depth, learn_rate = signif(learn_rate, 3),
            `Cross validated ROC AUC in the training window` = round(cv_roc_auc, 3)) %&amp;gt;%
  knitr::kable()&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;colgroup&gt;
&lt;col width=&#34;7%&#34; /&gt;
&lt;col width=&#34;7%&#34; /&gt;
&lt;col width=&#34;13%&#34; /&gt;
&lt;col width=&#34;13%&#34; /&gt;
&lt;col width=&#34;58%&#34; /&gt;
&lt;/colgroup&gt;
&lt;thead&gt;
&lt;tr class=&#34;header&#34;&gt;
&lt;th align=&#34;right&#34;&gt;Cycle&lt;/th&gt;
&lt;th align=&#34;right&#34;&gt;min_n&lt;/th&gt;
&lt;th align=&#34;right&#34;&gt;tree_depth&lt;/th&gt;
&lt;th align=&#34;right&#34;&gt;learn_rate&lt;/th&gt;
&lt;th align=&#34;right&#34;&gt;Cross validated ROC AUC in the training window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr class=&#34;odd&#34;&gt;
&lt;td align=&#34;right&#34;&gt;1&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;23&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;2&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.0032&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.619&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;even&#34;&gt;
&lt;td align=&#34;right&#34;&gt;2&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;40&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;1&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.0395&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.692&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;odd&#34;&gt;
&lt;td align=&#34;right&#34;&gt;3&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;39&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;12&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.0035&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.755&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;even&#34;&gt;
&lt;td align=&#34;right&#34;&gt;4&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;39&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;12&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.0035&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.759&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;odd&#34;&gt;
&lt;td align=&#34;right&#34;&gt;5&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;23&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;2&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.0032&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.634&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;even&#34;&gt;
&lt;td align=&#34;right&#34;&gt;6&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;23&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;15&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.0089&lt;/td&gt;
&lt;td align=&#34;right&#34;&gt;0.693&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div id=&#34;whats-notable-4&#34; class=&#34;section level4&#34;&gt;
&lt;h4&gt;What’s notable?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;The best hyperparameters change from cycle to cycle, from very shallow trees (a depth of 1 or 2) to deep trees (a depth of 12 or more). This is why we must choose them again in each cycle, on the training window only, rather than reuse the hyperparameters of Part 2, which were chosen on calls from the whole period.&lt;/li&gt;
&lt;li&gt;The cross validated ROC AUC within a training window does not tell us how well the model will do on the next 3 months: sometimes it is higher, and sometimes lower. Cross validation within a window measures how well a model fits that period, not the next one.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div id=&#34;comparison-with-part-2&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;Comparison with Part 2&lt;/h2&gt;
&lt;p&gt;The three ways of testing the models give very different answers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;On a random testing set from the same period (Part 2), the best models had a ROC AUC of about 0.80.&lt;/li&gt;
&lt;li&gt;When we trained on the first 75% of the calls and tested on the last 25% (Part 2), the ROC AUC was between 0.28 and 0.71, depending on the model and the features. But the hyperparameters had been chosen on calls from the whole period.&lt;/li&gt;
&lt;li&gt;With sliding windows, where every choice is made on past calls only, the ROC AUC over all the testing periods is between 0.573 and 0.655.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The testing periods are not exactly the same (the later calls of Part 2 start in May 2009, and the testing periods here start in June 2009), so the numbers cannot be compared exactly. But the message is clear: the random testing set of Part 2 gave a much too good picture of how the models would predict future calls.&lt;/p&gt;
&lt;/div&gt;
&lt;div id=&#34;how-a-bank-could-use-the-models&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;How a Bank Could Use the Models&lt;/h2&gt;
&lt;p&gt;Even with modest ROC AUCs, the models are useful within a month. If the bank calls the 20% of each month’s clients with the highest probabilities, using the glmnet model with the client and campaign features:&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;dfTopCalls &amp;lt;- dfSlidingPred %&amp;gt;%
  filter(model == best_row$model, feature_set == best_row$feature_set) %&amp;gt;%
  group_by(year_month) %&amp;gt;%
  arrange(desc(.pred_yes), .by_group = TRUE) %&amp;gt;%
  mutate(called = row_number() &amp;lt;= ceiling(0.2 * n())) %&amp;gt;%
  ungroup()

top_reach &amp;lt;- sum(dfTopCalls$y == &amp;quot;yes&amp;quot; &amp;amp; dfTopCalls$called) / sum(dfTopCalls$y == &amp;quot;yes&amp;quot;)
top_rate &amp;lt;- mean(dfTopCalls$y[dfTopCalls$called] == &amp;quot;yes&amp;quot;)
all_rate &amp;lt;- mean(dfTopCalls$y == &amp;quot;yes&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;It reaches 31% of the subscribers with 20% of the calls.&lt;/li&gt;
&lt;li&gt;68% of these calls end with a &lt;em&gt;yes&lt;/em&gt;, compared with 44% of all the calls in the testing periods: 1.5 times as many.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is a smaller gain than in Part 2, where the top 20% reached about two thirds of the subscribers. But it is an honest estimate of what the bank could expect from a model trained on the past.&lt;/p&gt;
&lt;/div&gt;
&lt;div id=&#34;conclusions&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;Conclusions&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;When the data covers a period of time, the way we test a model matters as much as the model itself. A random testing set lets the model learn from the same period that it is tested on, and it gives a much too good picture.&lt;/li&gt;
&lt;li&gt;In a sliding window, every step that learns from data, including the choice of the hyperparameters, the preparation of the features and the features that describe the recent past, must use the training window only. Then the testing period is truly unseen.&lt;/li&gt;
&lt;li&gt;On truly unseen calls, the models rank the clients within a month moderately well (an average ROC AUC of about 0.67), and calling the top 20% of each month gives about 1.5 times the success rate of calling at random.&lt;/li&gt;
&lt;li&gt;The economic features, which looked so useful in Part 2, do not help a model that predicts the future: the economy of the next months is outside what the model has seen.&lt;/li&gt;
&lt;li&gt;The models do better when the recent past is like the near future. In practice, a bank should train its models again often, and watch how well they predict each new month.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div id=&#34;appendix-sliding-window-code&#34; class=&#34;section level2&#34;&gt;
&lt;h2&gt;Appendix: Sliding Window Code&lt;/h2&gt;
&lt;p&gt;This code runs the sliding windows. It takes several minutes, so it does not run each time we build this page. Instead, the page saves the results in the file &lt;code&gt;Bank_Sliding.Rda&lt;/code&gt;, together with a fingerprint (a hash) of the inputs: the clean data from &lt;a href=&#34;https://www.nitingupta.com/casestudies/bank-marketing-part1-eda/&#34;&gt;Part 1&lt;/a&gt; and the code itself. When we build the page again, it calculates the fingerprint again:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the fingerprint is the same, the page loads the saved results in a few seconds.&lt;/li&gt;
&lt;li&gt;If the data or any part of the code has changed, the page runs the sliding windows again and saves the new results.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Changes to the comments or the spacing of the code do not count as changes. To run the sliding windows again without any change, delete &lt;code&gt;Bank_Sliding.Rda&lt;/code&gt;.&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;# How the sliding window code is built:
#   1. Each cycle has a training window of 12 calendar months and a testing period of the next 3 months. Then the
#      window moves forward by 3 months. Hyperparameters are tuned within each training window only, so no information
#      from the testing period or later is used (no lookahead). The Part 2 hyperparameters are not reused, because they
#      were tuned on calls from the whole period.
#   2. Within a training window, the hyperparameters are tuned by racing with 5-fold cross validation, as in Part 2.
#      The folds are random within the window: they do not need to respect time, because all of them come before the
#      testing period.
#   3. Three feature sets are compared: the client and campaign features only, these plus the five economic features
#      (their levels, as in Part 2), and these plus three trend features that describe how the economy and the campaign
#      changed (see &amp;quot;Trend features&amp;quot;).
#   4. Two models are compared: glmnet (regularized logistic regression) and xgboost. Plain logistic regression is not
#      used, because the later training windows have only a few thousand calls and ~50 predictors, where an
#      unregularized model is unstable. ranger is not used, because it was the slowest to tune in Part 2, and it did
#      not do better than xgboost.
#   5. The cycles run one after another; within a cycle, the tuning runs in parallel (see &amp;quot;Parallel backend&amp;quot;).

# load the clean dataset and the dates of the calls from Part 1 (bank_data_file is set in the &amp;quot;sliding_cache&amp;quot; chunk)
load(bank_data_file)

dfCalls &amp;lt;- dfClean %&amp;gt;%
    inner_join(dfCallDates, by = &amp;quot;Id&amp;quot;) %&amp;gt;%
    arrange(Id)

# Trend features
########################################################################################################################################################
# Calculated from past months only:
#   euribor_change:  the 3 month Euribor on the day of the call, minus its average in a reference month.
#   employed_change: the number of employees (nr.employed) at the time of the call, minus its average in the
#                    reference month.
#   prev_month_rate: the subscription rate in the reference month.
# For the calls in a training window, the reference month is the previous month with calls (some months have no calls:
# January, February and September 2008). For the calls in a testing period, it is the last month of the training
# window, for all 3 testing months (see &amp;quot;Run the cycles&amp;quot;). Otherwise, in the 2nd and 3rd testing months,
# prev_month_rate would use the results of calls in the testing period, which the model must not see.
# Euribor and employment on the day of a call are published economic data, known at the time of the call.
# The first month, May 2008, has no previous month, so its calls are left out.
dfMonthly &amp;lt;- dfCalls %&amp;gt;%
    group_by(year_month) %&amp;gt;%
    summarise(calls = n(), rate = mean(y == &amp;quot;yes&amp;quot;), euribor_mean = mean(euribor3m), employed_mean = mean(nr.employed)) %&amp;gt;%
    arrange(year_month) %&amp;gt;%
    mutate(prev_euribor = lag(euribor_mean), prev_employed = lag(employed_mean), prev_month_rate = lag(rate))

dfCalls &amp;lt;- dfCalls %&amp;gt;%
    inner_join(dfMonthly %&amp;gt;% select(year_month, prev_euribor, prev_employed, prev_month_rate), by = &amp;quot;year_month&amp;quot;) %&amp;gt;%
    mutate(euribor_change = euribor3m - prev_euribor,
           employed_change = nr.employed - prev_employed) %&amp;gt;%
    select(-prev_euribor, -prev_employed) %&amp;gt;%
    filter(!is.na(prev_month_rate))

vFeaturesEconomic &amp;lt;- c(&amp;quot;emp.var.rate&amp;quot;, &amp;quot;cons.price.idx&amp;quot;, &amp;quot;cons.conf.idx&amp;quot;, &amp;quot;euribor3m&amp;quot;, &amp;quot;nr.employed&amp;quot;)
vFeaturesTrend &amp;lt;- c(&amp;quot;euribor_change&amp;quot;, &amp;quot;employed_change&amp;quot;, &amp;quot;prev_month_rate&amp;quot;)

# predictors left out of each feature set (Id, year and year_month are never predictors)
lFeatureSets &amp;lt;- list(&amp;quot;client and campaign&amp;quot; = c(vFeaturesEconomic, vFeaturesTrend),
                     &amp;quot;+ economic levels&amp;quot; = vFeaturesTrend,
                     &amp;quot;+ economic trends&amp;quot; = vFeaturesEconomic)


# Cycles
########################################################################################################################################################
# The first testing period starts 12 calendar months after the first usable month (June 2008), so that every training
# window has 12 full months. Each cycle tests the next 3 months, and the next cycle starts 3 months later, until the
# last month of the data.
first_month &amp;lt;- min(dfCalls$year_month)
last_month &amp;lt;- max(dfCalls$year_month)
dfCycles &amp;lt;- tibble(test_start = seq(first_month %m+% months(12), last_month, by = &amp;quot;3 months&amp;quot;)) %&amp;gt;%
    mutate(train_start = test_start %m-% months(12),
           train_end = test_start %m-% months(1),
           test_end = test_start %m+% months(2),
           cycle = row_number(),
           train_first = as.Date(NA), train_last = as.Date(NA), test_first = as.Date(NA), test_last = as.Date(NA),
           train_calls = NA_integer_, train_rate = NA_real_, test_calls = NA_integer_, test_rate = NA_real_)

# Preprocessing Recipe
########################################################################################################################################################
# As in Part 2: dummy variables, log of right-skewed features, and removal of single-value predictors and exact linear
# combinations. It is prepared on the training window of each cycle only, and so is the choice of the skewed features.
vFeaturesClientNum &amp;lt;- c(&amp;quot;age&amp;quot;, &amp;quot;campaign&amp;quot;, &amp;quot;previous&amp;quot;)
get_preprocessing_recipe &amp;lt;- function(df) {

    vFeaturesSkew &amp;lt;- df %&amp;gt;%
        select(all_of(vFeaturesClientNum)) %&amp;gt;%
        select_if(function(Z) timeDate::skewness(Z, na.rm = T) &amp;gt; 0.75) %&amp;gt;%
        colnames()

    recipe(y ~ ., data = df) %&amp;gt;%
        update_role(Id, year, year_month, new_role = &amp;quot;ID&amp;quot;) %&amp;gt;%
        step_mutate_at(all_of(vFeaturesSkew), fn = ~ log(. + 1)) %&amp;gt;%
        step_dummy(all_nominal_predictors()) %&amp;gt;%
        step_zv(all_predictors()) %&amp;gt;%
        step_intercept() %&amp;gt;%
        step_lincomb(all_numeric_predictors()) %&amp;gt;%
        step_rm(any_of(&amp;quot;intercept&amp;quot;)) %&amp;gt;%
        prep()
}

# Models and grids
########################################################################################################################################################
class_metrics &amp;lt;- metric_set(roc_auc, pr_auc, mn_log_loss)

get_model_spec &amp;lt;- function(modelType) {
    switch(modelType,
           glmnet = logistic_reg(mode = &amp;quot;classification&amp;quot;, penalty = tune(), mixture = tune()) %&amp;gt;%
               set_engine(&amp;quot;glmnet&amp;quot;),
           xgboost = boost_tree(mode = &amp;quot;classification&amp;quot;, trees = 1000, min_n = tune(), tree_depth = tune(),
                                learn_rate = tune()) %&amp;gt;%
               set_engine(&amp;quot;xgboost&amp;quot;, nthread = 1))
}

# The same grids as in Part 2 (glmnet: 20 penalties x 5 mixtures; xgboost: a space-filling grid over the same ranges),
# with 10 instead of 25 xgboost combinations, because the tuning runs 18 times (6 cycles x 3 feature sets). In a test
# run with 4 xgboost combinations, xgboost already took 9 minutes over the 18 tuning runs. The ranges are not narrowed
# with what Part 2 found, because Part 2 tuned on calls from the whole period (that would be a lookahead).
get_model_param_grid &amp;lt;- function(modelType, randomSeed = 2300) {
    if(modelType == &amp;quot;glmnet&amp;quot;) {
        return(grid_regular(penalty(), mixture(), levels = c(penalty = 20, mixture = 5)))
    }
    set.seed(randomSeed)
    grid_space_filling(parameters(min_n(), tree_depth(), learn_rate(range = c(-2.5, -0.5))), size = 10, type = &amp;quot;max_entropy&amp;quot;)
}

# !! puts the names in vExclude into the workflow itself. Without it, the workflow keeps a reference to the variable,
# which the parallel workers cannot find.
get_workflow &amp;lt;- function(model_spec, vExclude) {
    workflow() %&amp;gt;%
        add_variables(outcomes = y, predictors = c(-Id, -year, -year_month, -any_of(!!vExclude))) %&amp;gt;%
        add_model(model_spec)
}

# Tune a model on the training window with racing, train it with the best combination on the whole window, and
# predict the testing period
tune_fit_predict &amp;lt;- function(dfTrain_Proc, dfTest_Proc, modelType, vExclude, randomSeed = 2300) {

    model_spec &amp;lt;- get_model_spec(modelType)
    set.seed(randomSeed)
    folds &amp;lt;- vfold_cv(dfTrain_Proc, v = 5, strata = y)
    race &amp;lt;- get_workflow(model_spec, vExclude) %&amp;gt;%
        finetune::tune_race_anova(resamples = folds,
                                  grid = get_model_param_grid(modelType, randomSeed),
                                  metrics = class_metrics,
                                  control = finetune::control_race(burn_in = 3, parallel_over = &amp;quot;everything&amp;quot;))
    params_best &amp;lt;- select_best(race, metric = &amp;quot;roc_auc&amp;quot;)
    set.seed(randomSeed)
    model_final &amp;lt;- get_workflow(model_spec, vExclude) %&amp;gt;%
        finalize_workflow(params_best) %&amp;gt;%
        fit(data = dfTrain_Proc)

    list(params = params_best %&amp;gt;% select(-.config),
         cv_roc_auc = show_best(race, metric = &amp;quot;roc_auc&amp;quot;, n = 1)$mean,
         predictions = dfTest_Proc %&amp;gt;%
             select(Id, year_month, y) %&amp;gt;%
             mutate(.pred_yes = predict(model_final, dfTest_Proc, type = &amp;quot;prob&amp;quot;)$.pred_yes))
}


# Parallel backend
########################################################################################################################################################
# As in Part 2: workers are capped at cores - 1 and at what fits in 70% of the available RAM, at 0.75 GB per worker.
gb_per_worker &amp;lt;- 0.75
gb_available &amp;lt;- ps::ps_system_memory()$avail / 1024^3
num_workers &amp;lt;- max(1, min(parallel::detectCores() - 1, floor(0.7 * gb_available / gb_per_worker)))
message(sprintf(&amp;quot;Using %d parallel workers (%.1f GB RAM available)&amp;quot;, num_workers, gb_available))
plan(multisession, workers = num_workers)


# Run the cycles
########################################################################################################################################################
lResults &amp;lt;- list()
dfTimings &amp;lt;- tibble(cycle = integer(), model = character(), feature_set = character(), seconds = numeric())
for (i in seq_len(nrow(dfCycles))) {

    cycle &amp;lt;- dfCycles[i, ]
    dfTrain &amp;lt;- dfCalls %&amp;gt;% filter(year_month &amp;gt;= cycle$train_start, year_month &amp;lt;= cycle$train_end)
    dfTest &amp;lt;- dfCalls %&amp;gt;% filter(year_month &amp;gt;= cycle$test_start, year_month &amp;lt;= cycle$test_end)

    # The testing period must be unseen: every training call comes before every testing call, and no call is in both
    stopifnot(max(dfTrain$year_month) &amp;lt; min(dfTest$year_month),
              length(intersect(dfTrain$Id, dfTest$Id)) == 0)

    # Trend features of the testing calls, relative to the last month of the training window (see &amp;quot;Trend features&amp;quot;)
    reference_month &amp;lt;- dfMonthly %&amp;gt;% filter(year_month == max(dfTrain$year_month))
    dfTest &amp;lt;- dfTest %&amp;gt;%
        mutate(euribor_change = euribor3m - reference_month$euribor_mean,
               employed_change = nr.employed - reference_month$employed_mean,
               prev_month_rate = reference_month$rate)

    preprocessing_recipe &amp;lt;- get_preprocessing_recipe(dfTrain)
    dfTrain_Proc &amp;lt;- bake(preprocessing_recipe, new_data = NULL)
    dfTest_Proc &amp;lt;- bake(preprocessing_recipe, new_data = dfTest)

    for (feature_set in names(lFeatureSets)) {
        for (modelType in c(&amp;quot;glmnet&amp;quot;, &amp;quot;xgboost&amp;quot;)) {
            t0 &amp;lt;- Sys.time()
            result &amp;lt;- tune_fit_predict(dfTrain_Proc, dfTest_Proc, modelType, lFeatureSets[[feature_set]])
            dfTimings &amp;lt;- dfTimings %&amp;gt;% add_row(cycle = cycle$cycle, model = modelType, feature_set = feature_set,
                                               seconds = as.numeric(difftime(Sys.time(), t0, units = &amp;quot;secs&amp;quot;)))
            lResults[[length(lResults) + 1]] &amp;lt;- list(cycle = cycle$cycle, model = modelType, feature_set = feature_set,
                                                     params = result$params, cv_roc_auc = result$cv_roc_auc,
                                                     predictions = result$predictions)
        }
    }
    dfCycles$train_first[i] &amp;lt;- min(dfTrain$year_month)
    dfCycles$train_last[i] &amp;lt;- max(dfTrain$year_month)
    dfCycles$test_first[i] &amp;lt;- min(dfTest$year_month)
    dfCycles$test_last[i] &amp;lt;- max(dfTest$year_month)
    dfCycles$train_calls[i] &amp;lt;- nrow(dfTrain)
    dfCycles$train_rate[i] &amp;lt;- mean(dfTrain$y == &amp;quot;yes&amp;quot;)
    dfCycles$test_calls[i] &amp;lt;- nrow(dfTest)
    dfCycles$test_rate[i] &amp;lt;- mean(dfTest$y == &amp;quot;yes&amp;quot;)
}

plan(sequential)

# predictions for every call of every testing period, by model and feature set
dfSlidingPred &amp;lt;- map_dfr(lResults, ~ .x$predictions %&amp;gt;% mutate(cycle = .x$cycle, model = .x$model, feature_set = .x$feature_set))

# the hyperparameters chosen in each cycle, with their cross validated ROC AUC within the training window
dfCycleParams &amp;lt;- map_dfr(lResults, ~ tibble(cycle = .x$cycle, model = .x$model, feature_set = .x$feature_set,
                                            cv_roc_auc = .x$cv_roc_auc, params = list(.x$params)))

# the results that the post uses, which are saved to Bank_Sliding.Rda
vSlidingResults &amp;lt;- c(&amp;quot;dfSlidingPred&amp;quot;, &amp;quot;dfCycleParams&amp;quot;, &amp;quot;dfCycles&amp;quot;, &amp;quot;dfMonthly&amp;quot;, &amp;quot;dfTimings&amp;quot;, &amp;quot;num_workers&amp;quot;,
                     &amp;quot;vFeaturesEconomic&amp;quot;, &amp;quot;vFeaturesTrend&amp;quot;, &amp;quot;lFeatureSets&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And this code decides whether to load the saved results or to run the sliding windows:&lt;/p&gt;
&lt;pre class=&#34;r&#34;&gt;&lt;code&gt;# The clean dataset and the dates of the calls from Part 1
bank_data_file &amp;lt;- &amp;quot;../bank-marketing-part1-eda/Bank_Marketing_Clean.Rda&amp;quot;

# Fingerprint (hash) of the inputs: the data that the code uses, and the code of the &amp;quot;sliding_steps&amp;quot; chunk.
# Comments and spacing are removed from the code first, so editing them does not start a new run.
envData &amp;lt;- new.env()
load(bank_data_file, envir = envData)
sliding_steps &amp;lt;- knitr::knit_code$get(&amp;quot;sliding_steps&amp;quot;)
sliding_hash &amp;lt;- rlang::hash(list(data = list(envData$dfClean, envData$dfCallDates),
                                 code = deparse(parse(text = sliding_steps, keep.source = FALSE))))

# If the saved results were made from the same inputs, load them. Otherwise, run the sliding windows again and save
# the new results together with the fingerprint of their inputs.
envSaved &amp;lt;- new.env()
if (file.exists(&amp;quot;Bank_Sliding.Rda&amp;quot;)) load(&amp;quot;Bank_Sliding.Rda&amp;quot;, envir = envSaved)
if (identical(envSaved$sliding_hash, sliding_hash)) {
  list2env(as.list(envSaved), envir = environment())
} else {
  eval(parse(text = sliding_steps))
  save(list = c(vSlidingResults, &amp;quot;sliding_hash&amp;quot;), file = &amp;quot;Bank_Sliding.Rda&amp;quot;)
}
rm(envData, envSaved)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
</description>
    </item>
    
  </channel>
</rss>
