<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>R, Econometrics, High Performance</title>
    <link>https://sebkrantz.github.io/Rblog/</link>
    <description>Recent content on R, Econometrics, High Performance</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <lastBuildDate>Mon, 09 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://sebkrantz.github.io/Rblog/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Introducing flownet: Efficient Transport Modeling in R</title>
      <link>https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/</link>
      <pubDate>Mon, 09 Feb 2026 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/</guid>
      <description><![CDATA[ 


<style>
pre.scroll-output {
  max-height: 400px;
  overflow-y: auto;
}
</style>
<p><img src='https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/flownet_logo.png' width="350px" align="right" /></p>
<p>I am excited to introduce a new R package called <a href="https://sebkrantz.github.io/flownet/"><em>flownet</em></a> providing high-performance tools for transport modeling—network processing, route enumeration, and traffic assignment in R. As of now, the package implements the <a href="https://sebkrantz.github.io/flownet/articles/introduction.html#path-sized-logit-model">Path-Sized Logit (PSL) model</a> (Ben-Akiva and Bierlaire, 1999)—a powerful and well-established method for stochastic traffic assignment accounting for route overlap—alongside a novel route enumeration algorithm and a highly efficient All-or-Nothing assignment solution. Furthermore, it provides powerful utility functions for (multimodal) network processing, including recursive graph consolidation/contraction, and/or simplification. These features, combined with an accessible representation of graphs and (sparse) origin-destination (OD) matrices using data frames and a parsimonious API, make it a compelling toolbox for both transport analytics and graph manipulation.</p>
<p>As has come to be expected of my packages, <em>flownet</em> strives to be computationally efficient, building on a whole array of <a href="https://fastverse.org/fastverse/"><em>fastverse</em></a> libraries including <a href="https://fastverse.org/collapse/"><em>collapse</em></a> and <a href="https://fastverse.org/kit/"><em>kit</em></a> for fast data manipulation, <a href="https://igraph.org/"><em>igraph</em></a> for efficient routing, <a href="https://hypertidy.github.io/geodist/"><em>geodist</em></a> and <a href="https://CRAN.R-project.org/package=leaderCluster"><em>leaderCluster</em></a> for spatial distances and clustering, and <a href="https://mirai.r-lib.org/"><em>mirai</em></a> for efficient R-level parallelism. These dependencies are supplemented by custom C code, e.g., the PSL is implemented fully in C. There is still significant room for improvements, particularly if <em>igraph</em> can be replaced with internal C/C++ routing code, which could port the entire route enumeration algorithm to C/C++. Such innovation may be the subject of future updates, alongside new traffic assignment methods.</p>
<p>The package supports directed and undirected graphs and multimodal networks.<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a> In the following I demonstrate <em>flownet</em> on two networks:</p>
<p>The first is the African continental road network I built in my <a href="https://sebastiankrantz.com/research.html#optimal-african-roads">Job Market Paper</a>. It is included in the package. This network is already consolidated and has 1379 nodes and 2344 edges representing 315,000 km of road network. It is thus a rather small network as far as the capabilities of <em>flownet</em> are concerned.</p>
<p>The second network is the <a href="https://faf.ornl.gov/faf5/">US Freight Analysis Framework (FAF5) network</a> comprising 487,384 links (edges) and 348,498 nodes. This network is very large and requires careful tuning of the PSL method. It also provides an excellent test case to demonstrate <em>flownet</em>’s network simplification capabilities.</p>
<div id="example-1-africas-continental-road-network" class="section level2">
<h2>Example 1: Africa’s Continental Road Network</h2>
<p>The <a href="https://sebkrantz.github.io/flownet/reference/africa_network.html"><code>africa_network</code></a>, taken from Krantz (2024) <a href="https://doi.org/10.1596/1813-9450-10893">Optimal Investments in Africa’s Road Network</a>, is included in the package. It was generated by intersecting and simplifying fastest <a href="https://project-osrm.org/">OSRM</a> car routes between 453 African cities with population &gt; 100,000 and international ports—provided in <a href="https://sebkrantz.github.io/flownet/reference/africa_cities_ports.html"><code>africa_cities_ports</code></a>. The procedure is described in the paper, and further demonstrated in the <a href="https://sebkrantz.github.io/flownet/articles/introduction.html#advanced-workflow-with-network-consolidation">introductory vignette</a> using <em>flownet</em>’s network processing tools. For this blog post we will take the network as given and defer the network processing workflow to the second (FAF5) example below.</p>
<p>The network includes 481 new links proposed by a network finding algorithm described in Section 4.2 of the paper. Below I plot the network with proposed links in grey and the 453 (port-)cities in black.</p>
<pre class="r"><code>library(fastverse)
## -- Attaching packages ------------------------------------------------------------------------------------------------------------- fastverse 0.3.4 --
## v data.table 1.17.0     v kit        0.0.21
## v magrittr   2.0.4      v collapse   2.1.6
fastverse_extend(flownet, sf, tmap)
## -- Attaching extension packages --------------------------------------------------------------------------------------------------- fastverse 0.3.4 --
## v flownet 0.2.0      v tmap    4.2   
## v sf      1.0.20
# Plot network colored by travel speed
tm_basemap(&quot;CartoDB.Positron&quot;, zoom = 4) +
tm_shape(africa_network) +
  tm_lines(col = &quot;speed_kmh&quot;,
           col.scale = tm_scale_continuous(values = &quot;turbo&quot;, 
                                           values.range = c(0.1, 0.9), 
                                           value.na = &quot;grey&quot;),
           col.legend = tm_legend(&quot;Speed (km/h)&quot;, position = c(&quot;left&quot;, &quot;bottom&quot;),
                                  frame = FALSE, text.size = 0.8, title.size = 1, 
                                  item.height = 2, na.show = FALSE), lwd = 1.5) +
tm_shape(africa_cities_ports) + tm_dots(size = 0.1) +
tm_layout(frame = FALSE, outer.margins = 0)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/africa-speed-map-1.png" alt="" width="100%" /></p>
<p>Evidently, OSRM link speeds differ widely, with speeds along many central African roads around 40km/h, and higher speeds closer to urban centers. Below, I further describe the network after removing the proposed links. As detailed in Section 3 of the paper, I extracted border and documentary times and costs of exporting and importing from the last World Bank Doing Business Survey (data collected in 2019), and converted them to travel-time-equivalent frictions (in minutes) along border-crossing links. There is thus a frictionless <code>duration</code> (travel time) variable and <code>border_time</code> (which includes documentary time—median 76h), which added together give the <code>total_time</code> needed to trade across a link.</p>
<pre class="r"><code># Removing potential new links
africa_net &lt;- fsubset(africa_network, !add, -add)
names(africa_net) |&gt; cat(sep = &quot;, &quot;)
## from, to, from_ctry, to_ctry, FX, FY, TX, TY, sp_distance, distance, duration, speed_kmh, passes, gravity, gravity_rd, border_dist, total_dist, border_time, total_time, duration_100kmh, total_time_100kmh, rugg, pop_wpop, pop_wpop_km2, cost_km, upgrade_cat, ug_cost_km, geometry
qsu(africa_net, cols = .c(from, to, speed_kmh, distance, duration, border_time, total_time))
##                 N       Mean          SD     Min         Max
## from         2344   669.4347    391.7331       1        1377
## to           2344   688.3921    395.8184       2        1379
## speed_kmh    2344    65.2302     16.0805  5.1429     127.749
## distance     2344  134411.13  115553.485   16968  1&#39;801830.5
## duration     2344    135.585    136.9445   12.05      2151.9
## border_time  2344   687.2841   1910.1195       0  11871.9998
## total_time   2344    822.869   1947.0973   12.05  12134.6998</code></pre>
<p>The package also provides, in <code>africa_trade</code>, a dataset of bilateral trade flows between the 47 African countries that accommodate any of the 453 selected (port-)cities. It is derived from the <a href="https://www.cepii.fr/CEPII/en/bdd_modele/bdd_modele_item.asp?id=37">CEPII BACI database</a>, HS96 version, averaged over 2012-2022 by 21 HS sections.</p>
<pre class="r"><code># Convert to tibble for compact printing of section names
qTBL(africa_trade)
## # A tibble: 27,721 × 8
##    iso3_o iso3_d section_code section_name                                                                         hs2_codes   value value_kd quantity
##    &lt;fct&gt;  &lt;fct&gt;         &lt;int&gt; &lt;fct&gt;                                                                                &lt;fct&gt;       &lt;dbl&gt;    &lt;dbl&gt;    &lt;dbl&gt;
##  1 AGO    BDI              16 Machinery and mechanical appliances, electrical equipment, parts thereof, sound rec… 84        5.10e+0    5.10   9.87e-1
##  2 AGO    BEN               1 Live animals and animal products                                                     3         6.81e+3 6527.     8.78e+3
##  3 AGO    BEN               4 Prepared foodstuffs, beverages, spirits and vinegar, tobacco and manufactured tobac… 19, 20, … 9.51e+0    9.10   2.07e+1
##  4 AGO    BEN               5 Mineral products                                                                     27        3.58e+3 3536.     8.37e+3
##  5 AGO    BEN               6 Product of the chemicals or allied industries                                        30, 32, … 9.44e-1    0.932  2.75e-2
##  6 AGO    BEN               7 Plastics and articles thereof, rubber and articles thereof                           39, 40    7.13e+1   72.0    3.13e+1
##  7 AGO    BEN               8 Raw hides and skins, leather, furskins and articles thereof, saddlery and harness, … 42        1.18e+0    1.17   2.5 e-2
##  8 AGO    BEN               9 Wood and articles of wood, wood charcoal, cork and articles of cork, manufacturers … 44, 46    1.5 e-1    0.140  3.2 e-2
##  9 AGO    BEN              10 Pulp of wood or of other fibrous cellulosic material, recovered (waste and scrap) p… 48, 49    8.9 e-1    0.863  4.36e-1
## 10 AGO    BEN              11 Textile and textile articles                                                         52, 57, … 3.00e+1   29.6    1.08e+1
## # ℹ 27,711 more rows</code></pre>
<p>Our exercise will be to assign these trade flows, in particular the <code>quantity</code> in metric tons, to the network. While certainly not all intra-African trade travels by road, <a href="https://www.uneca.org/the-african-continental-free-trade-area-and-demand-for-transport-infrastructure-and-services">UNECA 2022</a> estimate, in their 2019 baseline scenario, that 76.7% of intra-African freight traveled by road, followed by 22.1% maritime, 0.9% air, and only 0.3% by rail. Thus, focusing on the road network alone is not a horrible simplification. For simplicity, we will aggregate the trade data across sections and consider assignments with or without trade frictions and the 481 proposed new links.</p>
<pre class="r"><code># Aggregating the trade data across HS sections
africa_trade_agg &lt;- africa_trade |&gt; 
  collap(quantity ~ iso3_o + iso3_d, fsum)</code></pre>
<p>The Africa network is already in a graph format with <code>from</code> and <code>to</code> node columns. Thus, all we need to do here to get the representation <em>flownet</em> expects is drop the geometry. Next, we need to map the (port-)city locations to the nearest network nodes.</p>
<pre class="r"><code># Convert to graph
graph &lt;- st_drop_geometry(africa_net)
# Extract nodes with spatial coordinates
nodes &lt;- nodes_from_graph(graph, sf = TRUE)
head(nodes, 3)
## Simple feature collection with 3 features and 1 field
## Geometry type: POINT
## Dimension:     XY
## Bounding box:  xmin: -17.44671 ymin: 14.69281 xmax: -16.63849 ymax: 15.11222
## Geodetic CRS:  WGS 84
##   node                   geometry
## 1    1 POINT (-17.44671 14.69281)
## 2    2 POINT (-17.04453 14.70297)
## 3    3 POINT (-16.63849 15.11222)

# Map (port-)cities to the nearest nodes
nearest_nodes &lt;- nodes$node[st_nearest_feature(africa_cities_ports, nodes)]</code></pre>
<p>To construct an OD matrix for assignment to this network, we need to disaggregate the country-level trade flows using (port-)city population shares. The code below demonstrates this procedure, resulting in an OD matrix with columns <code>from</code>, <code>to</code>, and <code>flow</code> (in metric tons) at the (port-)city level.</p>
<pre class="r"><code># Compute each city&#39;s share of its country&#39;s population
city_pop &lt;- st_drop_geometry(africa_cities_ports) |&gt;
  fcompute(node = nearest_nodes, city = qF(city_country),
           pop_share = fsum(population, iso3, TRA = &quot;/&quot;), keep = &quot;iso3&quot;)
head(city_pop, 3)
##      iso3  node                        city pop_share
##    &lt;char&gt; &lt;int&gt;                      &lt;fctr&gt;     &lt;num&gt;
## 1:    EGY   937               Cairo - Egypt 0.6554678
## 2:    NGA   289             Lagos - Nigeria 0.3860030
## 3:    COD   635 Kinshasa - Congo (Kinshasa) 0.4784988

# Join with city population shares for origin and destination
# add_stub adds suffix to all columns, so iso3 -&gt; iso3_o matches africa_trade_agg$iso3_o
od_matrix_trade &lt;- africa_trade_agg |&gt;
  join(city_pop |&gt; add_stub(&quot;_o&quot;, FALSE), multiple = TRUE) |&gt;
  join(city_pop |&gt; add_stub(&quot;_d&quot;, FALSE), multiple = TRUE) |&gt;
  fmutate(flow = quantity * pop_share_o * pop_share_d) |&gt;
  frename(from = node_o, to = node_d) |&gt;
  fsubset(flow &gt; 0 &amp; from != to)
## left join: africa_trade_agg[iso3_o] 1971/1971 (100%) &lt;41.94:9.62&gt; y[iso3_o] 452/453 (99.8%)
## left join: x[iso3_d] 19685/19685 (100%) &lt;418.83:9.62&gt; y[iso3_d] 452/453 (99.8%)
nrow(od_matrix_trade)
## [1] 189020
od_matrix_trade |&gt; fselect(from:flow) |&gt; head(3)
##     from           city_o pop_share_o    to              city_d pop_share_d       flow
##    &lt;int&gt;           &lt;fctr&gt;       &lt;num&gt; &lt;int&gt;              &lt;fctr&gt;       &lt;num&gt;      &lt;num&gt;
## 1:   577  Luanda - Angola  0.53616303   962 Bujumbura - Burundi           1 0.52919291
## 2:   550 Lubango - Angola  0.03794550   962 Bujumbura - Burundi           1 0.03745221
## 3:   540 Cabinda - Angola  0.03202289   962 Bujumbura - Burundi           1 0.03160660</code></pre>
<p>We are now ready to assign traffic using the <a href="https://sebkrantz.github.io/flownet/articles/introduction.html#path-sized-logit-model">PSL model</a>, which is straightforward using <code>run_assignment()</code>. To increase performance, I set <code>nthreads = 8</code> to use 8 cores on this M4 Pro chip. R-level parallelism using <em>mirai</em> is highly efficient, but requires additional memory. In this example we also save all intermediate results, including individual path alternatives and associated probabilities, by setting <code>return.extra = "all"</code>. This can get very memory-intensive, especially if there are many plausible paths per OD pair. Thus, we limit the number of paths per OD-pair to 150 by setting <code>npaths.max = 150</code>. Another crucial step for PSL modelling is cost-scaling. Multinomial logits, of which the PSL is a penalized variant, do not deal well with very large path-costs. Thus, mean or median scaling of the generalized cost (dividing all data points by the mean or median) is good practice. We will later also need to calibrate the PSL parameter <code>beta</code> (default 1) to achieve the desired level of penalization of overlapping paths. But first, we conduct a basic dry run of the model and explore the outputs.</p>
<pre class="r"><code># Median scaling cost column: recommended for PSL
settfm(graph, duration_ms = fmedian(duration, TRA = &quot;/&quot;))
# Run Traffic Assignment using the PSL model
system.time({
result &lt;- run_assignment(graph, od_matrix_trade, cost.column = &quot;duration_ms&quot;,
            nthreads = 8, npaths.max = 150, return.extra = &quot;all&quot;, verbose = FALSE)
})
##    user  system elapsed 
##  32.782   8.887  39.307
print(result)
## FlowNet object
## Call: run_assignment(graph_df = graph, od_matrix_long = od_matrix_trade,      cost.column = &quot;duration_ms&quot;, npaths.max = 150, return.extra = &quot;all&quot;,      verbose = FALSE, nthreads = 8) 
## 
## Number of nodes: 1379 
## Number of edges: 2344 
## Number of simulations/OD-pairs: 189020 
## 
## Average number of paths per simulation (SD): 79.42657  (30.90874)
## Average number of edges utilized per simulation (SD): 436.6385  (251.9788)
## Average number of visits per edge (SD): 7.879177  (13.34574)
## Average path cost (SD): 53.4557  (5.859316)
## Average path-size factors (SD): 0.1437334  (0.07117963)
## Average path weight (SD): 0.02389012  (0.03021725)
## Average summed edge weight (SD): 0.108716  (0.2317558)
## 
## Final flows summary statistics:
##      N  Ndist         Mean           SD  Min          Max  Skew  Kurt
##   2344   2322  1&#39;347742.43  2&#39;591303.02    0  20&#39;806237.1  3.52  17.4
##        1%       5%       10%       25%        50%          75%          90%          95%          99%
##   1048.96  9720.13  27794.68  93975.69  398554.56  1&#39;169703.79  3&#39;757161.33  6&#39;509143.87  13&#39;062843.5</code></pre>
<p>Processing OD-pairs at a rate of around 6000 per second is quite fast, given that on average 80 alternative paths are retained per OD-pair—i.e., we are generating 0.5 million paths per second. Overall, we generated 15 million paths stored in <code>result$paths</code>, yielding an object more than 5 GB in size.</p>
<pre class="r"><code>str(result, max.level = 1)         # Overview of results objects
## List of 11
##  $ call             : language run_assignment(graph_df = graph, od_matrix_long = od_matrix_trade, cost.column = &quot;duration_ms&quot;, npaths.max = 150,| __truncated__
##  $ graph            :Class &#39;igraph&#39;  hidden list of 10
##  $ final_flows      : num [1:2344] 6071643 966528 907662 4289932 349595 ...
##  $ od_pairs_used    : int [1:189020] 1 2 3 4 5 6 7 8 9 10 ...
##  $ paths            :List of 189020
##  $ path_costs       :List of 189020
##  $ path_size_factors:List of 189020
##  $ path_weights     :List of 189020
##  $ edges            :List of 189020
##  $ edge_counts      :List of 189020
##  $ edge_weights     :List of 189020
##  - attr(*, &quot;class&quot;)= chr &quot;flownet&quot;
format(object.size(result), &quot;Mb&quot;)  # Object size
## [1] &quot;5317.7 Mb&quot;
sum(vlengths(result$paths))        # Total Number of paths
## [1] 15013210
Qprobs &lt;- c(0, 0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99, 1)
quantile(vlengths(result$paths), Qprobs) # Number of paths distribution
##   0%   1%   5%  25%  50%  75%  95%  99% 100% 
##    1    4   15   61   92  102  112  118  131</code></pre>
<p>The default, <code>return.extra = NULL</code>, would have only returned the final flows vector equal in length to the number of edges (2344). In general, <code>return.extra = "paths"</code>, responsible for the biggest memory bloat (the object without <code>"paths"</code> is only 1.5 GB), is hardly ever needed.</p>
<p>The print method above shows, among other things, the average degree of overlap between paths in terms of visits per edge, computed as <code>mean(fmean(result$edge_counts)) = 7.87</code>, the average adjusted path probability as <code>mean(fmean(result$path_weights)) = 0.024</code>, as well as the path probabilities summed across each edge as <code>mean(fmean(result$edge_weights)) = 0.11</code>. We can use this information to visualize the paths and their probabilities for a specific OD-pair.</p> 
<p>Let’s take an iconic Cape Town to Cairo (C2C) routing example:</p>
<pre class="r"><code># See which OD-pair is Cape Town to Cairo
c2c &lt;- od_matrix_trade |&gt; 
  with(which(city_o %like% &quot;Cape Town&quot; &amp; city_d %like% &quot;Cairo&quot;))
od_matrix_trade[c2c, ]
##    iso3_o iso3_d quantity  from                   city_o pop_share_o    to        city_d pop_share_d     flow
##    &lt;fctr&gt; &lt;fctr&gt;    &lt;num&gt; &lt;int&gt;                   &lt;fctr&gt;       &lt;num&gt; &lt;int&gt;        &lt;fctr&gt;       &lt;num&gt;    &lt;num&gt;
## 1:    ZAF    EGY 974865.4   699 Cape Town - South Africa   0.2271359   937 Cairo - Egypt   0.6554678 145138.2
# In this case all OD-pairs are used, otherwise need to adjust index
length(result$od_pairs_used) == nrow(od_matrix_trade)
## [1] TRUE
# Extract results for Cape Town to Cairo
cost &lt;- graph$duration_ms
c2c_paths &lt;- result$paths[[c2c]]
c2c_edges &lt;- result$edges[[c2c]] # !! C to R bug in versions &lt;= 0.1.2: need to subtract 1 !!
c2c_ecounts &lt;- result$edge_counts[[c2c]]
c2c_eweights &lt;- result$edge_weights[[c2c]]
c2c_pcosts &lt;- result$path_costs[[c2c]]
c2c_PSF &lt;- result$path_size_factors[[c2c]]
length(c2c_pweights &lt;- result$path_weights[[c2c]]) # Number of paths generated/retained
## [1] 96</code></pre>
<p>Let’s start by running a number of tests to validate that the software computes the path probabilities and costs correctly. The path cost is computed as the sum of link costs along the path.</p>
<pre class="r"><code>all.equal(c2c_pcosts, fsum(lapply(c2c_paths, \(x) cost[x]))) # Test path cost computation
## [1] TRUE</code></pre>
<p>The path probabilities are computed using the sum of the log-path-size factors (PSFs) (which account for path overlap) and the negative path cost. The code below tests that these computations are correct, and also shows how to compute the PSFs from the paths, costs, and edge counts.</p>
<pre class="r"><code># Compute path-size factors
PSF &lt;- sapply(c2c_paths, \(x) sum(cost[x] / c2c_ecounts[match(x, c2c_edges)])) / c2c_pcosts
all.equal(PSF, c2c_PSF)
## [1] TRUE
quantile(c2c_pcosts, Qprobs)
##        0%        1%        5%       25%       50%       75%       95%       99%      100% 
##  80.70831  80.84662  81.23735  84.36138  89.11926 102.08080 108.97703 118.06345 119.18069
quantile(PSF, Qprobs)
##         0%         1%         5%        25%        50%        75%        95%        99%       100% 
## 0.02408269 0.02985957 0.04826749 0.08517544 0.10678103 0.15676674 0.24896484 0.30524840 0.33583496
quantile(log(PSF), Qprobs)
##        0%        1%        5%       25%       50%       75%       95%       99%      100% 
## -3.726262 -3.512376 -3.031051 -2.463042 -2.236975 -1.852997 -1.390452 -1.186878 -1.091135
quantile(log(PSF) / -c2c_pcosts, Qprobs) # Adjustment amount with default beta = 1
##         0%         1%         5%        25%        50%        75%        95%        99%       100% 
## 0.01003899 0.01258266 0.01388331 0.01872674 0.02279938 0.02859784 0.03630842 0.04338826 0.04616949</code></pre>
<p>The quantiles show that log(PSF) ranges between -3.7 and -1.1, whereas the cost is between 80.7 and 119, which yields a median ratio of 2.3%. This is likely too weak a corrective effect; practitioners usually aim for the PSF penalty to represent roughly 10% to 20% of the utility for heavily overlapped paths. That should approximately be the case when setting <code>beta = 4</code>:</p>
<pre class="r"><code>quantile(4*log(PSF) / -c2c_pcosts, Qprobs)
##         0%         1%         5%        25%        50%        75%        95%        99%       100% 
## 0.04015597 0.05033063 0.05553323 0.07490697 0.09119751 0.11439135 0.14523370 0.17355304 0.18467798
# Testing on all paths
fmedian(t(mapply(\(x, y) fquantile(4*log(x)/-y), result$path_size_factors, result$path_costs)))
##         0%        25%        50%        75%       100% 
## 0.06814703 0.14517954 0.18032579 0.21843395 0.32888192</code></pre>
<p>The median correction across all paths is a bit higher, but long paths like C2C are well penalized, and I expect traders to be more optimizing along shorter paths, thus a value of <code>beta = 4</code> is agreeable. We will then need to re-estimate the model with this value of <code>beta</code>. However, before doing so, let’s do a final test that the software computes the path probabilities correctly.</p>
<pre class="r"><code># Test that software computes probabilities correctly (beta = 1)
all.equal(proportions(exp(-c2c_pcosts + log(c2c_PSF))) , c2c_pweights) 
## [1] TRUE</code></pre>
<p>Now let’s run the calibrated model, not returning paths or restricting the number of paths this time:</p>
<pre class="r"><code># Run Traffic Assignment using the calibrated PSL model - omitting paths this time to save memory
system.time({
result &lt;- run_assignment(graph, od_matrix_trade, 
            cost.column = &quot;duration_ms&quot;, beta = 4, nthreads = 8, verbose = FALSE,
            return.extra = c(&quot;edges&quot;, &quot;counts&quot;, &quot;costs&quot;, &quot;weights&quot;, &quot;eweights&quot;))
})
##    user  system elapsed 
##  25.507   5.625  31.061
print(result)
## FlowNet object
## Call: run_assignment(graph_df = graph, od_matrix_long = od_matrix_trade,      cost.column = &quot;duration_ms&quot;, beta = 4, return.extra = c(&quot;edges&quot;,          &quot;counts&quot;, &quot;costs&quot;, &quot;weights&quot;, &quot;eweights&quot;), verbose = FALSE,      nthreads = 8) 
## 
## Number of edges: 2344 
## Number of simulations/OD-pairs: 189020 
## 
## Average number of edges utilized per simulation (SD): 581.9307  (432.7984)
## Average number of visits per edge (SD): 11.26684  (22.75359)
## Average path cost (SD): 53.48491  (5.858939)
## Average path weight (SD): 0.02091092  (0.04234497)
## Average summed edge weight (SD): 0.09600654  (0.2067006)
## 
## Final flows summary statistics:
##      N  Ndist         Mean           SD  Min          Max  Skew   Kurt
##   2344   2322  1&#39;330454.53  2&#39;399186.13    0  17&#39;965431.2  3.34  15.91
##        1%        5%       10%        25%        50%          75%          90%          95%          99%
##   1042.41  14289.95  33555.03  120524.42  403739.31  1&#39;310539.57  3&#39;711287.11  6&#39;144711.81  12&#39;818351.2</code></pre>
<p>And let’s look again at the Cape Town to Cairo routes.</p>
<pre class="r"><code>c2c_edges &lt;- result$edges[[c2c]] # !! C to R Bug in versions &lt;= 0.1.2: need to subtract 1 !!
c2c_ecounts &lt;- result$edge_counts[[c2c]]
c2c_pcosts &lt;- result$path_costs[[c2c]]
c2c_eweights &lt;- result$edge_weights[[c2c]]
quantile(c2c_pweights &lt;- result$path_weights[[c2c]], Qprobs) |&gt; round(4)
##     0%     1%     5%    25%    50%    75%    95%    99%   100% 
## 0.0000 0.0000 0.0000 0.0000 0.0000 0.0008 0.0125 0.0276 0.2721</code></pre>
<p>We can plot the paths in various ways. Let’s start by visualizing the edge counts:</p>
<pre class="r"><code>africa_net$c2c_ecounts[c2c_edges] &lt;- c2c_ecounts
tm_basemap(&quot;CartoDB.Positron&quot;, zoom = 4) +
tm_shape(africa_net) +
  tm_lines(col = &quot;c2c_ecounts&quot;,
           col.scale = tm_scale_continuous_sqrt(5, values = &quot;turbo&quot;, 
                                           values.range = c(0.1, 0.9), 
                                           value.na = &quot;grey&quot;),
           col.legend = tm_legend(&quot;Edge Visits&quot;, position = c(&quot;left&quot;, &quot;bottom&quot;),
                                  frame = FALSE, text.size = 0.8, title.size = 1, 
                                  item.height = 2, na.show = FALSE), lwd = 1.5) +
tm_shape(africa_cities_ports) + tm_dots(size = 0.1) +
tm_layout(frame = FALSE, outer.margins = 0)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/c2c-edge-counts-map-1.png" alt="" width="100%" /></p>
<p>As this plot makes evident, by virtue of the various edge-level statistics <code>run_assignment()</code> can return, it is not necessary to retain the full paths to fully understand the model.</p>
<p>Now, let’s also plot the probabilities. The <code>edge_weights</code> returned by the model represent the sum of path probabilities over all paths using each edge. Thus, in the context of probabilistic assignment, they can be interpreted as the proportion of C2C trade that traverses the edge. For additional clarity, let’s highlight only those edges where the probability is above 0.0001.</p>
<pre class="r"><code>africa_net$c2c_eweights[c2c_edges] &lt;- replace_outliers(c2c_eweights, 0.0001, NA, &quot;min&quot;)
tm_basemap(&quot;CartoDB.Positron&quot;, zoom = 4) +
tm_shape(africa_net) +
  tm_lines(col = &quot;c2c_eweights&quot;,
           col.scale = tm_scale_continuous_sqrt(6, values = &quot;turbo&quot;, limits = c(0, 1),
                                           values.range = c(0.1, 0.9), 
                                           value.na = &quot;grey&quot;),
           col.legend = tm_legend(&quot;Edge Probabilities&quot;, position = c(&quot;left&quot;, &quot;bottom&quot;),
                                  frame = FALSE, text.size = 0.8, title.size = 1, 
                                  item.height = 2, na.show = FALSE), lwd = 1.5) +
tm_shape(africa_cities_ports) + tm_dots(size = 0.1) +
tm_layout(frame = FALSE, outer.margins = 0)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/c2c-edge-probs-map-1.png" alt="" width="100%" /></p>
<p>Clearly, and in contrast to what the edge counts from the raw routes suggested, no meaningful C2C traffic is routed through central Africa or the Sahara.</p>
<p>The model thus seems well-behaved, and we can plot the final flows on the entire network:</p>
<pre class="r"><code>africa_net$final_flows &lt;- result$final_flows
tm_basemap(&quot;CartoDB.Positron&quot;, zoom = 4) +
tm_shape(africa_net) +
  tm_lines(col = &quot;final_flows&quot;,
           col.scale = tm_scale_continuous_sqrt(values = &quot;turbo&quot;, 
                                           values.range = c(0.1, 0.9), 
                                           value.na = &quot;grey&quot;),
           col.legend = tm_legend(&quot;Frictionless Flows&quot;, position = c(&quot;left&quot;, &quot;bottom&quot;),
                                  frame = FALSE, text.size = 0.8, title.size = 1, 
                                  item.height = 2, na.show = FALSE), lwd = 1.5) +
tm_shape(africa_cities_ports) + tm_dots(size = 0.1) +
tm_layout(frame = FALSE, outer.margins = 0)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/frictionless-flows-map-1.png" alt="" width="100%" /></p>
<p>Evidently, certain trade routes are much more utilized than others. But this, of course, assumes the frictionless scenario. Let’s now see how the picture changes with the realistic trade costs added:</p>
<pre class="r"><code># Median scaling cost column: recommended for PSL
settfm(graph, total_time_ms = fmedian(total_time, TRA = &quot;/&quot;))
# Run Traffic Assignment using the PSL model
system.time({
result_tc &lt;- run_assignment(graph, od_matrix_trade, cost.column = &quot;total_time_ms&quot;, 
                            beta = 4, nthreads = 8, verbose = FALSE)
})
##    user  system elapsed 
##  25.675   2.666  27.640
print(result_tc)
## FlowNet object
## Call: run_assignment(graph_df = graph, od_matrix_long = od_matrix_trade,      cost.column = &quot;total_time_ms&quot;, beta = 4, verbose = FALSE,      nthreads = 8) 
## 
## Number of edges: 2344 
## Number of simulations/OD-pairs: 189020 
## 
## Final flows summary statistics:
##      N  Ndist         Mean           SD  Min          Max  Skew   Kurt
##   2344   2314  1&#39;401715.01  3&#39;019114.05    0  32&#39;492559.6  3.86  21.47
##   1%       5%       10%       25%       50%          75%       90%          95%          99%
##    0  3035.57  11555.93  55039.95  273655.4  1&#39;140869.45  3&#39;780896  7&#39;276797.09  16&#39;361834.1</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/friction-flows-map-1.png" alt="" width="100%" /></p>
<p>This result is quite fascinating as it suggests that, if the Doing Business 2019 Trade Frictions are accurate and traders are rational, these frictions could strongly distort intra-African trade patterns from the efficient equilibrium determined by the quality of infrastructure. This could be further investigated...</p>
<p>Speaking about infrastructure quality, what if the existing network were fully upgraded? If all roads had a minimum travel speed of 100km/h? The network includes a column <code>duration_100kmh</code> simulating just that. The median link under this scenario has a travel time of only 65% of the current travel time.</p>
<pre class="r"><code>quantile(africa_net$duration_100kmh / africa_net$duration, Qprobs)
##         0%         1%         5%        25%        50%        75%        95%        99%       100% 
## 0.05142949 0.31306222 0.39842537 0.53691407 0.64688128 0.80874861 0.85202685 0.94515228 1.00000000
settfm(graph, duration_100kmh_ms = fmedian(duration_100kmh, TRA = &quot;/&quot;))
result_ug &lt;- run_assignment(graph, od_matrix_trade, cost.column = &quot;duration_100kmh_ms&quot;, 
                            beta = 4, nthreads = 8, verbose = FALSE)
print(result_ug)
## FlowNet object
## Call: run_assignment(graph_df = graph, od_matrix_long = od_matrix_trade,      cost.column = &quot;duration_100kmh_ms&quot;, beta = 4, verbose = FALSE,      nthreads = 8) 
## 
## Number of edges: 2344 
## Number of simulations/OD-pairs: 189020 
## 
## Final flows summary statistics:
##      N  Ndist         Mean           SD  Min          Max  Skew   Kurt
##   2344   2312  1&#39;299050.05  2&#39;196434.83    0  15&#39;383521.6  3.45  16.51
##        1%        5%       10%        25%        50%          75%          90%          95%          99%
##   4404.87  27001.59  67602.42  192133.57  537029.72  1&#39;379204.11  3&#39;069293.25  5&#39;558163.65  12&#39;401186.9</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/upgraded-flows-map-1.png" alt="" width="100%" /></p>
<p>The plot appears similar to the previous frictionless plot, with a bit more traffic routed through central Africa and the Sahara where at present the road quality is very poor. However, given the present low volume of trade between Eastern and Western Africa (less than 1% of total intra-African trade), paving roads in central Africa wouldn’t pay off. Such investments thus appear hinged on changing regional trade patterns, and, as the <a href="https://sebastiankrantz.com/research.html#optimal-african-roads">paper finds</a> using a generative gravity-like spatial equilibrium model that does allow trade flows to adjust to infrastructure changes, on unrealistically low trade elasticities.</p>
<p>Lastly, we can simulate flows on the network with the proposed links added, and see how much they would be utilized. These links, although drawn as straight lines, are assumed to be 1.2 times longer than the line, which is the average across existing links when comparing them to a straight line.</p>
<pre class="r"><code>qDT(africa_network)[add == TRUE, quantile(distance / sp_distance)]
##      0%     25%     50%     75%    100% 
## 1.20812 1.20812 1.20812 1.20812 1.20812</code></pre>
<p>They are given a speed of 100km/h, and I will also assume the existing network was upgraded first.</p>
<pre class="r"><code>graph_total &lt;- st_drop_geometry(africa_network)
settfm(graph_total, duration_100kmh_ms = fmedian(duration_100kmh, TRA = &quot;/&quot;))
result_add &lt;- run_assignment(graph_total, od_matrix_trade, 
                             cost.column = &quot;duration_100kmh_ms&quot;, 
                             beta = 4, nthreads = 8, verbose = FALSE)
print(result_add)
## FlowNet object
## Call: run_assignment(graph_df = graph_total, od_matrix_long = od_matrix_trade,      cost.column = &quot;duration_100kmh_ms&quot;, beta = 4, verbose = FALSE,      nthreads = 8) 
## 
## Number of edges: 2825 
## Number of simulations/OD-pairs: 189020 
## 
## Final flows summary statistics:
##      N  Ndist       Mean           SD  Min          Max  Skew  Kurt
##   2825   2789  943826.09  1&#39;718125.92    0  16&#39;443663.3  4.22  25.5
##   1%        5%       10%        25%        50%     75%          90%         95%          99%
##    0  14333.81  34879.15  121091.46  361495.66  980800  2&#39;313254.89  3&#39;765464.8  9&#39;265394.48</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/total-flows-map-1.png" alt="" width="100%" /></p>
<p>The results suggest that some new national links, e.g., in Botswana, additional trans-Saharan links, and reopening the Morocco-Algeria border, could be utilized significantly under current trade patterns.</p>
<p>Let me note again that all of this assumes intra-African trade patterns are static, which of course they are not, as infrastructure improvements can alter trade flows. The joint endogeneity of infrastructure and trade is appropriately handled <a href="https://sebastiankrantz.com/research.html#optimal-african-roads">in the paper</a>, building on <a href="https://doi.org/10.3982/ECTA15213">Fajgelbaum and Schaal (2020)</a>.</p>
</div>
<div id="example-2-the-us-freight-analysis-framework-faf-network" class="section level2">
<h2>Example 2: The US Freight Analysis Framework (FAF) Network</h2>
<p>The <a href="https://www.bts.gov/faf">Freight Analysis Framework (FAF)</a>, produced jointly by the Bureau of Transportation Statistics (BTS) and the Federal Highway Administration (FHWA), integrates data from a variety of sources—most notably the Commodity Flow Survey—to create a comprehensive picture of freight movement among US states and major metropolitan areas by all modes of transportation. It provides estimates of tonnage, value, and domestic ton-miles by origin-destination pairs, commodity type, and transportation mode, along with 30-year forecasts through 2050 under different economic growth scenarios.</p>
<p>The latest version, <a href="https://faf.ornl.gov/faf5/">FAF5</a>, is benchmarked on the 2017 Commodity Flow Survey and includes a <a href="https://catalog.data.gov/dataset/freight-analysis-framework-faf5-network-links1">model highway network</a> with 487,384 links and <a href="https://catalog.data.gov/dataset/freight-analysis-framework-faf5-network-nodes1">348,498 nodes</a>. The links cover all roads in the National Highway System (NHS) and the National Highway Freight Network (NHFN) that are currently open to traffic. FAF5 also provides truck flow assignments that route commodity movements onto this highway network. An update to FAF6, benchmarked on the 2022 Commodity Flow Survey, is expected in 2026.</p>
<p>We begin by loading the network links from a geodatabase using <code>st_read()</code>.</p>
<pre class="r"><code># Loading FAF5 network from geodatabase format
FAF5 &lt;- st_read(FAF5_path, layer = &quot;FAF5_Links&quot;)
## Reading layer `FAF5_Links&#39; from data source `/Users/sebastiankrantz/Documents/CPCS/FAF5/Networks/Geodatabase Format/FAF5Network.gdb&#39; using driver `OpenFileGDB&#39;
## Simple feature collection with 487394 features and 52 fields
## Geometry type: MULTILINESTRING
## Dimension:     XY
## Bounding box:  xmin: -167.0152 ymin: 18.91474 xmax: -66.98005 ymax: 71.3198
## Geodetic CRS:  NAD83
nrow(FAF5)
## [1] 487394
table(vlengths(FAF5$SHAPE))
## 
##      1 
## 487394
head(FAF5, 1)
## Simple feature collection with 1 feature and 52 fields
## Geometry type: MULTILINESTRING
## Dimension:     XY
## Bounding box:  xmin: -132.9754 ymin: 56.80636 xmax: -132.975 ymax: 56.80701
## Geodetic CRS:  NAD83
##   ID     LENGTH DIR    DATA  VERSION Class               Class_Description                    Road_Name Sign_Rte Rte_Type Rte_Number Rte_Qualifier
## 1  1 0.05408278   1 1324805 V2021.05    19 Facility Access/Circulator Road PETERSBURG FERRY TERMINAL RD     &lt;NA&gt;     &lt;NA&gt;       &lt;NA&gt;          &lt;NA&gt;
##   Country STATE STFIPS County_Name CTFIPS Urban_Code FAFZONE Status F_Class Facility_Type NHS STRAHNET NHFN Truck AB_Lanes BA_Lanes Speed_Limit
## 1     USA    AK     02  PETERSBURG  02195      99999      20      1       5            NA  NA       NA   NA  &lt;NA&gt;        1       NA          10
##   Toll_Type Toll_Name Toll_Link Toll_Link_Name HPMS_USA_RouteID HPMS_Begin_Point HPMS_End_Point BorderState1 BorderState2 BorderFAF1 BorderFAF2
## 1        NA      &lt;NA&gt;        NA           &lt;NA&gt;             &lt;NA&gt;               NA             NA         &lt;NA&gt;         &lt;NA&gt;         NA         NA
##   TRUCKTOLL BorderLink AddedBorderTime AdjustSpeed AdjustReason AB_FinalSpeed BA_FinalSpeed AB_CombinedSpeed BA_CombinedSpeed AB_FreeFlowTime
## 1        NA         NA              NA          NA         &lt;NA&gt;            43            43               43               43      0.07546435
##   BA_FreeFlowTime SHAPE_Length                          SHAPE
## 1      0.07546435  0.001008685 MULTILINESTRING ((-132.975 ...</code></pre>
<p>The FAF5 network is large and spatially accurate—too large to visualize in R—but you can view it <a href="https://www.arcgis.com/apps/mapviewer/index.html?url=https://services.arcgis.com/xOi1kZaI0eWDREZv/ArcGIS/rest/services/NTAD_Freight_Analysis_Framework_Network_Links/FeatureServer/0&amp;source=sd">online</a>.</p>
<p>The <code>SHAPE</code> column contains <code>MULTILINESTRING</code> geometries, but, as <code>table(vlengths(FAF5$SHAPE))</code> confirms, all are of length 1 and can be cast to simple <code>LINESTRING</code>s. This is required by <code>flownet::linestrings_to_graph()</code>, which matches start and end points across linestrings to build a graph, subject to a numerical tolerance (by default, 6-digit rounding <span class="math inline">\(\approx\)</span> 11cm at the equator). To ensure this rounding is properly applied, we also transform the coordinates to WGS84 (EPSG:4326).</p>
<pre class="r"><code>graph &lt;- FAF5 |&gt; st_cast(&quot;LINESTRING&quot;) |&gt; st_transform(4326) |&gt; 
    linestrings_to_graph()
nrow(graph)
## [1] 487394
graph[1:5, 1:10]
##   edge from        FX       FY to        TX       TY         .length ID      LENGTH
## 1    1    1 -132.9750 56.80636  2 -132.9750 56.80701    86.86074 [m]  1  0.05408278
## 2    2    2 -132.9750 56.80701  4 -132.9762 56.80849   184.49595 [m]  2  0.11485356
## 3    3    2 -132.9750 56.80701  3 -132.9746 56.80664    52.08027 [m]  3  0.03243200
## 4    4    3 -132.9746 56.80664  6 -132.9601 56.80941  1004.86210 [m]  4  0.62628710
## 5    5    4 -132.9762 56.80849 10 -132.6638 56.71238 24432.32444 [m]  5 15.22300434
# &#39;edge&#39; is always unique
any_duplicated(graph$edge)
## [1] FALSE
# But there may be duplicate edges between the same from-to nodes
sum(fduplicated(graph[, c(&quot;from&quot;, &quot;to&quot;)]))
## [1] 679</code></pre>
<p>Evidently, casting to <code>LINESTRING</code> did not increase the row count in this case. <code>linestrings_to_graph()</code> then removed the geometry (<code>SHAPE</code>) column and added 8 columns: a unique edge identifier, <code>from</code> and <code>to</code> node IDs with corresponding geodetic coordinates (WGS84), and <code>.length</code> computed directly from the shape in meters (which here matches the <code>LENGTH</code> column expressed in miles).</p>
<p>We can again extract nodes with <code>nodes_from_graph()</code> to determine the nearest node to each of the 3796 FAF5 zones provided as origin and destination locations for commodity flows:</p>
<pre class="r"><code>zones &lt;- st_read(FAF5_path, layer = &quot;FAF5_Nodes&quot;) |&gt; 
  fsubset(!is.na(CentroidID)) |&gt; st_transform(4326)
## Reading layer `FAF5_Nodes&#39; from data source `/Users/sebastiankrantz/Documents/CPCS/FAF5/Networks/Geodatabase Format/FAF5Network.gdb&#39; using driver `OpenFileGDB&#39;
## Simple feature collection with 974788 features and 15 fields
## Geometry type: POINT
## Dimension:     XY
## Bounding box:  xmin: -167.0152 ymin: 18.91474 xmax: -66.98005 ymax: 71.3198
## Geodetic CRS:  NAD83
nrow(zones)
## [1] 3796
head(zones, 1)
## Simple feature collection with 1 feature and 15 fields
## Geometry type: POINT
## Dimension:     XY
## Bounding box:  xmin: -132.9393 ymin: 56.83877 xmax: -132.9393 ymax: 56.83877
## Geodetic CRS:  WGS 84
##   ID     DATA Entry_or_Exit Exit_Number Interchange Centroid CentroidID Facility_Type Facility_Name County State StateID StateName FAFID StateNameBak
## 1 14 47179393          &lt;NA&gt;        &lt;NA&gt;        &lt;NA&gt;        1       2195          &lt;NA&gt;          &lt;NA&gt;   &lt;NA&gt;  &lt;NA&gt;       2    Alaska    20       Alaska
##                        SHAPE
## 1 POINT (-132.9393 56.83877)
nodes &lt;- nodes_from_graph(graph, sf = TRUE)
nrow(nodes)
## [1] 348495</code></pre>
<p>Using the <a href="https://faf.ornl.gov/faf5/dtt_total.aspx">FAF5 Data Tabulation Tool</a>, I have generated an OD matrix of total 2024 truck flows:</p>
<pre class="r"><code>OD &lt;- fread(FAF5_2024_truck_flows) |&gt; janitor::clean_names() |&gt; 
  ftransform(id_o = as.integer(substr(dms_orig, 1, 3)), 
             id_d = as.integer(substr(dms_dest, 1, 3)))
nrow(OD)
## [1] 16798
head(OD, 3)
##             dms_orig          dms_dest dms_mode thousand_tons_in_2024  id_o  id_d
##               &lt;char&gt;            &lt;char&gt;   &lt;char&gt;                 &lt;num&gt; &lt;int&gt; &lt;int&gt;
## 1: 011-Birmingham AL 011-Birmingham AL  1-Truck            35382.9943    11    11
## 2: 011-Birmingham AL     012-Mobile AL  1-Truck              970.8396    11    12
## 3: 011-Birmingham AL    019-Rest of AL  1-Truck            10178.2525    11    19</code></pre>
<p>This data can be matched to zone nodes, but only at the coarser 3-digit FAF zone level:</p>
<pre class="r"><code>zones |&gt; join(fslice(OD, id_o), on = c(&quot;FAFID&quot; = &quot;id_o&quot;)) |&gt; invisible()
## left join: zones[FAFID] 3796/3796 (100%) &lt;28.76:1st&gt; y[id_o] 132/132 (100%)
zones |&gt; join(fslice(OD, id_d), on = c(&quot;FAFID&quot; = &quot;id_d&quot;)) |&gt; invisible()
## left join: zones[FAFID] 3796/3796 (100%) &lt;28.76:1st&gt; y[id_d] 132/132 (100%)</code></pre>
<p>We therefore aggregate the centroids to the 3-digit level and map them to the nearest network nodes:</p>
<pre class="r"><code># Aggregating zones and computing aggregate centroids
zones_agg &lt;- zones |&gt; fgroup_by(FAFID) |&gt; 
  fsummarise(SHAPE = st_centroid(st_combine(SHAPE)))
# Matching each aggregate zone centroid to the nearest network node
zones_agg$NN &lt;- nodes$node[st_nearest_feature(zones_agg, nodes)]</code></pre>
<p>We are now ready to create a mapped OD-matrix with <code>from</code> and <code>to</code> node columns as well as <code>flow</code>:</p>
<pre class="r"><code>OD_mapped &lt;- OD |&gt; 
  join(zones_agg |&gt; st_drop_geometry() |&gt; fselect(FAFID, NN), 
       on = c(&quot;id_o&quot; = &quot;FAFID&quot;)) |&gt; frename(from = NN) |&gt;
  join(zones_agg |&gt; st_drop_geometry() |&gt; fselect(FAFID, NN), 
       on = c(&quot;id_d&quot; = &quot;FAFID&quot;)) |&gt; frename(to = NN) |&gt;
  fmutate(flow = thousand_tons_in_2024) |&gt; 
  fsubset(is.finite(flow) / flow &gt; 0, from, dms_orig, to, dms_dest, flow)
## left join: OD[id_o] 16798/16798 (100%) &lt;127.26:1st&gt; y[FAFID] 132/132 (100%)
## left join: x[id_d] 16798/16798 (100%) &lt;127.26:1st&gt; y[FAFID] 132/132 (100%)
head(OD_mapped)
##     from          dms_orig     to          dms_dest       flow
##    &lt;int&gt;            &lt;char&gt;  &lt;int&gt;            &lt;char&gt;      &lt;num&gt;
## 1:  6551 011-Birmingham AL   6551 011-Birmingham AL 35382.9943
## 2:  6551 011-Birmingham AL 124499     012-Mobile AL   970.8396
## 3:  6551 011-Birmingham AL 125253    019-Rest of AL 10178.2525
## 4:  6551 011-Birmingham AL   1691        020-Alaska     0.0000
## 5:  6551 011-Birmingham AL  15659    041-Phoenix AZ    50.0572
## 6:  6551 011-Birmingham AL   5981     042-Tucson AZ     1.3969</code></pre>
<p>Let us now proceed to a basic flows assignment using the efficient All-or-Nothing (AoN) method. Before running it, we need to further process the graph in two ways:</p>
<ol style="list-style-type: decimal">
<li>Create a column representing the generalized cost of traversing a link.</li>
<li>Ensure directed links are properly represented.</li>
</ol>
<p>For (1), I combine the free flow time (in minutes) with a truck toll estimate (average per-mile toll for a 5-axle vehicle <span class="math inline">\(\times\)</span> segment length), converted to minutes assuming a value of time (VOT) of 50 USD/hour.</p>
<pre class="r"><code>fnobs(graph$TRUCKTOLL) # Number of links with tolls
## [1] 3245
graph %&lt;&gt;% fmutate(TollTime = replace_na(TRUCKTOLL, 0) / 50 * 60,
                   cost_AB = replace_na(AB_FreeFlowTime + TollTime, 1e4),
                   cost_BA = replace_na(BA_FreeFlowTime + TollTime, 1e4))
graph %$% fquantile(setv(TollTime / cost_AB, 0, NA), Qprobs)
##         0%         1%         5%        25%        50%        75%        95%        99%       100% 
## 0.09420283 0.11402505 0.13810940 0.22898990 0.28057551 0.84988865 0.98935454 0.99612581 0.99964115</code></pre>
<p>Among the 3245 links with tolls, tolls account for ~28% of the generalized cost. A few links (~3800 in each direction) are missing free flow time; these are imputed with a very high cost of 10,000 minutes.</p>
<p>The network provides free flow time estimates in both directions, and, to complicate things further, a <code>DIR</code> column indicates whether the link is bidirectional (<code>0</code>) or unidirectional (<code>1</code> or <code>-1</code>). We thus need to convert the graph to a fully directed one by duplicating bidirectional links with swapped endpoints.</p>
<pre class="r"><code>table(graph$DIR) # See direction or undirectedness of links
## 
##     -1      0      1 
##    391 168215 318788
# Create fully directed graph representation
graph_dir &lt;- rowbind(
   graph |&gt; fselect(from, to, FX, FY, TX, TY, cost = cost_AB), 
   graph |&gt; fsubset(DIR == 0,
     from = to, to = from, FX = TX, FY = TY, TX = FX, TY = FY, cost = cost_BA)                       
)
nrow(graph_dir)
## [1] 655609</code></pre>
<p>We are now ready for directed All-or-Nothing assignment:</p>
<pre class="r"><code>system.time({
result_AoN &lt;- run_assignment(graph_dir, OD_mapped, directed = TRUE, 
     method = &quot;AoN&quot;, return.extra = &quot;costs&quot;, verbose = FALSE)
})
##    user  system elapsed 
##  10.310   0.199  10.519
print(result_AoN)
## FlowNet object
## Call: run_assignment(graph_df = graph_dir, od_matrix_long = OD_mapped,      directed = TRUE, method = &quot;AoN&quot;, return.extra = &quot;costs&quot;,      verbose = FALSE) 
## 
## Number of edges: 655609 
## Number of simulations/OD-pairs: 16438 
## 
## Average path cost (SD): 1207.033  (759.4758)
## 
## Final flows summary statistics:
##        N  Ndist     Mean       SD  Min        Max  Skew   Kurt
##   655609   5401  1948.29  6721.89    0  137376.81  4.99  35.61
##   1%  5%  10%  25%  50%  75%      90%       95%       99%
##    0   0    0    0    0    0  5378.46  15025.71  34257.51</code></pre>
<p>The <em>igraph</em> library called under the hood still warns about some unreachable nodes, leaving <code>16798-16438 = 360</code> OD-pairs unassigned. A remedy would be to duplicate all directed links with a very high cost in the opposite direction, but with 655,609 edges already, further edge inflation is undesirable.</p>
<p>As noted above, this network is a borderline case for PSL assignment in terms of computational complexity, but, with only ~16.8k OD-pairs, we can manage by restricting detours to 1.2<span class="math inline">\(\times\)</span> the shortest path and the angle for intermediate target nodes to 30 degrees.<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a> Since the US network is very <a href="https://christopherwolfram.com/projects/efficiency-of-road-networks/">route efficient</a>, these restrictions are reasonable. They do not fully tame the combinatorial explosion, however—some connections (e.g., San Francisco to New York) still admit over 90,000 alternatives—so we additionally cap the number of paths at a random sample of 1000.<a href="#fn3" class="footnote-ref" id="fnref3"><sup>3</sup></a> For memory-efficient multithreading, I also limit the pre-computed OD cost and geodesic distance matrices to 5000<span class="math inline">\(^2\)</span>.</p>
<p>We also need to calibrate the PSL model. The average path cost here is 1207 minutes—exponentiating such large numbers in the MNL concentrates virtually all probability on the shortest path, reducing the PSL to AoN. Having calibrated the Africa model with <code>beta = 4</code> at an average normalized cost of 53.5, I apply a heuristic scaling of <code>53.5 / 1207 = 0.0443</code> to the FAF5 link costs and reuse the same <code>beta</code>.</p>
<pre class="r"><code>graph_dir$cost_scaled &lt;- graph_dir$cost * 53.5 / 1207
system.time({
result_PSL &lt;- run_assignment(
    graph_dir, OD_mapped, directed = TRUE, 
    cost.column = &quot;cost_scaled&quot;, method = &quot;PSL&quot;, beta = 4,
    detour.max = 1.2, angle.max = 30, npaths.max = 1000, 
    dmat.max.size = 5000^2, nthreads = 8, verbose = FALSE,
    return.extra = c(&quot;edges&quot;, &quot;counts&quot;, &quot;weights&quot;, &quot;eweights&quot;)
) |&gt; suppressWarnings()
})
##    user  system elapsed 
## 444.772  20.004 476.424
print(result_PSL)
## FlowNet object
## Call: run_assignment(graph_df = graph_dir, od_matrix_long = OD_mapped,      directed = TRUE, cost.column = &quot;cost_scaled&quot;, method = &quot;PSL&quot;,      beta = 4, detour.max = 1.2, angle.max = 30, npaths.max = 1000,      dmat.max.size = 5000^2, return.extra = c(&quot;edges&quot;, &quot;counts&quot;,          &quot;weights&quot;, &quot;eweights&quot;), verbose = FALSE, nthreads = 8) 
## 
## Number of edges: 655609 
## Number of simulations/OD-pairs: 16438 
## 
## Average number of edges utilized per simulation (SD): 13803.19  (8072.216)
## Average number of visits per edge (SD): 28.39304  (69.88708)
## Average path weight (SD): 0.002916186  (0.01594657)
## Average summed edge weight (SD): 0.06338159  (0.18493)
## 
## Final flows summary statistics:
##        N   Ndist     Mean       SD  Min        Max  Skew   Kurt
##   655609  397919  1882.31  5631.94    0  132405.13  4.86  35.47
##   1%  5%  10%   25%    50%     75%      90%       95%       99%
##    0   0    0  0.79  25.25  464.02  5373.66  12819.44  28378.51</code></pre>
<p>Solving 16,438 OD pairs (<span class="math inline">\(\leq\)</span> 1000 paths each) in about 8 minutes—again generating around 15 million paths, but on a network with 253<span class="math inline">\(\times\)</span> more nodes and 280<span class="math inline">\(\times\)</span> more edges than the Africa network, while taking only 16<span class="math inline">\(\times\)</span> more time—is rather impressive. Still, the throughput of only ~35 OD pairs per second imposes clear limits for considering much larger OD matrices.<a href="#fn4" class="footnote-ref" id="fnref4"><sup>4</sup></a></p>
<p>Now that we have a result, let’s visualize the generated routes from San Francisco to New York.</p>
<pre class="r"><code>sf2ny &lt;- OD_mapped |&gt; 
  with(which(dms_orig %like% &quot;San Francisco&quot; &amp; dms_dest %like% &quot;363-New York&quot;))
# Here we need to adjust the index because some OD pairs were removed
sf2ny &lt;- whichv(result_PSL$od_pairs_used, sf2ny)
length(result_PSL$path_weights[[sf2ny]]) # Number of paths retained
## [1] 408
sf2ny_edges &lt;- result_PSL$edges[[sf2ny]]
sf2ny_net &lt;- linestrings_from_graph(ss(graph_dir, sf2ny_edges))
sf2ny_net$edge_counts &lt;- result_PSL$edge_counts[[sf2ny]]
sf2ny_net$edge_weights &lt;- result_PSL$edge_weights[[sf2ny]]

tm_basemap(&quot;CartoDB.Positron&quot;, zoom = 4) +
tm_shape(roworder(sf2ny_net, edge_counts)) +
  tm_lines(col = &quot;edge_counts&quot;,
           col.scale = tm_scale_continuous_sqrt(6, values = &quot;turbo&quot;, 
                                           values.range = c(0.1, 0.9), 
                                           value.na = &quot;grey&quot;),
           col.legend = tm_legend(&quot;Edge Visits&quot;, position = c(&quot;left&quot;, &quot;bottom&quot;),
                                  orientation = &quot;landscape&quot;,
                                  frame = FALSE, text.size = 0.8, title.size = 1,
                                  item.width = 3, bg.alpha = 0.6,
                                  na.show = FALSE), lwd = 1.5) +
tm_shape(zones_agg) + tm_dots(size = 0.1) +
tm_layout(frame = FALSE, outer.margins = 0)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/sf2ny-edge-counts-1.png" alt="" width="100%" /></p>
<p>As expected, we obtain a large number of paths, many of which take curious detours. The <a href="https://sebkrantz.github.io/flownet/reference/run_assignment.html#route-enumeration-algorithm">route enumeration algorithm</a> filters out paths with duplicate directional edges, ensuring no route traverses a road segment twice—this ex-post elimination is the main reason we retain only 408 of the sampled 1000. Some intermediate nodes generating valid routes lie inside city centers or in the open field, yielding complex local patterns. Nonetheless, the edge counts clearly reveal two main corridors (north and south), both branching out as they approach New York.</p>
<p>Let’s now see what the edge probabilities suggest where traffic is actually going:</p>
<pre class="r"><code># Again clipping small probabilities below 0.0001
sf2ny_net$edge_weights_clip &lt;- replace_outliers(sf2ny_net$edge_weights, 0.0001, NA, &quot;min&quot;)
tm_basemap(&quot;CartoDB.Positron&quot;, zoom = 4) +
tm_shape(roworder(sf2ny_net, edge_weights_clip)) +
  tm_lines(col = &quot;edge_weights_clip&quot;,
           col.scale = tm_scale_continuous_sqrt(6, values = &quot;turbo&quot;, limits = c(0, 1),
                                           values.range = c(0.1, 0.9), 
                                           value.na = &quot;grey&quot;),
           col.legend = tm_legend(&quot;Edge Probabilities&quot;, position = c(&quot;left&quot;, &quot;bottom&quot;),
                                  orientation = &quot;landscape&quot;,
                                  frame = FALSE, text.size = 0.8, title.size = 1,
                                  item.width = 3, bg.alpha = 0.6,
                                  na.show = FALSE), lwd = 1.5) +
tm_shape(zones_agg) + tm_dots(size = 0.1) +
tm_layout(frame = FALSE, outer.margins = 0)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/sf2ny-edge-probs-1.png" alt="" width="100%" /></p>
<p>The southern route is clearly not deemed very viable by the MNL function. Most traffic follows the northern corridor, with meaningful heterogeneity only in the Midwest where it splits into a few near-equally probable branches that later merge. A minor southern variant carries about 5% of traffic.</p>
<p>As with the Africa network, the PSL model imposes economically sound structure on a large set of heterogeneous routes. Let us now attempt to visualize the full network flows. To reduce spatial complexity, we will aggregate the directed graph back to an undirected one and filter out links with negligible flows, as well as links going to Alaska (upwards of 49.1 degrees latitude).</p>
<pre class="r"><code>flows &lt;- graph_dir %&gt;%
  ftransform(flows = result_PSL$final_flows,
             from = fpmax(from, to), to = fpmin(from, to)) %&gt;%
  fgroup_by(from, to) %&gt;%
  fsummarise(across(FX:TY, ffirst), 
             flows = fsum(flows),
             cost = fmean(cost)) %&gt;%
  fsubset(flows &gt; 1 &amp; FY &lt; 49.1 &amp; TY &lt; 49.1)
head(flows, 3)
##   from to        FX       FY        TX       TY     flows      cost
## 1   94 93 -117.7272 33.48681 -117.7854 33.54277  1184.096 8.0597573
## 2   95 94 -117.7272 33.48681 -117.7067 33.46553  1184.096 3.0502339
## 3  100 99 -117.4452 33.27583 -117.4406 33.27092 13576.867 0.4469114</code></pre>
<p>For the visualization, I use a dark map and order links by flow so the important ones are plotted last.<a href="#fn5" class="footnote-ref" id="fnref5"><sup>5</sup></a></p>
<pre class="r"><code>tm_basemap(&quot;CartoDB.DarkMatter&quot;, zoom = 4) +
tm_shape(roworder(linestrings_from_graph(flows), flows)) +
  tm_lines(col = &quot;flows&quot;,
           col.scale = tm_scale_continuous_log(6, values = &quot;inferno&quot;),
           col.legend = tm_legend(&quot;PSL Flows&quot;, position = c(&quot;left&quot;, &quot;bottom&quot;),
                                  frame = FALSE, text.size = 0.8, title.size = 1, 
                                  item.height = 2, na.show = FALSE, bg.alpha = 0.4), lwd = 0.5) +
tm_layout(frame = FALSE, outer.margins = 0)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/high-res-plot-1.png" alt="" width="100%" /></p>
<p>To speed up PSL assignments on very large networks, permitting the use of larger OD matrices within reasonable computing times, we can also use several graph simplification tools <em>flownet</em> provides.</p>
<p>The first, <code>consolidate_graph()</code>, reduces graph complexity by contracting intermediate nodes that connect only two edges, and dropping singleton, loop, and duplicate edges. It supports several levels of recursion: <code>recursive = "full"</code> (the default) repeats edge elimination, node contraction, and attribute aggregation until convergence, yielding the most compact result but making it impossible to trace it back to the original edges. <code>recursive = "partial"</code> omits the aggregation step from the recursion loop and attaches attributes mapping to original edges. This is useful particularly when a more detailed network geometry needs to be unionized along with the graph consolidation for visualization purposes. For aggregation, <code>consolidate_graph()</code> delegates to <a href="https://fastverse.org/collapse/reference/collap.html"><code>collap()</code></a> in <em>collapse</em>, which supports multi-type and custom aggregation with different functions and weights.<a href="#fn6" class="footnote-ref" id="fnref6"><sup>6</sup></a> Below, I apply full recursive consolidation on this directed graph, summing all numeric columns (here only <code>"cost"</code> is aggregated).</p>
<pre class="r scroll-output"><code>graph_dir_c &lt;- consolidate_graph(graph_dir, keep = zones_agg$NN, directed = TRUE, FUN = &quot;fsum&quot;)
## Consolidating directed graph graph_dir with 655609 edges using full recursion
## Initial node degrees:
##      1      2      3      4      5      6      7      8      9     10     11     12 
##  12478  50928 135599  53020  26793  56446   1257  11574     40    351      3      6 
## 
## Dropped 47 loop edges
## Dropped 1090 duplicate edges
## Dropped 12358 edges leading to singleton nodes
## Dropped 2164 edges leading to singleton nodes
## Dropped 43 edges leading to singleton nodes
## Dropped 9 edges leading to singleton nodes
## Dropped 4 edges leading to singleton nodes
## Dropped 1 edges leading to singleton nodes
## Contracted 61403 intermediate nodes
## Dropped 12 edges leading to singleton nodes
## Aggregated 639881 edges down to 578232 edges
## Dropped 4872 loop edges
## Dropped 5 edges leading to singleton nodes
## Dropped 1 edges leading to singleton nodes
## Contracted 1031 intermediate nodes
## Dropped 10 edges leading to singleton nodes
## Aggregated 573344 edges down to 572279 edges
## Dropped 598 loop edges
## Contracted 287 intermediate nodes
## Dropped 2 edges leading to singleton nodes
## Aggregated 571679 edges down to 571384 edges
## Dropped 227 loop edges
## Contracted 122 intermediate nodes
## Aggregated 571157 edges down to 571030 edges
## Dropped 109 loop edges
## Contracted 58 intermediate nodes
## Aggregated 570921 edges down to 570862 edges
## Dropped 52 loop edges
## Contracted 36 intermediate nodes
## Aggregated 570810 edges down to 570774 edges
## Dropped 33 loop edges
## Contracted 27 intermediate nodes
## Aggregated 570741 edges down to 570714 edges
## Dropped 27 loop edges
## Contracted 23 intermediate nodes
## Aggregated 570687 edges down to 570663 edges
## Dropped 22 loop edges
## Contracted 15 intermediate nodes
## Aggregated 570641 edges down to 570626 edges
## Dropped 15 loop edges
## Contracted 13 intermediate nodes
## Aggregated 570611 edges down to 570598 edges
## Dropped 13 loop edges
## Contracted 11 intermediate nodes
## Aggregated 570585 edges down to 570574 edges
## Dropped 11 loop edges
## Contracted 10 intermediate nodes
## Aggregated 570563 edges down to 570553 edges
## Dropped 10 loop edges
## Contracted 7 intermediate nodes
## Aggregated 570543 edges down to 570536 edges
## Dropped 7 loop edges
## Contracted 4 intermediate nodes
## Aggregated 570529 edges down to 570525 edges
## Dropped 4 loop edges
## Contracted 4 intermediate nodes
## Aggregated 570521 edges down to 570517 edges
## Dropped 4 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570513 edges down to 570511 edges
## Dropped 2 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570509 edges down to 570507 edges
## Dropped 2 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570505 edges down to 570503 edges
## Dropped 2 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570501 edges down to 570499 edges
## Dropped 2 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570497 edges down to 570495 edges
## Dropped 2 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570493 edges down to 570491 edges
## Dropped 2 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570489 edges down to 570487 edges
## Dropped 2 loop edges
## Contracted 2 intermediate nodes
## Aggregated 570485 edges down to 570483 edges
## Dropped 2 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570481 edges down to 570480 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570479 edges down to 570478 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570477 edges down to 570476 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570475 edges down to 570474 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570473 edges down to 570472 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570471 edges down to 570470 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570469 edges down to 570468 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570467 edges down to 570466 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570465 edges down to 570464 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570463 edges down to 570462 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570461 edges down to 570460 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570459 edges down to 570458 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570457 edges down to 570456 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570455 edges down to 570454 edges
## Dropped 1 loop edges
## Contracted 1 intermediate nodes
## Aggregated 570453 edges down to 570452 edges
## Dropped 1 loop edges
## No nodes to contract, returning graph
## 
## Consolidated directed graph graph_dir from 655609 edges to 570451 edges (87.0%)
## Final node degrees:
##      1      2      3      4      5      6      7      8      9     10     11     12 
##      1    209 122726  55411  26528  53742   1132  10816     28     82      3      1
nrow(graph_dir_c)
## [1] 570451</code></pre>
<p>Full consolidation in this case reduced the number of edges by only 13%, suggesting that most edges in the FAF5 network serve an important connecting function.</p>
<p>For a more drastic network simplification, we can turn to <code>simplify_network()</code>, which provides two topological network simplification methods. The default, <code>"shortest-paths"</code>, computes all shortest paths between a given set of nodes (or OD pairs) and retains only the edges traversed by any path. This eliminates links not needed for primary navigation, at the cost of reducing alternative routing options.</p>
<pre class="r"><code>graph_dir_c_sc &lt;- simplify_network(graph_dir_c, zones_agg$NN, directed = TRUE, 
                                   method = &quot;shortest-paths&quot;) |&gt;
  consolidate_graph(keep = zones_agg$NN, directed = TRUE, FUN = &quot;fsum&quot;)
## Created graph with 270679 nodes and 570451 edges...
## Retained 70854/570451 edges traversed by shortest paths (12.4%)
## Consolidating directed graph . with 70854 edges using full recursion
## Initial node degrees:
##     1     2     3     4     5     6     7     8 
##     1 49675  3162  7499   281   223     4    13 
## 
## Contracted 49631 intermediate nodes
## Aggregated 70854 edges down to 21223 edges
## No nodes to contract, returning graph
## 
## Consolidated directed graph . from 70854 edges to 21223 edges (30.0%)
## Final node degrees:
##    1    2    3    4    5    6    7    8 
##    1   44 3162 7499  281  223    4   13
nrow(graph_dir_c_sc)
## [1] 21223</code></pre>
<p>The method is very effective, cutting the consolidated graph by 87% and then a further 70% through follow-up consolidation. <em>igraph</em> still warns about a few unreachable nodes.</p>
<p>We can also simplify the undirected flow graph created above (where costs were averaged across directions)—a reasonable simplifying assumption given symmetric highway connectivity. Let us first match the nearest nodes and consolidate.<a href="#fn7" class="footnote-ref" id="fnref7"><sup>7</sup></a></p>
<pre class="r scroll-output"><code># Matching nearest nodes and consolidating
flows_nodes &lt;- nodes_from_graph(flows, sf = TRUE)
zones_agg$NN_undir &lt;- flows_nodes$node[st_nearest_feature(zones_agg, flows_nodes)]
flows_c &lt;- consolidate_graph(flows, keep = zones_agg$NN_undir, w = ~ cost)
## Consolidating undirected graph flows with 380531 edges using full recursion
## Initial node degrees:
##      1      2      3      4      5      6      7      8 
##   2022 144346 127137  21398    530    111      3      1 
## 
## Dropped 1878 edges leading to singleton nodes
## Dropped 739 edges leading to singleton nodes
## Dropped 361 edges leading to singleton nodes
## Dropped 202 edges leading to singleton nodes
## Dropped 110 edges leading to singleton nodes
## Dropped 69 edges leading to singleton nodes
## Dropped 47 edges leading to singleton nodes
## Dropped 29 edges leading to singleton nodes
## Dropped 24 edges leading to singleton nodes
## Dropped 20 edges leading to singleton nodes
## Dropped 18 edges leading to singleton nodes
## Dropped 13 edges leading to singleton nodes
## Dropped 10 edges leading to singleton nodes
## Dropped 8 edges leading to singleton nodes
## Dropped 6 edges leading to singleton nodes
## Dropped 3 edges leading to singleton nodes
## Dropped 3 edges leading to singleton nodes
## Dropped 3 edges leading to singleton nodes
## Dropped 3 edges leading to singleton nodes
## Dropped 2 edges leading to singleton nodes
## Dropped 2 edges leading to singleton nodes
## Dropped 1 edges leading to singleton nodes
## Oriented 45693 undirected intermediate edges
## Contracted 143923 intermediate nodes
## Oriented 1964 undirected intermediate edges
## Contracted 1964 intermediate nodes
## Oriented 15 undirected intermediate edges
## Contracted 15 intermediate nodes
## Aggregated 376980 edges down to 248202 edges
## Dropped 8 loop edges
## Dropped 15 edges leading to singleton nodes
## Oriented 15515 undirected intermediate edges
## Contracted 17786 intermediate nodes
## Oriented 403 undirected intermediate edges
## Contracted 403 intermediate nodes
## Oriented 25 undirected intermediate edges
## Contracted 25 intermediate nodes
## Oriented 2 undirected intermediate edges
## Contracted 2 intermediate nodes
## Aggregated 248179 edges down to 232379 edges
## Dropped 3 loop edges
## Dropped 1 edges leading to singleton nodes
## Oriented 2210 undirected intermediate edges
## Contracted 2491 intermediate nodes
## Oriented 34 undirected intermediate edges
## Contracted 34 intermediate nodes
## Oriented 1 undirected intermediate edges
## Contracted 1 intermediate nodes
## Aggregated 232375 edges down to 230068 edges
## Oriented 253 undirected intermediate edges
## Contracted 310 intermediate nodes
## Oriented 1 undirected intermediate edges
## Contracted 1 intermediate nodes
## Aggregated 230068 edges down to 229777 edges
## Oriented 40 undirected intermediate edges
## Contracted 51 intermediate nodes
## Aggregated 229777 edges down to 229732 edges
## Dropped 1 edges leading to singleton nodes
## Oriented 7 undirected intermediate edges
## Contracted 7 intermediate nodes
## Aggregated 229731 edges down to 229724 edges
## No nodes to contract, returning graph
## 
## Consolidated undirected graph flows from 380531 edges to 229724 edges (60.4%)
## Final node degrees:
##      1      2      3      4      5      6      7      8 
##      3     64 124282  20840    493    104      2      1</code></pre>
<p>We then run shortest-path simplification followed by consolidation.</p>
<pre class="r"><code>flows_c_sc &lt;- simplify_network(flows_c, zones_agg$NN_undir) |&gt; 
  consolidate_graph(keep = zones_agg$NN_undir, w = ~ cost, verbose = FALSE)
## Created graph with 145789 nodes and 229724 edges...
## Retained 32118/229724 edges traversed by shortest paths (14.0%)
nrow(flows_c_sc)
## [1] 3091</code></pre>
<p>And voila, that I would call a radical simplification, first retaining 32,118 edges traversed by shortest paths and then recursively consolidating them down to 3091 edges.</p>
<p>The result is visually sparse, and the simplification discards flows on non-traversed links. Ideally one would re-assign the OD matrix to the simplified network; I leave that as an exercise since the procedure should be clear by now.</p>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/sp-simplified-agg-map-1.png" alt="" width="100%" /></p>
<p>A viable alternative retaining far more edges for alternative routing is to base the shortest-path simplification on the full 3796 disaggregated zones rather than only the 132 aggregated zones:</p>
<pre class="r"><code># Need to first match them to the undirected graph
flows_c_nodes &lt;- nodes_from_graph(flows_c, sf = TRUE)
zones$NN_undir &lt;- flows_c_nodes$node[st_nearest_feature(zones, flows_c_nodes)]
# Combining with the aggregate zone nearest nodes
sp_nodes &lt;- unique(c(zones$NN_undir, zones_agg$NN_undir))
length(sp_nodes)
## [1] 3457
# Now simplifying again
flows_c_sc &lt;- simplify_network(flows_c, sp_nodes) |&gt; 
   consolidate_graph(keep = zones_agg$NN_undir, w = ~ cost, verbose = FALSE)
## Created graph with 145789 nodes and 229724 edges...
## Retained 107695/229724 edges traversed by shortest paths (46.9%)
nrow(flows_c_sc)
## [1] 48672</code></pre>
<p>The resulting graph is much more detailed. This required computing <code>3457*(3457-1)/2 = 5,973,696</code> shortest paths, which took ~3 min—a future update will add multithreading to <code>simplify_network()</code>.</p>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/sp-simplified-full-map-1.png" alt="" width="100%" /></p>
<p>This simplified and consolidated network certainly looks more appealing and provides a reasonable simplification for routing and stochastic traffic assignment using the PSL in this case.</p>
<p>The second method, <code>"cluster"</code>, takes a fundamentally different approach: it spatially clusters nearby nodes and contracts the graph accordingly, (1) dropping intra-cluster loop edges and (2) aggregating inter-cluster edges via <a href="https://fastverse.org/collapse/reference/collap.html"><code>collap()</code></a>. Clustering proceeds in two stages: first, optionally, designate key nodes (e.g., zone centroids) as cluster seeds, absorbing all nodes within a specified radius; then, use Hartigan (1975)’s Leader Algorithm—implemented efficiently in C by <a href="https://CRAN.R-project.org/package=leaderCluster">leaderCluster</a> with geographic coordinate support—to cluster the remaining nodes using a second radius. If no key nodes are provided, all nodes are clustered via the Leader Algorithm.</p>
<p>Below, we use the 132 zone nodes as cluster seeds and a 7km radius for both stages. The larger the radius, the more aggressive the simplification.<a href="#fn8" class="footnote-ref" id="fnref8"><sup>8</sup></a></p>
<pre class="r scroll-output"><code>flows_c_cc &lt;- simplify_network(flows_c, zones_agg$NN_undir, method = &quot;cluster&quot;, 
                      radius_km = list(nodes = 7, cluster = 7), w = ~ flows) |&gt; 
   consolidate_graph(keep = zones_agg$NN_undir, w = ~ cost)
## Clustering nodes close to &#39;keep&#39; nodes using a radius of 7km
## Clustering the remaining nodes with the leaderCluster algorithm using a radius of 7km
## leaderCluster algorithm converged in 13 iterations
## Dropped 183503 self-loop edges (following clustering)
## Oriented 30362 undirected edges
## Contracting 46221 edges down to 26395 edges
## Consolidating undirected graph . with 26395 edges using full recursion
## Initial node degrees:
##    1    2    3    4    5    6    7    8    9   10   11   13 
##   55 2349 5358 3197 1709  972  412  163   46   15    3    1 
## 
## Dropped 54 edges leading to singleton nodes
## Oriented 1129 undirected intermediate edges
## Contracted 2325 intermediate nodes
## Oriented 1 undirected intermediate edges
## Contracted 1 intermediate nodes
## Aggregated 26341 edges down to 23449 edges
## Dropped 2 loop edges
## Oriented 103 undirected intermediate edges
## Contracted 149 intermediate nodes
## Aggregated 23447 edges down to 23293 edges
## Oriented 6 undirected intermediate edges
## Contracted 6 intermediate nodes
## Aggregated 23293 edges down to 23287 edges
## No nodes to contract, returning graph
## 
## Consolidated undirected graph . from 26395 edges to 23287 edges (88.2%)
## Final node degrees:
##    1    2    3    4    5    6    7    8    9   10   12 
##    1   24 5477 3288 1641  882  330  101   25    9    1
nrow(flows_c_cc)
## [1] 23287</code></pre>
<p>It is notable that clustering leaves less room for follow-up consolidation than shortest-path simplification, which tends to produce long chains of connected edges.</p>
<p>Let’s look at the result:</p>
<p><img src="https://sebkrantz.github.io/Rblog/2026/02/09/introducing-flownet-efficient-transport-modeling-in-r/index_files/figure-html/cluster-simplified-map-1.png" alt="" width="100%" /></p>
<p>Also pretty neat—I think—and very similar. Finally, I note that while this network is unimodal, both <code>consolidate_graph()</code> and <code>simplify_network()</code> support multimodal networks—passing mode identifiers to their ‘<code>by</code>’ arguments prevents edges of different modes from being conflated.</p>
</div>
<div id="conclusion" class="section level2">
<h2>Conclusion</h2>
<p><a href="https://sebkrantz.github.io/flownet/"><em>flownet</em></a> is a new R package for transport modeling. It presently provides route enumeration and traffic assignment using the Path-Sized Logit model, powerful tools for network processing, and a parsimonious API. These features make it a compelling choice for transport analytics, particularly in R. The package is built on top of several <a href="https://fastverse.org/fastverse/"><em>fastverse</em></a> libraries and custom C code to ensure high performance.</p>
<p>In this post, I demonstrated the package on African and US road networks, showcasing how to assign trade/traffic flows with the PSL method, and how to investigate/visualize the results. I additionally demonstrated the package’s network simplification tools on the detailed US network.</p>
<p>Future <em>flownet</em> updates may comprise additional traffic assignment methods, more advanced network processing tools, and performance optimizations. But even in its present state, I believe the package is computationally capable of handling most networks we deal with at <a href="https://cpcs.ca/">CPCS</a> and a wide variety of other applications, including modeling on regional or global multimodal transport networks.</p>
</div>
<div id="references" class="section level2">
<h2>References</h2>
<p>Ben-Akiva, M., &amp; Bierlaire, M. (1999). Discrete choice methods and their
applications to short term travel decisions. In R. W. Hall (Ed.), <em>Handbook of Transportation Science</em> (pp. 5–33). Springer US. <a href="https://doi.org/10.1007/978-1-4615-5203-1_2" class="uri">https://doi.org/10.1007/978-1-4615-5203-1_2</a></p>
<p>Cascetta, E. (2001). <em>Transportation systems engineering: Theory and methods</em>. Springer.</p>
<p>Ben-Akiva, M., &amp; Lerman, S. R. (1985). <em>Discrete choice analysis: Theory and application to travel demand</em>. The MIT Press.</p>
<p>Ramming, M. S. (2002). <em>Network knowledge and route choice</em> (Doctoral dissertation). Massachusetts Institute of Technology.</p>
<p>Prato, C. G. (2009). Route choice modeling: Past, present and future research directions. <em>Journal of Choice Modelling, 2</em>(1), 65–100. <a href="https://doi.org/10.1016/S1755-5345(13)70005-8" class="uri">https://doi.org/10.1016/S1755-5345(13)70005-8</a></p>
<p>AequilibraE Python Documentation: <a href="https://www.aequilibrae.com/develop/python/route_choice/path_size_logit.html" class="uri">https://www.aequilibrae.com/develop/python/route_choice/path_size_logit.html</a></p>
<p>Hartigan, J. A. (1975). <em>Clustering algorithms</em>. John Wiley &amp; Sons, Inc.</p>
</div>
<div class="footnotes footnotes-end-of-document">
<hr />
<ol>
<li id="fn1"><p>Multimodality is principally supported via <code>by</code> arguments to network processing functions. Passing link-mode identifiers to this argument prevents network simplification/contraction across different modes.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>Measured against a straight line from origin to destination node.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
<li id="fn3"><p>Exempting the shortest path, which is always included and reported first if <code>return.extra = "paths"</code>.<a href="#fnref3" class="footnote-back">↩︎</a></p></li>
<li id="fn4"><p>Disaggregating to all 3796 FAF5 zone nodes (e.g., using population weights as in the Africa example) would yield <span class="math inline">\(3796^2-3796 \approx 14.4\)</span> million OD pairs, requiring roughly <span class="math inline">\(14.4\text{M}/16.4\text{k} = 876\times\)</span> longer <span class="math inline">\(\approx\)</span> 6 days on this machine. A 32-core server might bring this down to ~2 days, given that the 4GHz M4 Pro cores used here are state-of-the-art for single-thread performance.<a href="#fnref4" class="footnote-back">↩︎</a></p></li>
<li id="fn5"><p><code>linestrings_from_graph()</code> is a convenience function to turn the <code>FX</code>, <code>FY</code>, <code>TX</code>, and <code>TY</code> coordinates into straight linestrings again.<a href="#fnref5" class="footnote-back">↩︎</a></p></li>
<li id="fn6"><p>By default, the mean is applied to numeric and the statistical mode to categorical columns. Structural columns (<code>edge</code>, <code>from</code>, <code>to</code>, <code>FX</code>, <code>FY</code>, <code>TX</code>, <code>TY</code>) are processed separately so that the consolidated graph has a new <code>edge</code> id and a consistent spatial structure.<a href="#fnref6" class="footnote-back">↩︎</a></p></li>
<li id="fn7"><p>The graph has two non-identifier columns: <code>"cost"</code> and <code>"flows"</code>. Passing <code>w = ~ cost</code> to <a href="https://fastverse.org/collapse/reference/collap.html"><code>collap()</code></a> uses costs as weights for a weighted mean of <code>"flows"</code>, and by default sums the weights (<code>"cost"</code>) themselves. This yields the total cost along contracted edges and the cost-weighted mean of flows.<a href="#fnref7" class="footnote-back">↩︎</a></p></li>
<li id="fn8"><p><em>Note</em> here how I apply the weight trick using <a href="https://fastverse.org/collapse/reference/collap.html"><code>collap()</code></a> in two different ways: in <code>simplify_network()</code>, I weight by flows, such that, according to <a href="https://fastverse.org/collapse/reference/collap.html"><code>collap()</code></a> defaults, the flows along edges that we combine are summed and the costs are averaged using the flows as weights. Then, in the following consolidation, I do it the other way around (as before). Think about it and you will hopefully agree with me that that makes sense.<a href="#fnref8" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Releasing dfms 1.0: Fast and Feature-Rich Estimation of Dynamic Factor Models in R</title>
      <link>https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/</link>
      <pubDate>Thu, 29 Jan 2026 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/</guid>
      <description><![CDATA[ 


<p><img src='https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/index_files/dfms_logo.png' width="350px" align="right" /></p>
<p>I am very happy to announce the release of <a href="https://docs.ropensci.org/dfms/index.html"><em>dfms</em> version 1.0</a> (and 0.4.0 just a week earlier, see <a href="https://docs.ropensci.org/dfms/news/index.html#dfms-100">news</a>), implementing major features such as support for dynamic factor models (DFMs) with autoregressive errors, mixed-frequency (monthly-quarterly) DFMs, including with autoregressive errors, and decomposition of forecast revisions into news releases (updates to time series) following Banbura and Modugno (2014)—supporting interpretable nowcasts for one or multiple monthly or quarterly targets. This completes the planned scope of the package: the full, computationally efficient, and easily accessible implementation of the methodology of Banbura and Modugno (2014) in R.</p>
<p>Together with this major release, <em>dfms</em> has now been successfully peer-reviewed by two academic reviewers through the <a href="https://ropensci.org/">rOpenSci</a> <a href="https://ropensci.org/software-review/">Software Peer Review</a>, and was published by rOpenSci—the repo now lives at <a href="https://github.com/ropensci/dfms"><code>github.com/ropensci/dfms</code></a> and the website at <a href="https://docs.ropensci.org/dfms/">docs.ropensci.org/dfms</a> (redirects are in place).</p>
<p><em>dfms</em> 1.0 thus provides a feature-rich, easy-to-use, computationally efficient (C++ powered), and verified toolset to estimate and work with DFMs in R. It also seamlessly integrates with the R language and its common methods and design patterns for statistical packages. It is well documented via a <a href="https://raw.githubusercontent.com/ropensci/dfms/main/vignettes/dynamic_factor_models_paper.pdf">theoretical</a> and <a href="https://docs.ropensci.org/dfms/articles/introduction.html">practical</a> vignette and detailed function <a href="https://docs.ropensci.org/dfms/reference/index.html">documentation</a>, and thus offers an excellent entry point for young researchers and practitioners interested in DFMs and nowcasting. I therefore hope that its uptake within the scientific and R communities will increase.</p>
<p>The release also marks a major milestone for a package that was first put on CRAN in October 2022 to estimate a simple, single frequency DFM—especially given that I don’t sit in a Central Bank or do macro/business cycle analysis for a living. I also never had a natural fascination for Kalman Filters. Rather, a series of professional coincidences over the course of 5 years eventually culminated in this package. I thus want to briefly reflect on the history of <em>dfms</em> before I demonstrate its new features.</p>
<div id="the-story-of-dfms-from-simple-beginnings-to-fully-featured-dfm-package" class="section level2">
<h2>The Story of <em>dfms</em>: From Simple Beginnings to Fully-Featured DFM Package</h2>
<p>As an economist training in macroeconomics, trade, and development, I got to know about DFMs almost by accident during my Master’s in Geneva at a summer school on “Big Data in Macroeconomics and Finance” at the Kiel Institute for the World Economy in 2018—which also marks my first encounter with the Kiel Institute where I ended up doing my PhD. The course covered a variety of topics via external lecturers: household finance, social networks, geospatial big data, text mining, and also a day of “Nowcasting” with Michele Modugno from the Federal Reserve Board—where he presented his mixed-frequency DFM methodology that <em>dfms</em> now efficiently implements in R and C++. I didn’t understand much of it back then and was more interested in the other topics, but I had gotten the gist of it.</p>
<p>Following my Master’s, I was posted to Uganda as an ODI Fellow on a 2-year assignment (2020/21) in the Macroeconomic Policy Department of Uganda’s Ministry of Finance, Planning, and Economic Development (MoFPED). Most of my work involved financial programming, data science, and other kinds of econometric and CGE analysis. In spring 2021, I was, however, working part-time on a research paper on <a href="https://sebastiankrantz.com/research.html#uganda-fiscal-spending">Macroeconomic Dynamics and the Effects of Fiscal Spending in Uganda</a>. Its aim was to assess the dynamic impact of fiscal spending on the Ugandan economy and answer a few critical questions that kept coming up, such as are we crowding out credit to the private sector? What happens to interest/tbill rates when we spend more? and what is the economic/growth impact of our spending? Annual time series in Uganda are relatively short, but we observed fiscal spending and a few other key indicators on a monthly basis. Putting together a monthly estimation dataset was thus key for meaningful dynamic modelling.</p>
<p>The main monthly indicator of economic activity in Uganda with some history is the <a href="https://bou.or.ug/bouwebsite/bouwebsitecontent/research/BoUworkingPapers/research/BouWorkingPapers/2012/Composite_Indicator_of_Economic_Activity_for_Uganda._BOUWP0612.pdf">Composite Index of Economic Activity (CIEA)</a> published by the Bank of Uganda. It combines 10 monthly series: real currency in circulation, real VAT on domestic goods, real exports of goods and services, real imports, real government expenditure, real sales of selected companies, real cement production, real excise taxes, real PAYE and real private sector credit. They are combined by first using the Henderson Moving Average procedure for seasonal adjustment and separation of irregular components, and aggregation of the trend-cycle series into a composite trend-cycle indicator using weights derived from PCA and correlations of series with quarterly GDP.</p>
<p>And that is where my problem was—the inclusion of real government expenditure in this index defeated my purposes of using it to assess fiscal spending impacts. I thus needed an index without it, and I also thought the removal of irregular components and complicated aggregation procedure was suboptimal for research purposes. I wanted a simple, transparent procedure to obtain a monthly indicator of economic activity for Uganda, and a basic DFM with a single factor seemed the appropriate</p>
<p><span class="math display">\[\textbf{x}_t = \textbf{c} f_t + \textbf{e}_t \ \sim\  N(\textbf{0}, \textbf{R})\]</span>
<span class="math display">\[f_t = \sum_{j=1}^p a_j f_{t-j} + u_t \ \sim\  N(0, q)\]</span></p>
<p>methodology—with observation weights <strong>c</strong> and transition coefficients <span class="math inline">\(a_j\)</span> to be jointly estimated.</p>
<p>So I looked for a simple implementation of DFMs and found, on GitHub, the R package <a href="https://github.com/rbagd/dynfactoR">dynfactoR</a> by Rtys Bagduzinas that estimated single-frequency DFMs with the EM/QML algorithm following Doz, Giannone, and Reichlin (2012), and also produced PCA and 2-step estimates following Doz, Giannone, and Reichlin (2011). It used a Julia backend for some of the filters, thus I <a href="https://github.com/SebKrantz/dynfacto_R">forked it</a> and made some changes—rewriting the Kalman Filter and Smoother and some parts of the EM algorithm in <a href="https://arma.sourceforge.net/">Armadillo C++</a>, and linking through Rcpp. The package served the purposes of my paper well, and I had thought of creating something more organized and releasing it to CRAN, but didn’t follow through at that point (2021) as I was wrapping up my assignment in Uganda.</p>
<p>I returned to dynfactoR a year later in the early stages of my PhD—still working on empirical macroeconomics in Africa—and managed to refactor the package and put a first version on CRAN. For this refactor, I took much inspiration from the excellent <a href="https://CRAN.R-project.org/package=vars">vars</a> package to estimate (S)VAR and (S)VEC models in R, which provides a compelling nomenclature with main function <code>VAR()</code> and tight observance of the object-oriented conventions for statistical R packages—including <code>print()</code>, <code>plot()</code>, <code>coef()</code>, <code>summary()</code>, <code>residuals()</code>, <code>fitted()</code>, <code>predict()</code>, and <code>logLik()</code> methods for the <em>varest</em> objects returned by <code>VAR()</code>, and various other methods, e.g., <code>print()</code> and <code>plot()</code> for the <em>varprd</em> objects returned by <code>predict()</code>. It also provides information criteria to select the VAR lag order via <code>VARselect()</code>. Furthermore, its function documentation includes mathematical formulations alongside brief explanations, which I liked a lot. I thus got inspired and called the package <em>dfms</em>, with main function <code>DFM()</code>, implemented information criteria to select the number of factors <code>r</code> following Bai and Ng (2002) in <code>ICr()</code>, including a <code>screeplot()</code> method following Onatski (2010),<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a> and a comprehensive set of methods like <em>vars</em>. I also exported the C++ Kalman Filter and Smoother—which to my knowledge is still the most efficient implementation in R—alongside the function <code>tsnarmimp()</code> to impute time series matrices using cubic splines and moving averages (needed internally to obtain initial PCA-based system matrices to initialize the Kalman Filter), and Armadillo’s inverse and pseudo-inverse functions which I found somewhat more robust than R’s native ones. <a href="https://docs.ropensci.org/dfms/news/index.html#dfms-013"><em>dfms</em> version 0.1.3</a>, including these features, hit CRAN in October 2022, and I introduced it with a brief <a href="https://sebkrantz.github.io/Rblog/2022/10/22/introducing-dfms-efficient-estimation-of-dynamic-factor-models-in-r/">blog post</a>. I also <a href="https://github.com/ropensci/software-review/issues/556">submitted it</a> for the <em>rOpenSci</em> <a href="https://ropensci.org/software-review/">Software Peer Review</a> as I thought that might increase its quality.</p>
<p>It then occurred, one year into the PhD, that I was getting a bit bored in Kiel during the winter of 2022/23, and, through connections, organized myself a 3-month research visit at Stellenbosch University in South Africa—from February-April 2023. Parts of the department were very interested in nowcasting the South African economy. They had attempted it with regression and ML-based approaches, but, given my <em>dfms</em> package on CRAN, they asked me to work on an interpretable DFM-based methodology. This interest in nowcasting was warranted as, in the spring of 2023, the <a href="https://en.wikipedia.org/wiki/South_African_energy_crisis">South African energy crisis</a> was at its peak, with the implementation of load-shedding level 6—which meant about 8 hours per day on generators—inducing justified fears that it would stifle the South African economy.</p>
<p>Thus, I set out to implement a mixed-frequency DFM to nowcast the South African economy, particularly real GDP and unemployment. The first step, which took up much of my time, was to assemble and automate a database of high quality macroeconomic indicators. I ended up creating the <a href="https://github.com/Stellenbosch-Econometrics/SAMADB-Issues">South Africa Macroeconomic Database (SAMADB)</a>—an open relational database with ~10,000 macroeconomic time series obtained from the South African Reserve Bank (SARB) and Statistics South Africa (STATSSA) and updated on a weekly basis via <a href="https://www.econdata.co.za/">EconData</a> and automated scraping of the <a href="https://www.resbank.co.za/en/home/publications/quarterly-bulletin1/download-information-from-xlsx-data-files">SARB</a> and <a href="https://www.statssa.gov.za/?page_id=1847">STATSSA</a> websites. I hosted it on servers at <a href="https://codera.co.za/">Codera Analytics</a>, implemented the updating through GitHub Actions, and created the <a href="https://CRAN.R-project.org/package=samadb">samadb</a> R package for easy access. As of January 2026, the weekly updates still run, and the R package enjoys almost 15,000 downloads per month. I also wrote <a href="https://pypi.org/project/samadb/">Python</a> and <a href="https://juliahub.com/ui/Packages/General/SAMaDB">Julia</a> packages as these languages were more popular in the department.</p>
<p>With the database in place, I took a deep-dive on DFMs for nowcasting applications, revisiting the workshop materials by Michele Modugno, carefully reading Banbura and Modugno (2014), and looking at various nowcasting codes. The <a href="https://raw.githubusercontent.com/ropensci/dfms/main/vignettes/dynamic_factor_models_paper.pdf">theoretical vignette</a>, published in March 2023, is a result of this deep-dive and was principally written to assist my own understanding. I started implementing some of the features described there, and also received the rOpenSci software reviews during this time, which helped me improve the package. I released <a href="https://docs.ropensci.org/dfms/news/index.html#dfms-020"><em>dfms</em> 0.2.0</a>, adding autoregressive errors and various smaller improvements, to CRAN at the end of March 2023. I mention in the <a href="https://docs.ropensci.org/dfms/news/index.html#dfms-020">news</a> for that release that “I plan to implement all features described in this vignette until summer 2023”, but that turned out to be highly optimistic. With one month to go in my research visit, it dawned on me that I wouldn’t be able to complete <em>dfms</em> and build a nowcasting model in good time.</p>
<p>Thus, I switched to Python, where Chad Fulton had done some excellent work implementing the <a href="https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.dynamic_factor_mq.DynamicFactorMQ.html">DynamicFactorMQ</a> class for mixed-frequency nowcasting in <em>statsmodels</em> alongside a <a href="https://www.chadfulton.com/topics/statespace_large_dynamic_factor_models.html">comprehensive tutorial</a>. Following selection of a set of indicators for nowcasting, integration with the database and running the model on a weekly basis—also <a href="https://github.com/Stellenbosch-Econometrics/SA-Nowcast">implemented through GitHub Actions</a>—went quite smoothly. I <a href="https://raw.githubusercontent.com/Stellenbosch-Econometrics/SA-Nowcast/main/presentation/SAMADB_Nowcasting.pdf">presented</a> the database, model, and model evaluation results at the <a href="https://www.ber.ac.za/">Bureau of Economic Research</a> seminar in Stellenbosch on April 20. It was a great success, with additional staff from the Reserve Bank, Treasury, and Statistics South Africa joining online.</p>
<p>Afterwards, and as announced in the <a href="https://raw.githubusercontent.com/Stellenbosch-Econometrics/SA-Nowcast/main/presentation/SAMADB_Nowcasting.pdf">slides</a>, I devoted the rest of my time and also some time in April/May to the development of a <a href="https://www.newyorkfed.org/research/policy/nowcast/#nowcast/">NY-Fed-style Nowcasting Dashboard</a> hosted through Codera Analytics at <a href="https://nowcast.codera.co.za/">nowcast.codera.co.za</a> and published in May 2023. <a href="https://github.com/Stellenbosch-Econometrics/SA-Nowcast/tree/main/app">Built</a> with <a href="https://dash.plotly.com/">Dash</a> and <a href="https://plotly.com/python/">Plotly</a> in Python, it provides interactive visualizations of the nowcast evolution and the data releases driving it, as well as historical nowcasting performance and release impacts by sector. This <a href="https://github.com/Stellenbosch-Econometrics/SA-Nowcast">South Africa Nowcasting Model</a> ran until November 2024, when it broke down due to the unavailability of some time series. So that is also a warning right there—if you are building a nowcasting platform using <em>dfms</em> or other tools, make sure you have a robust data pipeline in place and only include series that will remain available for years.</p>
<p>With the nowcasting platform in place, and the initial software reviews completed, there was not much urgency to work more on <em>dfms</em>. I had shifted my research interests to infrastructure and value chains in Africa, and was busy with other projects. rOpenSci editors kept pinging me in 2024 for the resubmit, but I was wrapping up my PhD, and also still had the ambition to implement mixed-frequency features. This state dragged on for a while after the PhD defense (Nov’24) as I was consulting, job-searching, and doing other things, but I managed, in May 2025, to push out <a href="https://docs.ropensci.org/dfms/news/index.html#dfms-030"><em>dfms</em> 0.3.0</a> with the mixed-frequency DFM implementation and resubmitted it to rOpenSci. The reviewers approved, and, given that it was to be published through rOpenSci in January 2026, I forced myself to complete the scope of the package. I thus spent two intense weekends and pushed out <a href="https://docs.ropensci.org/dfms/news/index.html#dfms-040"><em>dfms</em> 0.4.0</a>, adding support for mixed-frequency estimation with autoregressive errors, on January 18, followed by this monumental <a href="https://docs.ropensci.org/dfms/news/index.html#dfms-100">1.0.0 release</a>, including the news decomposition following Banbura and Modugno (2014), full-state predictions, a <a href="https://github.com/ropensci/dfms/blob/main/man/figures/logo.png">package logo</a> and updated <a href="https://docs.ropensci.org/dfms/articles/introduction.html">practical vignette</a>, on January 26. The package was then published through rOpenSci, which means the <a href="https://github.com/ropensci/dfms">repo</a> moved to rOpenSci GitHub and its <a href="https://docs.ropensci.org/dfms/">docs</a> are served from docs.ropensci.org.</p>
<p>The final push was greatly facilitated by the availability of modern AI, in particular Cursor and the Opus 4.5 and GPT 5.2 Codex models, which could take into context Banbura and Modugno (2014)’s original <a href="https://github.com/ropensci/dfms/tree/main/misc/BM2014/RepFilesBanburaModugnoJAE14">Matlab code</a> and their paper + appendix, and help make and execute detailed implementation plans. I tested these by translating the Matlab code to <a href="https://github.com/ropensci/dfms/tree/main/misc/BM2014/BM14_Julia">Julia</a> and testing it against R—using the Cursor debugger to hunt down complicated bugs.</p>
<p>This thus concludes the story of <em>dfms</em>—from simple single-frequency package to a full-blown DFM/nowcasting library in four years. Apart from some differences in design choices, Kalman Filter initialization, and reporting, the package produces identical results to Banbura and Modugno (2014)’s research codes.</p>
<p>The literature on DFMs has, of course, moved on—with regime switching/time-varying volatility/parameter Bayesian DFMs now the state of the art.<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a> Since the state of the art is complicated and computationally intense though, and the methodology of Banbura and Modugno (2014) works very well when shocks are moderate—we also <a href="https://github.com/Stellenbosch-Econometrics/NowcastingUSGDP">tested the DFM against various ML/DL based approaches on US data</a>, including the COVID shock, and <a href="https://rawcdn.githack.com/Stellenbosch-Econometrics/NowcastingUSGDP/4ee94e0582b093428d0e242eb723cdf2335f0682/presentation/sem-presentation.html#/title-slide">found</a> that it outperformed most ML/DL methods—I believe <em>dfms</em> 1.0+ can serve the community well, and I hope that it will find use in academia and policy institutions.</p>
</div>
<div id="new-dfms-features-mixed-frequency-dfms-with-autoregressive-errors-full-state-predictions-and-multi-target-news-decomposition" class="section level2">
<h2>New <em>dfms</em> Features: Mixed Frequency DFMs with Autoregressive Errors, Full-State Predictions, and Multi-Target News Decomposition</h2>
<p>The two latest <em>dfms</em> updates principally introduce three new features: (1) mixed-frequency estimation with autoregressive errors implemented in <em>dfms</em> 0.4.0; (2) decomposition of forecast revisions into news contributions following Banbura and Modugno (2014), and (3) full-state predictions and forecasts implemented in <em>dfms</em> 1.0.0.</p>
<p>To stick with the context of its history, and since the <a href="https://docs.ropensci.org/dfms/reference/index.html">documentation</a> and <a href="https://docs.ropensci.org/dfms/articles/introduction.html">vignette</a> provide lots of examples using the included dataset of Banbura and Modugno (2014), I will re-estimate my South Africa nowcasting model with the last available vintages for end-November and early-December 2024.</p>
<pre class="r"><code>library(fastverse)
## -- Attaching packages ------------------------------------------------------------------------------------------------------------- fastverse 0.3.4 --
## v data.table 1.17.0     v kit        0.0.21
## v magrittr   2.0.4      v collapse   2.1.6
fastverse_extend(xts, dfms, readxl)
## -- Attaching extension packages --------------------------------------------------------------------------------------------------- fastverse 0.3.4 --
## v xts    0.14.1     v readxl 1.4.5 
## v dfms   1.0.0
## -- Conflicts ---------------------------------------------------------------------------------------------------------------- fastverse_conflicts() --
## x xts::first() masks data.table::first()
## x xts::last()  masks data.table::last()
## x dfms::news() masks utils::news()
# Get Last November 2024 Vintage
download.file(&quot;https://github.com/coderaanalytics-models/SA-Nowcast/raw/refs/heads/main/vintages/econdata_nowcast_data_22_11_2024.xlsx&quot;,
              destfile = &quot;econdata_nowcast_data_22_11_2024.xlsx&quot;, mode = &quot;wb&quot;)
data_m &lt;- read_xlsx(&quot;econdata_nowcast_data_22_11_2024.xlsx&quot;, 
                    sheet = &quot;data_logdiff_m&quot;) |&gt; qDT() |&gt; as.xts()
ncol(data_m)
## [1] 54
data_q &lt;- read_xlsx(&quot;econdata_nowcast_data_22_11_2024.xlsx&quot;, 
                    sheet = &quot;data_logdiff_q&quot;) |&gt; qDT() |&gt; 
  fmutate(date = as.Date(date)) |&gt; 
  # dfms expects quarterly data to be released on the 3rd month of each quarter
  within(lubridate::month(date) &lt;- lubridate::month(date) + 2L) |&gt; as.xts()
colnames(data_q)
## [1] &quot;UNEMP&quot; &quot;GDP&quot;   &quot;RGDP&quot;
# The xts data format makes temporal merging and subsetting very easy
data &lt;- merge(data_m, data_q)[&quot;1990-02/&quot;]
# .. it also has a nice plot method
plot(scale(data), lwd = 1, ylim = c(-5, 5))</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/index_files/figure-html/unnamed-chunk-2-1.png" alt="" width="100%" /></p>
<p>The usual <em>dfms</em> workflow is starts with information criteria to determine the number of factors and the factor-VAR order</p>
<pre class="r"><code>(ic &lt;- ICr(data))
## Missing values detected: imputing data with tsnarmimp() with default settings
## Optimal Number of Factors (r) from Bai and Ng (2002) Criteria
## 
## IC1 IC2 IC3 
##   1   1  20
screeplot(ic)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/index_files/figure-html/unnamed-chunk-3-1.png" alt="" width="100%" /></p>
<pre class="r"><code>vars::VARselect(ic$F_pca[, 1:4])
## $selection
## AIC(n)  HQ(n)  SC(n) FPE(n) 
##      7      3      3      7 
## 
## $criteria
##                1         2        3        4        5        6        7        8        9       10
## AIC(n)  3.372492  2.959518 1.718657 1.667090 1.668591 1.636513 1.606690 1.618062 1.652979 1.673131
## HQ(n)   3.450602  3.100117 1.921743 1.932665 1.996653 2.027064 2.059729 2.133589 2.230994 2.313634
## SC(n)   3.569849  3.314762 2.231786 2.338105 2.497491 2.623300 2.751363 2.920620 3.113423 3.291460
## FPE(n) 29.151229 19.289241 5.577523 5.297771 5.306648 5.140434 4.991144 5.050561 5.233119 5.343569</code></pre>
<p>Since the data includes the COVID shock, the first principal component is very dominant, and the information criteria suggest 1 factor. However, since the screeplot drops a bit after the 4th eigenvalue, I opt for four factors, also given that series from the four sectors—real, financial, fiscal, and external—are represented in the dataset. The VAR order is selected as 3 by the HQ and SC criteria. I thus estimate the DFM with 4 factors and VAR(3) dynamics, including autoregressive errors of order 1 and an adjusted state-space form where quarterly variables are represented by unobserved monthly counterparts following Mariano and Murasawa (2003). Importantly for the later news decomposition, setting <code>max.missing = 1</code> prevents further rows from being dropped from the estimation dataset.<a href="#fn3" class="footnote-ref" id="fnref3"><sup>3</sup></a> These two features increase the size of the state-space, and thus computation time, a lot compared to simple estimation (which takes just 2 seconds), but the robust C++ backend still delivers a very respectable computation time of less than one minute for this most complex model case.</p>
<pre class="r"><code>system.time(DFM(data, r = 4, p = 3, max.missing = 1))
## Converged after 33 iterations.
##    user  system elapsed 
##   1.373   0.046   1.421
system.time({
dfm &lt;- DFM(data, r = 4, p = 3, idio.ar1 = TRUE, max.missing = 1,
           quarterly.vars = colnames(data_q))
})
## Converged after 26 iterations.
##    user  system elapsed 
##  32.635   0.533  33.225
summary(dfm)
## Mixed Frequency Dynamic Factor Model
## n = 57, nm = 54, nq = 3, T = 417, r = 4, p = 3
## %NA = 26.2695, %NAm = 23.8209
##    with AR(1) errors: mean(abs(rho)) = 0.296 
## 
## Call:  DFM(X = data, r = 4, p = 3, max.missing = 1, idio.ar1 = TRUE,      quarterly.vars = colnames(data_q))
## 
## Summary Statistics of Factors [F]
##       N    Mean  Median      SD       Min      Max
## f1  417  0.0358  0.0224  3.1427  -54.3033  25.3044
## f2  417  0.0148  0.0358  1.5887   -19.842     21.7
## f3  417  0.0464  0.0729  1.6763  -25.5152  12.9381
## f4  417  0.0239  0.0049  1.1397   -8.1336  12.0924
## 
## Factor Transition Matrix [A]
##       L1.f1   L1.f2   L1.f3   L1.f4     L2.f1    L2.f2   L2.f3   L2.f4   L3.f1    L3.f2    L3.f3    L3.f4
## f1 -0.47917 -0.4016 1.49438  0.4893 -0.970167 -0.64590  0.2723 -0.4588 -0.4225 -0.10730 -0.12302 -0.37666
## f2  0.07605 -0.4483 0.05939  0.1679 -0.008145 -0.30928  0.2850  0.1486  0.2977 -0.12425 -0.08882 -0.13348
## f3  0.42230  0.2774 0.02289  0.5551  0.080860 -0.02339 -0.4085  0.1032  0.1769  0.10793 -0.07216  0.10206
## f4  0.16261  0.1108 0.12947 -0.2689  0.005231  0.10899  0.1003 -0.3174  0.2039 -0.02751 -0.12524 -0.07243
## 
## Factor Covariance Matrix [cov(F)]
##           f1        f2        f3        f4
## f1   9.8763   -0.1549    0.2107    0.2422 
## f2  -0.1549    2.5241    0.1329    0.9066*
## f3   0.2107    0.1329    2.8101    0.4524*
## f4   0.2422    0.9066*   0.4524*   1.2990 
## 
## Factor Transition Error Covariance Matrix [Q]
##        u1      u2     u3      u4
## u1 7.6784  0.1027 0.9381  0.7389
## u2 0.1027  0.2946 0.1342 -0.2394
## u3 0.9381  0.1342 0.4943  0.1539
## u4 0.7389 -0.2394 0.1539  0.7417
## 
## Summary of Residual AR(1) Serial Correlations
##    N     Mean   Median      SD      Min     Max
##   57  -0.1739  -0.2529  0.2912  -0.6048  0.4128
## 
## Summary of Individual R-Squared&#39;s
##    N    Mean  Median      SD      Min     Max
##   57  0.5093  0.5816  0.3811  -0.0188  0.9995
plot(dfm, xlim = c(200, 415))</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/index_files/figure-html/unnamed-chunk-4-1.png" alt="" width="100%" /></p>
<pre class="r"><code>plot(dfm, type = &quot;individual&quot;, method = &quot;all&quot;)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/index_files/figure-html/unnamed-chunk-4-2.png" alt="" width="100%" /></p>
<p>The factor model is, ostensibly, heavily impacted by the COVID shock, although the extent to which this shock introduced error in the model parameters is not clear. For this demonstration at least, I will not modify the data to deal with the shock. Ostensibly, a significant amount of series still display autoregressive dynamics at <code>mean(abs(rho)) = 0.3</code>, with autoregressive parameters <span class="math inline">\(\rho\)</span> ranging from -0.6 to 0.41. As further described in the <a href="https://raw.githubusercontent.com/ropensci/dfms/main/vignettes/dynamic_factor_models_paper.pdf">theoretical vignette</a>, the model with autoregressive errors not only estimates these <span class="math inline">\(\rho\)</span> parameters for each series—saved in <code>dfm$rho</code>—but also the error series <span class="math inline">\(e_t\)</span> itself—saved in <code>dfm$e</code>—which are modeled as part of the state vector along with the factors. This implies that for these models one can make predictions using either the full state including the modeled residual values or using just the factors. The difference, in this case, is vast, with modeled residuals giving a much better fit to the data.</p>
<pre class="r"><code># Computing residual absolute ratio in percentage terms
rarp &lt;- fmedian(abs(resid(dfm))) / fmedian(abs(resid(dfm, use.full.state = FALSE))) * 100
descr(rarp)
## Dataset: rarp, 1 Variables, N = 57
## ------------------------------------------------------------------------------------------------------------------------------------------------------
## rarp (numeric): 
## Statistics
##    N  Ndist  Mean    SD   Min  Max  Skew  Kurt
##   57     57  0.04  0.06  0.01  0.3  2.41  8.89
## Quantiles
##     1%    5%   10%   25%   50%   75%   90%   95%   99%
##   0.01  0.01  0.01  0.01  0.02  0.05  0.13  0.16  0.27
## ------------------------------------------------------------------------------------------------------------------------------------------------------</code></pre>
<p>While using the full state (the new default) changes the interpretation of in-sample residuals and fitted values, it generally slightly improves the accuracy of dynamic predictions obtainable with <code>predict()</code>.</p>
<pre class="r"><code>plot(predict(dfm), xlim = c(400, 428), ylim = c(-2, 2))</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2026/01/29/releasing-dfms-1-0-fast-and-feature-rich-estimation-of-dynamic-factor-models-in-r/index_files/figure-html/unnamed-chunk-6-1.png" alt="" width="100%" /></p>
<p>Now, the main addition in <em>dfms</em> 1.0, as mentioned, is the news decomposition via <code>news()</code>. For this we first need to load the latest vintage and set it to the same range.</p>
<pre class="r"><code>download.file(&quot;https://github.com/coderaanalytics-models/SA-Nowcast/raw/refs/heads/main/vintages/econdata_nowcast_data_03_12_2024.xlsx&quot;,
              destfile = &quot;econdata_nowcast_data_03_12_2024.xlsx&quot;, mode = &quot;wb&quot;)
data_m_dec &lt;- read_xlsx(&quot;econdata_nowcast_data_03_12_2024.xlsx&quot;, sheet = &quot;data_logdiff_m&quot;) |&gt;
  qDT() |&gt; as.xts()
data_q_dec &lt;- read_xlsx(&quot;econdata_nowcast_data_03_12_2024.xlsx&quot;, sheet = &quot;data_logdiff_q&quot;) |&gt;
  qDT() |&gt; fmutate(date = as.Date(date)) |&gt; within(lubridate::month(date) &lt;- lubridate::month(date) + 2L) |&gt; as.xts()
data_dec &lt;- merge(data_m_dec, data_q_dec)[&quot;1990-02/2024-10&quot;]</code></pre>
<p>We could then either estimate a second DFM using this vintage and pass both model objects to <code>news()</code>, or simply pass the new vintage dataset to <code>news()</code> and it will internally estimate a second DFM of the exact same specification. The function supports one or multiple targets and, by default, chooses the last period in the dataset as the nowcasting target period.</p>
<pre class="r"><code>news_res &lt;- news(dfm, data_dec, target.vars = colnames(data_q))
## Converged after 26 iterations.
print(news_res)      # Multiple targets -&gt; dfm_news_list object
## DFM News (Multiple Targets)
## Target time: 417 
## Targets: 3 
## Standardized: FALSE 
##         y_old   y_new revision
## UNEMP -0.4463 -3.2878  -2.8415
## GDP    1.5853  2.0275   0.4421
## RGDP   0.0134  0.4465   0.4332
print(news_res$RGDP) # Single target -&gt; dfm_news object
## DFM News
## Target variable: RGDP 
## Target time: 417 
## Old forecast: 0.0134 
## New forecast: 0.4465 
## Revision: 0.4332 
## Standardized: FALSE
# Target period: 
index(data)[417]
## [1] &quot;2024-10-01 UTC&quot;
# Basically this is our prediction of RGDP if it were measured 
# across August-October 2024. To do a proper Q4 nowcast 
# we would need to extend the dataset by two months. </code></pre>
<p>Apart from the forecast revision, the object includes, for each target, a dataframe giving the news for each variable with revisions and its impact on the target forecast revision.</p>
<pre class="r"><code>series &lt;- read_xlsx(&quot;econdata_nowcast_data_03_12_2024.xlsx&quot;, sheet = &quot;series&quot;)
news_rgdp &lt;- news_res$RGDP$news_df |&gt; na_omit() |&gt; 
  join(slt(series, series, label, broad_sector, topic)) |&gt;
  colorder(series, label, broad_sector, topic)
## left join: x[series] 40/40 (100%) &lt;1:1st&gt; y[series] 40/57 (70.2%)

news_rgdp |&gt; tfmv(is.numeric, round, 4) |&gt; 
  fselect(label, topic, actual, forecast, news, gain, impact)
##                                             label            topic   actual forecast     news    gain  impact
## 1                                 Consumer Prices           Prices  -0.0247   0.4317  -0.4564  0.0026 -0.0012
## 2                        Final manufactured goods           Prices  -0.6941   0.1364  -0.8305  0.0349 -0.0290
## 3                     Coal and petroleum products           Prices  -4.0912  -1.3569  -2.7344  0.0072 -0.0198
## 4                                          Petrol           Prices  -6.0095  -1.3109  -4.6985  0.0053 -0.0247
## 5                                  Motor vehicles           Prices  -0.4008   0.4983  -0.8991  0.0001 -0.0001
## 6                                     Grand Total          Tourism   0.7678   1.7822  -1.0144  0.0001 -0.0001
## 7                                     Grand Total          Tourism   3.6389  -2.6752   6.3140  0.0040  0.0254
## 8                            Overnight (tourists)          Tourism   2.0994  -9.3852  11.4846  0.0025  0.0287
## 9                            Overnight (tourists)          Tourism   0.9646  -5.9568   6.9213  0.0001  0.0008
## 10                  Total income - Total industry          Tourism  -0.0664  -7.1929   7.1265  0.0036  0.0254
## 11        Stay units nights sold - Total industry          Tourism  -0.9814  -6.2616   5.2802  0.0039  0.0208
## 12                Occupancy rate - Total industry          Tourism  -0.2642  -6.3626   6.0984  0.0040  0.0243
## 13                              Leading indicator       Other Real   0.9353  -0.3900   1.3253  0.0099  0.0131
## 14                                 Total: Payload       Other Real   1.4904  -0.0783   1.5687  0.0046  0.0072
## 15       Total: Income for freight transportation       Other Real  -1.2610  -0.4410  -0.8199  0.0060 -0.0049
## 16                             Passenger journeys       Other Real  -6.9646  -2.6520  -4.3126  0.0028 -0.0121
## 17            Income for passenger transportation       Other Real -10.1055  -2.3960  -7.7095  0.0053 -0.0410
## 18                                             M0 Money and Credit  -0.3816  -0.2107  -0.1709  0.0121 -0.0021
## 19                   Total monetary (M3) deposits Money and Credit   0.3206   0.8764  -0.5558 -0.0281  0.0156
## 20          Claims on the domestic private sector Money and Credit  -0.7167   0.8042  -1.5208 -0.0169  0.0257
## 21 Domestic claims on the government sector (nsa) Money and Credit  -4.2307  -0.0585  -4.1722 -0.0020  0.0082
## 22   External position - net foreign assets (nsa)   Other Fiancial   0.6238   2.7275  -2.1037 -0.0001  0.0002
## 23                                          Total   Other Fiancial  65.1872   2.0438  63.1434  0.0047  0.2972
## 24                                  Total exports            Trade   2.5785  -0.7230   3.3015  0.0338  0.1117
## 25                                  Total imports            Trade  -3.7547   0.8862  -4.6409  0.0046 -0.0211
## 26                        Official Reserve Assets         Reserves  -0.9553   0.8221  -1.7774 -0.0003  0.0005
## 27                      Foreign currency reserves         Reserves  -2.2546   0.5593  -2.8139  0.0001 -0.0004
## 28                              Cash-flow revenue        Cash Flow  -3.3586  -0.5585  -2.8000  0.0082 -0.0231
## 29                          Cash-flow expenditure        Cash Flow  -2.4694   1.2563  -3.7258 -0.0005  0.0020
## 30        Cash-flow balance (deficit -/surplus +)        Cash Flow  -0.4770 -15.9455  15.4685  0.0004  0.0065
## 31                      Domestic government bonds        Financing   0.5948   0.0164   0.5784  0.0001  0.0000
## 32                        Foreign bonds and loans        Financing -64.4709   0.0312 -64.5021 -0.0001  0.0090
## 33            Treasury bills and short-term loans        Financing  -1.6063  12.1153 -13.7216 -0.0003  0.0047
## 34                        Change in cash balances        Financing   6.3097  -7.1304  13.4401  0.0012  0.0166
## 35                            Total net financing        Financing   0.0669   5.2526  -5.1856  0.0000 -0.0001
## 36                          Total gross loan debt             Debt   0.6452   0.9158  -0.2706  0.0142 -0.0038
## 37                            Domestic Marketable             Debt   0.7577   0.9854  -0.2278  0.0131 -0.0030
## 38                                   Foreign Debt             Debt   0.5219   1.4193  -0.8974  0.0005 -0.0004
## 39                        Domestic non-marketable             Debt  -4.3718  -1.8796  -2.4922 -0.0001  0.0002
## 40                              Unemployment rate       Other Real  -4.2689  -0.0486  -4.2204  0.0024 -0.0101</code></pre>
<p>The impact on the target (real GDP in this case) is the news (actual - forecast) times the gain (series weight). These impacts sum to (or their sum closely approximates), the difference between the nowcasts. Naturally, they can also be summed across sectors or other groups of series to provide a more interpretable nowcast news decomposition.</p>
<pre class="r"><code>with(news_res$RGDP, (y_new - y_old) / sum(news_df$impact))
##      RGDP 
## 0.9696488

news_rgdp |&gt; 
  collap(impact ~ broad_sector + topic, fsum) |&gt;
  fmutate(impact = round(impact, 3)) |&gt;
  fsubset(order(-abs(impact)))
##    broad_sector            topic impact
## 1     Financial   Other Fiancial  0.297
## 2          Real          Tourism  0.125
## 3      External            Trade  0.091
## 4          Real           Prices -0.075
## 5          Real       Other Real -0.048
## 6     Financial Money and Credit  0.047
## 7        Fiscal        Financing  0.030
## 8        Fiscal        Cash Flow -0.015
## 9        Fiscal             Debt -0.007
## 10     External         Reserves  0.000</code></pre>
<p>As mentioned, this dataset likely needs further processing to do something about the stark shocks before nowcasting using <em>dfms</em>—issues that more current methodologies attempt to address in more sophisticated ways. Nevertheless, the package now provides a full, efficient, and user-friendly implementation of the Banbura and Modugno (2014) methodology in R—which remains the baseline as far as linear Gaussian factor and nowcasting models are concerned. I thus hope that it will be useful to researchers and practitioners interested in DFMs and nowcasting.</p>
</div>
<div id="references" class="section level2">
<h2>References</h2>
<p>Doz, C., Giannone, D., &amp; Reichlin, L. (2011). A two-step estimator for large approximate dynamic factor models based on Kalman filtering. <em>Journal of Econometrics, 164</em>(1), 188-205.</p>
<p>Doz, C., Giannone, D., &amp; Reichlin, L. (2012). A quasi-maximum likelihood approach for large, approximate dynamic factor models. <em>Review of Economics and Statistics, 94</em>(4), 1014-1024.</p>
<p>Banbura, M., &amp; Modugno, M. (2014). Maximum likelihood estimation of factor models on datasets with arbitrary pattern of missing data. <em>Journal of Applied Econometrics, 29</em>(1), 133-160.</p>
<p>Mariano, R. S., &amp; Murasawa, Y. (2003). A new coincident index of business cycles based on monthly and quarterly series. <em>Journal of Applied Econometrics, 18</em>(4), 427-443.</p>
<p>Bai, J., Ng, S. (2002). Determining the Number of Factors in Approximate Factor Models. <em>Econometrica, 70</em>(1), 191-221.</p>
<p>Onatski, A. (2010). Determining the number of factors from empirical distribution of eigenvalues. <em>The Review of Economics and Statistics, 92</em>(4), 1004-1016.</p>
<p>Stock, J. H., &amp; Watson, M. W. (2016). Dynamic Factor Models, Factor-Augmented Vector Autoregressions, and Structural Vector Autoregressions in Macroeconomics. <em>Handbook of Macroeconomics, 2</em>, 415–525.</p>
</div>
<div class="footnotes footnotes-end-of-document">
<hr />
<ol>
<li id="fn1"><p><code>ICr()</code> returns PCA factor estimates on an imputed dataset using <code>tsnarmimp()</code> which are used for the information criteria following Bai and Ng (2002), and the eigenvalues can be plotted with <code>screeplot()</code>. Further, <code>vars::VARselect()</code> can be applied to a suitable number of PCA factor estimates to select the optimal lag order for the DFM.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>The <a href="https://www.newyorkfed.org/research/policy/nowcast/#nowcast/">NY-Fed Nowcast</a>, which used Banbura and Modugno (2014)’s methodology, was temporarily suspended between September 2021 and September 2023 following the huge COVID-19 shock and only recently reintroduced with these modern DFM extensions. <a href="https://econweb.umd.edu/~drechsel/papers/advances.pdf">This paper</a> by Antolin-Diaz, Drechsel, and Petrella is also a good reference for current developments.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
<li id="fn3"><p>By default <code>DFM()</code> drops rows missing more than 80% of series at the beginning or end of the sample.<a href="#fnref3" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Introducing OptimalTransportNetworks.jl: Optimal Transport Networks in Spatial Equilibrium</title>
      <link>https://sebkrantz.github.io/Rblog/2024/09/22/introducing-optimaltransportnetworks-jl-optimal-transport-networks-in-spatial-equilibrium/</link>
      <pubDate>Sun, 22 Sep 2024 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2024/09/22/introducing-optimaltransportnetworks-jl-optimal-transport-networks-in-spatial-equilibrium/</guid>
      <description><![CDATA[ 


<p>I’m happy to announce the release of <a href="https://github.com/OptimalTransportNetworks/OptimalTransportNetworks.jl">OptimalTransportNetworks.jl</a>, a modern Julia translation of the MATLAB <a href="https://github.com/OptimalTransportNetworks/OptimalTransportNetworkToolbox">OptimalTransportNetworkToolbox</a> implementing the quantitative spatial model and algorithms described in</p>
<p>Fajgelbaum, P. D., &amp; Schaal, E. (2020). Optimal transport networks in spatial equilibrium. <em>Econometrica, 88</em>(4), 1411-1452.</p>
<p>The model/software uses duality principles to <strong>optimize over the space of networks</strong>, nesting an optimal flows problem and a neoclassical general-equilibrium trade model into a global network design problem to derive the optimal (welfare maximizing) transport network (extension) from any set of economic fundamentals.<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a></p>
<p>For more information about the model see <a href="https://github.com/OptimalTransportNetworks/OptimalTransportNetworkToolbox/tree/main/docs/paper_materials">this folder</a> and the <a href="https://raw.githubusercontent.com/OptimalTransportNetworks/OptimalTransportNetworkToolbox/main/docs/User%20Guide.pdf">MATLAB User Guide</a>.</p>
<p>The model is the first of its kind and a pathbreaking contribution towards the welfare maximizing planning of transport infrastructure. Its creation has been funded by the European Union through an <a href="https://cordis.europa.eu/project/id/804095">ERC Research Grant</a>.</p>
<div id="example" class="section level2">
<h2>Example</h2>
<p>The code for this example is in <a href="https://github.com/OptimalTransportNetworks/OptimalTransportNetworks.jl/blob/main/examples/example04.jl">example04.jl</a>. See the <a href="https://github.com/OptimalTransportNetworks/OptimalTransportNetworks.jl/blob/main/examples">examples folder</a> for more examples.</p>
<p>This plot shows the endowments on a map-graph: circle size is population, circle colour is productivity (the central node is more productive), the black lines indicate geographic barriers, and the background is shaded according to the cost of network building (elevation), indicating a mountain in the upper right corner.</p>
<p align="center">
<img src="example_04_setup.png" alt="example_04_setup.png"/>
</p>
<p>The following plot shows the optimal network after 200 iterations, keeping population fixed. The size of nodes indicates consumption in each node. The blue lines are the proposed roads and their thickness indicates their capacity.<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a></p>
<p align="center">
<img src="example_04_solution.png" alt="example_04_solution.png"/>
</p>
<p>You may wonder why are there multiple roads to certain cities? The reason is decreasing returns to infrastructure (<span class="math inline">\(\beta \leq \gamma\)</span>, where <span class="math inline">\(\beta\)</span> governs the strength of congestion forces and <span class="math inline">\(\gamma\)</span> the returns to infrastructure). This guarantees the global convexity of the nested optimization problem. In this example <span class="math inline">\(\beta = \gamma = 1\)</span>.</p>
<p>However, we can assume increasing returns to infrastructure, e.g., by setting <span class="math inline">\(\gamma = 2\)</span>. In this case the nested optimization problem is not globally convex, and the software automatically calls a Simulated Annealing algorithm to approximate the global solution following convex optimization. The result:</p>
<p align="center">
<img src="example_04_solution_annealing.png" alt="example_04_solution_annealing.png"/>
</p>
<p>In reality, which assumption is reasonable depends on the setting. Inside cities, estimates of the empirical relationship between speed, roads, and vehicles by <a href="https://direct.mit.edu/rest/article-abstract/100/4/725/58505/Speed?redirectedFrom=fulltext">Couture, Duranton, and Turner (2018)</a> using U.S. data imply the DRS/convex case, i.e., congestion forces are strong and it is better to ‘build several smaller roads than a few huge roads’. Outside of cities there is (to my knowledge) not much evidence on this matter.</p>
</div>
<div id="customization-and-research-use" class="section level2">
<h2>Customization and Research Use</h2>
<p>While the examples are nice and illustrative, the library permits users to provide custom graphs which must not be dense/fully connected.</p>
<p>My own recent research on <a href="https://github.com/SebKrantz/OptimalAfricanRoads">Optimal Investments in Africa’s Road Network</a>, considers, amongst other scenarios, optimal $10 billion (2015 USD) upgrades to the trans-African transport network using a sparse graph derived from fastest Open Street Map routes between 47 large (port-)cities - see the <a href="https://github.com/SebKrantz/OptimalAfricanRoads/blob/main/code/11_GE_simulation_trans_african/optimal_trans_african_networks_largest_pcities.jl">simulation code</a>, the following figures are taken from the <a href="https://www.dropbox.com/scl/fi/0mbk75tb4t5g9vl1tlxnx/presentation_WCA_CEPR.pdf?rlkey=sbispafuhwvslzsol0n2ey1hi&amp;dl=0">slides</a>.</p>
<p>I find that optimal upgrades depend not so much on the returns to infrastructure (as the network is fixed/sparse), but rather on heterogeneous road construction costs, the elasticity of substitution between different city-goods (<span class="math inline">\(\sigma\)</span> - a trade elasticity) and whether social planners are inequality averse (<span class="math inline">\(\rho = 2\)</span> instead of <span class="math inline">\(0\)</span>).</p>
<p align="center">
<img src="Trans_African_Upgrade_Sigma3.8.png" alt="Trans_African_Upgrade_Sigma3.8.png" style="max-width: 100%"/>
<img src="Trans_African_Upgrade_Sigma2.png" alt="Trans_African_Upgrade_Sigma2.png" style="max-width: 100%"/>
<img src="Trans_African_Upgrade_Flows.png" alt="Trans_African_Upgrade_Flows.png" style="max-width: 100%"/>
</p>
<p>I have forked available replication materials for papers using this framework on the <a href="https://github.com/OptimalTransportNetworks">OptimalTransportNetworks organization</a>. Further works I am aware of include:</p>
<p>Graff, T. (2024). Spatial inefficiencies in Africa’s trade network. <em>Journal of Development Economics</em>, 103319. Available at <a href="https://tilmangraff.github.io/" class="uri">https://tilmangraff.github.io/</a></p>
<p>Gorton, Nicole and Ianchovichina, Elena, Trade Networks in Latin America: Spatial Inefficiencies and Optimal Expansions. Available at SSRN: <a href="https://ssrn.com/abstract=4041108" class="uri">https://ssrn.com/abstract=4041108</a> or <a href="http://dx.doi.org/10.2139/ssrn.4041108" class="uri">http://dx.doi.org/10.2139/ssrn.4041108</a></p>
<p>Kreindler, G., Gaduh, A., Graff, T., Hanna, R., &amp; Olken, B. A. (2023). Optimal Public Transportation Networks: Evidence from the World’s Largest Bus Rapid Transit System in Jakarta (No. w31369). National Bureau of Economic Research. Available at <a href="https://www.nber.org/papers/w31369" class="uri">https://www.nber.org/papers/w31369</a></p>
</div>
<div id="going-forward" class="section level2">
<h2>Going Forward</h2>
<p>Community efforts to further improve the code are very welcome. I started the GitHub organization <a href="https://github.com/OptimalTransportNetworks">OptimalTransportNetworks</a> to maintain both the Julia and MATLAB libraries, but also to gather/start similar contributions, including replication materials of relevant research papers.</p>
<p>The overall aim of the organization is to develop powerful open tools to solve (large-scale) transport infrastructure allocation problems from an economics/welfare perspective. Global infrastructure investment needs are estimated at 3.5% of global GDP - around 100 trillion USD by 2040 according to the <a href="https://cdn.gihub.org/outlook/live/methodology/Global+Infrastructure+Outlook+factsheet+-+June+2018.pdf">Global Infrastructure Outlook</a> - and transport investments are a good part of that - e.g. the African Development Bank estimates that <a href="https://www.afdb.org/en/news-and-events/scaling-financing-key-accelerating-africas-structural-transformation-73244">73% of Africa’s estimated ~$400B annual financing gap until 2030 is in transportation</a>. Thus it is high time to start thinking about economic/welfare maximizing planning and develop relevant open tools. If you’re interested in contributing/maintaining or have new ideas/research/approaches, don’t hesitate to reach out - happy to welcome smart minds to the organization.</p>
</div>
<div class="footnotes footnotes-end-of-document">
<hr />
<ol>
<li id="fn1"><p>population per location, productivity per location for each of <em>N</em> traded goods, endowment of a non-traded good, and (optionally) a pre-existing transport network.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>Which is necessarily a continuous measure. We may also think in terms of of road speed, with “thicker” roads being the faster ones.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Releasing collapse 2.0: Blazing Fast Joins, Reshaping, and Enhanced R</title>
      <link>https://sebkrantz.github.io/Rblog/2023/10/17/releasing-collapse-2-0-blazing-fast-joins-reshaping-and-enhanced-r/</link>
      <pubDate>Tue, 17 Oct 2023 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2023/10/17/releasing-collapse-2-0-blazing-fast-joins-reshaping-and-enhanced-r/</guid>
      <description><![CDATA[ 


<p>I’m excited to announce the release of <a href="https://sebkrantz.github.io/collapse/"><em>collapse</em></a> 2.0, adding blazing fast joins, pivots, flexible namespace and many other features, including a brand new <a href="https://sebkrantz.github.io/collapse/"><em>website</em></a>, an updated <a href="https://raw.githubusercontent.com/SebKrantz/collapse/master/misc/collapse%20cheat%20sheet/collapse_cheat_sheet.pdf">cheat sheet</a>, and a new <a href="https://sebkrantz.github.io/collapse/articles/collapse_for_tidyverse_users.html">vignette</a> aimed at <em>tidyverse</em> users.</p>
<p>In the 3.5 years after the first release of <em>collapse</em> 1.0 to CRAN in March 2020, the package has seen 10 major updates, and become a remarkable piece of statistical software that is robust, stable, lightweight, fast, statistically advanced, comprehensive, flexible, class-agnostic, verbose and well-documented. It is profoundly able to deal with rich (multi-level, irregular, weighted, nested, labelled, and missing) scientific data, and can enhance the workflow of every R user.</p>
<p>The addition of rich, fast, and verbose joins and pivots in this release, together with secure interactive namespace masking and extensive global configurability, should enable many R users to use it as a workhorse package for data manipulation and statistical computing tasks.</p>
<p>In this post, I briefly introduce the core new features of this release and end with some reflections on why I created the package and think that its approach towards speeding up and enriching R is more encompassing than others.</p>
<div id="fast-class-agnostic-and-verbose-table-joins" class="section level1">
<h1>Fast, Class-Agnostic, and Verbose Table Joins</h1>
<p>Joins in <em>collapse</em> has been a much-requested feature. Still, I was long hesitant to take them on because they are complex, high-risk, operations, and I was unsure on how to go about them or provide an implementation that would satisfy my own ambitious demands to their performance, generality and verbosity.</p>
<p>I am glad that, following repeated requests, I have overcome these hesitations and designed an implementation - inspired by <a href="https://pola-rs.github.io/polars/py-polars/html/reference/dataframe/api/polars.DataFrame.join.html"><em>polars</em></a> - that I am very satisfied with. <em>collapse</em>’s join function is simply called <code>join()</code>, and provides 6 types of joins (left, inner, full, right, semi and anti), controlled by a <code>how</code> argument - the default being a left join. It also provides two separate join algorithms: a vectorized hash join (the default, <code>sort = FALSE</code>) and a sort-merge-join (<code>sort = TRUE</code>). The join-column argument is called <code>on</code>, and, if left empty, selects columns present in both datasets. An example with generated data follows:</p>
<pre class="r"><code>library(collapse)
df1 &lt;- data.frame(
  id1 = c(1, 1, 2, 3),
  id2 = c(&quot;a&quot;, &quot;b&quot;, &quot;b&quot;, &quot;c&quot;),
  name = c(&quot;John&quot;, &quot;Jane&quot;, &quot;Bob&quot;, &quot;Carl&quot;),
  age = c(35, 28, 42, 50)
)
df2 &lt;- data.frame(
  id1 = c(1, 2, 3, 3),
  id2 = c(&quot;a&quot;, &quot;b&quot;, &quot;c&quot;, &quot;e&quot;),
  salary = c(60000, 55000, 70000, 80000),
  dept = c(&quot;IT&quot;, &quot;Marketing&quot;, &quot;Sales&quot;, &quot;IT&quot;)
)

# Different types of joins
for (i in c(&quot;left&quot;,&quot;inner&quot;,&quot;right&quot;,&quot;full&quot;,&quot;semi&quot;,&quot;anti&quot;))
    join(df1, df2, how = i) |&gt; print()
## left join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 name age salary      dept
## 1   1   a John  35  60000        IT
## 2   1   b Jane  28     NA      &lt;NA&gt;
## 3   2   b  Bob  42  55000 Marketing
## 4   3   c Carl  50  70000     Sales
## inner join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 name age salary      dept
## 1   1   a John  35  60000        IT
## 2   2   b  Bob  42  55000 Marketing
## 3   3   c Carl  50  70000     Sales
## right join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 name age salary      dept
## 1   1   a John  35  60000        IT
## 2   2   b  Bob  42  55000 Marketing
## 3   3   c Carl  50  70000     Sales
## 4   3   e &lt;NA&gt;  NA  80000        IT
## full join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 name age salary      dept
## 1   1   a John  35  60000        IT
## 2   1   b Jane  28     NA      &lt;NA&gt;
## 3   2   b  Bob  42  55000 Marketing
## 4   3   c Carl  50  70000     Sales
## 5   3   e &lt;NA&gt;  NA  80000        IT
## semi join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 name age
## 1   1   a John  35
## 2   2   b  Bob  42
## 3   3   c Carl  50
## anti join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 name age
## 1   1   b Jane  28</code></pre>
<p>Notice how, by default (<code>verbose = 1</code>), a compact summary of the operation is printed, indicating the type of join, the datasets and columns, and the number and percentage of records from each dataset matched in the join operation. <code>join()</code> also preserves the attributes of the first argument (<code>x</code>) and the order of columns and rows (default <code>keep.col.order = TRUE</code>, <code>sort = FALSE</code>) in it. We can thus think of a join operation as adding columns to a data frame-like object (<code>x</code>) from another similar object (<code>y</code>) .</p>
<p>There are several additional options to increase verbosity and assimilate the join operation:</p>
<pre class="r"><code># verbose = 2 also shows classes, allowing you to detect implicit conversions (inside fmatch())
join(df1, df2, how = &quot;left&quot;, verbose = 2)
## left join: df1[id1:numeric, id2:character] 3/4 (75%) &lt;m:m&gt; df2[id1:numeric, id2:character] 3/4 (75%)
##   id1 id2 name age salary      dept
## 1   1   a John  35  60000        IT
## 2   1   b Jane  28     NA      &lt;NA&gt;
## 3   2   b  Bob  42  55000 Marketing
## 4   3   c Carl  50  70000     Sales

# Adding join column: useful especially for full join
join(df1, df2, how = &quot;full&quot;, column = TRUE)
## full join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 name age salary      dept   .join
## 1   1   a John  35  60000        IT matched
## 2   1   b Jane  28     NA      &lt;NA&gt;     df1
## 3   2   b  Bob  42  55000 Marketing matched
## 4   3   c Carl  50  70000     Sales matched
## 5   3   e &lt;NA&gt;  NA  80000        IT     df2

# Custom column + rearranging
join(df1, df2, how = &quot;full&quot;, column = list(&quot;join&quot;, c(&quot;x&quot;, &quot;y&quot;, &quot;x_y&quot;)), 
     keep.col.order = FALSE)
## full join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
##   id1 id2 join name age salary      dept
## 1   1   a  x_y John  35  60000        IT
## 2   1   b    x Jane  28     NA      &lt;NA&gt;
## 3   2   b  x_y  Bob  42  55000 Marketing
## 4   3   c  x_y Carl  50  70000     Sales
## 5   3   e    y &lt;NA&gt;  NA  80000        IT

# Attaching match attribute
str(join(df1, df2, attr = TRUE))
## left join: df1[id1, id2] 3/4 (75%) &lt;m:m&gt; df2[id1, id2] 3/4 (75%)
## &#39;data.frame&#39;:	4 obs. of  6 variables:
##  $ id1   : num  1 1 2 3
##  $ id2   : chr  &quot;a&quot; &quot;b&quot; &quot;b&quot; &quot;c&quot;
##  $ name  : chr  &quot;John&quot; &quot;Jane&quot; &quot;Bob&quot; &quot;Carl&quot;
##  $ age   : num  35 28 42 50
##  $ salary: num  60000 NA 55000 70000
##  $ dept  : chr  &quot;IT&quot; NA &quot;Marketing&quot; &quot;Sales&quot;
##  - attr(*, &quot;join.match&quot;)=List of 3
##   ..$ call   : language join(x = df1, y = df2, attr = TRUE)
##   ..$ on.cols:List of 2
##   .. ..$ x: chr [1:2] &quot;id1&quot; &quot;id2&quot;
##   .. ..$ y: chr [1:2] &quot;id1&quot; &quot;id2&quot;
##   ..$ match  : &#39;qG&#39; int [1:4] 1 NA 2 3
##   .. ..- attr(*, &quot;N.nomatch&quot;)= int 1
##   .. ..- attr(*, &quot;N.groups&quot;)= int 4
##   .. ..- attr(*, &quot;N.distinct&quot;)= int 3</code></pre>
<p>Finally, it is possible to validate the join operation to be either one of <code>"m:m"</code> (default, no checks), <code>"1:m"</code>, <code>"m:1"</code> or <code>"1:1"</code>. For example:</p>
<pre class="r"><code>join(df1, rowbind(df2, df2), validate = &quot;1:1&quot;)
## Error in join(df1, rowbind(df2, df2), validate = &quot;1:1&quot;): Join is not 1:1: df1 (x) is unique on the join columns; rowbind (y) is not unique on the join columns</code></pre>
<p>Another check being automatically executed inside the workhorse function <code>fmatch()</code> (if <code>sort = FALSE</code>) is for overidentified join conditions, i.e., if the records are more than identified by the join columns. For example if we added <code>"name"</code> and <code>"dept"</code> to the join condition, this would issue a warning as the match is already identified by <code>"id1"</code> and <code>"id2"</code>:</p>
<pre class="r"><code>join(df1, df2, on = c(&quot;id1&quot;, &quot;id2&quot;, &quot;name&quot; = &quot;dept&quot;), how = &quot;left&quot;)
## Warning in fmatch(x[ixon], y[iyon], nomatch = NA_integer_, count = count, : Overidentified match/join: the first 2 of 3 columns uniquely match the records. With overid &gt; 0, fmatch() continues to
## match columns. Consider removing columns or setting overid = 0 to terminate the algorithm after 2 columns (the results may differ, see ?fmatch). Alternatively set overid = 2 to silence this warning.
## left join: df1[id1, id2, name] 0/4 (0%) &lt;m:m&gt; df2[id1, id2, dept] 0/4 (0%)
##   id1 id2 name age salary
## 1   1   a John  35     NA
## 2   1   b Jane  28     NA
## 3   2   b  Bob  42     NA
## 4   3   c Carl  50     NA</code></pre>
<p>The warning can be silenced by passing <code>overid = 2</code> to <code>join()</code>. To see better where this may be useful, consider the following example using <code>fmatch()</code>.</p>
<pre class="r"><code>df1 &lt;- data.frame(
  id1 = c(1, 1, 2, 3),
  id2 = c(&quot;a&quot;, &quot;b&quot;, &quot;b&quot;, &quot;c&quot;),
  name = c(&quot;John&quot;, &quot;Bob&quot;, &quot;Jane&quot;, &quot;Carl&quot;)
)
df2 &lt;- data.frame(
  id1 = c(1, 2, 3, 3),
  id2 = c(&quot;a&quot;, &quot;b&quot;, &quot;c&quot;, &quot;e&quot;),
  name = c(&quot;John&quot;, &quot;Janne&quot;, &quot;Carl&quot;, &quot;Lynne&quot;)
)

# This gives an overidentification warning: columns 1:2 identify the data
fmatch(df1, df2)
## Warning in fmatch(df1, df2): Overidentified match/join: the first 2 of 3 columns uniquely match the records. With overid &gt; 0, fmatch() continues to match columns. Consider removing columns or setting
## overid = 0 to terminate the algorithm after 2 columns (the results may differ, see ?fmatch). Alternatively set overid = 2 to silence this warning.
## [1]  1 NA NA  3
# This just runs through without warning
fmatch(df1, df2, overid = 2)
## [1]  1 NA NA  3
# This terminates computation after first 2 columns
fmatch(df1, df2, overid = 0)
## [1]  1 NA  2  3
fmatch(df1[1:2], df2[1:2])  # Same thing!
## [1]  1 NA  2  3
# -&gt; note that here we get an additional match based on the unique ids,
# which we didn&#39;t get before because &quot;Jane&quot; != &quot;Janne&quot;</code></pre>
<p>So, in summary, the implementation of joins on <em>collapse</em> as provided by the <code>join()</code> function is not only blazing fast<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a> and class-agnostic but also allows you to verify all aspects of this high-risk operation.</p>
</div>
<div id="advanced-pivots" class="section level1">
<h1>Advanced Pivots</h1>
<p>The second big addition in <em>collapse</em> 2.0 is <code>pivot()</code>, which provides advanced data reshaping capabilities in a single parsimonious API. Notably, it supports longer-, wider-, and recast-pivoting functionality and can accommodate variable labels in the reshaping process.</p>
<p>Fortunately, <em>collapse</em> supplies a perfect test dataset to illustrate these capabilities: the 2014 <a href="https://www.rug.nl/ggdc/structuralchange/previous-sector-database/10-sector-2014">Groningen Growth and Development Centre 10-Sector Database</a>, which provides sectoral employment and value-added series for 10 broad sectors in 43 countries:</p>
<pre class="r"><code>head(GGDC10S)
##   Country Regioncode             Region Variable Year      AGR      MIN       MAN        PU       CON      WRT      TRA     FIRE      GOV      OTH      SUM
## 1     BWA        SSA Sub-saharan Africa       VA 1960       NA       NA        NA        NA        NA       NA       NA       NA       NA       NA       NA
## 2     BWA        SSA Sub-saharan Africa       VA 1961       NA       NA        NA        NA        NA       NA       NA       NA       NA       NA       NA
## 3     BWA        SSA Sub-saharan Africa       VA 1962       NA       NA        NA        NA        NA       NA       NA       NA       NA       NA       NA
## 4     BWA        SSA Sub-saharan Africa       VA 1963       NA       NA        NA        NA        NA       NA       NA       NA       NA       NA       NA
## 5     BWA        SSA Sub-saharan Africa       VA 1964 16.30154 3.494075 0.7365696 0.1043936 0.6600454 6.243732 1.658928 1.119194 4.822485 2.341328 37.48229
## 6     BWA        SSA Sub-saharan Africa       VA 1965 15.72700 2.495768 1.0181992 0.1350976 1.3462312 7.064825 1.939007 1.246789 5.695848 2.678338 39.34710

namlab(GGDC10S, N = TRUE, Ndistinct = TRUE)
##      Variable    N Ndist                                                 Label
## 1     Country 5027    43                                               Country
## 2  Regioncode 5027     6                                           Region code
## 3      Region 5027     6                                                Region
## 4    Variable 5027     2                                              Variable
## 5        Year 5027    67                                                  Year
## 6         AGR 4364  4353                                          Agriculture 
## 7         MIN 4355  4224                                                Mining
## 8         MAN 4355  4353                                         Manufacturing
## 9          PU 4354  4237                                             Utilities
## 10        CON 4355  4339                                          Construction
## 11        WRT 4355  4344                         Trade, restaurants and hotels
## 12        TRA 4355  4334                  Transport, storage and communication
## 13       FIRE 4355  4349 Finance, insurance, real estate and business services
## 14        GOV 3482  3470                                   Government services
## 15        OTH 4248  4238               Community, social and personal services
## 16        SUM 4364  4364                               Summation of sector GDP</code></pre>
<p>Evidently, the data is supplied in a format where two variables, employment and value-added, are stacked in each sector column. The data is also labeled, with descriptions attached as <code>"label"</code> attributes (retrievable using <code>vlabels()</code> or, together with names, using <code>namlab()</code>).</p>
<p>There are 3 different ways to reshape this data to make it easier to analyze. The first is to simply melt it into a long frame, e.g. for plotting with <code>ggplot2</code>:</p>
<pre class="r"><code># Pivot Longer
pivot(GGDC10S, ids = 1:5, 
      names = list(variable = &quot;Sectorcode&quot;, value = &quot;Value&quot;), 
      labels = &quot;Sector&quot;, how = &quot;longer&quot;, na.rm = TRUE) |&gt; head()
##   Country Regioncode             Region Variable Year Sectorcode       Sector    Value
## 1     BWA        SSA Sub-saharan Africa       VA 1964        AGR Agriculture  16.30154
## 2     BWA        SSA Sub-saharan Africa       VA 1965        AGR Agriculture  15.72700
## 3     BWA        SSA Sub-saharan Africa       VA 1966        AGR Agriculture  17.68066
## 4     BWA        SSA Sub-saharan Africa       VA 1967        AGR Agriculture  19.14591
## 5     BWA        SSA Sub-saharan Africa       VA 1968        AGR Agriculture  21.09957
## 6     BWA        SSA Sub-saharan Africa       VA 1969        AGR Agriculture  21.86221</code></pre>
<p>Note how specifying the <code>labels</code> argument created a column that captures the sector descriptions, which would otherwise be lost in the reshaping process, and <code>na.rm = TRUE</code> removed missing values in the long frame. I note without demonstration that this operation has an exact reverse operation: <code>pivot(long_df, 1:5, "Value", "Sector", "Description", how = "wider")</code>.</p>
<p>The second way to reshape the data is to create a wider frame with sector-variable columns:</p>
<pre class="r"><code># Pivot Wider
pivot(GGDC10S, ids = 1:5, names = &quot;Variable&quot;, how = &quot;wider&quot;, na.rm = TRUE) |&gt; 
  namlab(N = TRUE, Ndistinct = TRUE)
##      Variable    N Ndist                                                 Label
## 1     Country 3376    36                                               Country
## 2  Regioncode 3376     6                                           Region code
## 3      Region 3376     6                                                Region
## 4    Variable 3376     2                                              Variable
## 5        Year 3376    67                                                  Year
## 6      AGR_VA 1702  1700                                          Agriculture 
## 7     AGR_EMP 1674  1669                                          Agriculture 
## 8      MIN_VA 1702  1641                                                Mining
## 9     MIN_EMP 1674  1622                                                Mining
## 10     MAN_VA 1702  1702                                         Manufacturing
## 11    MAN_EMP 1674  1672                                         Manufacturing
## 12      PU_VA 1702  1665                                             Utilities
## 13     PU_EMP 1674  1615                                             Utilities
## 14     CON_VA 1702  1693                                          Construction
## 15    CON_EMP 1674  1668                                          Construction
## 16     WRT_VA 1702  1695                         Trade, restaurants and hotels
## 17    WRT_EMP 1674  1670                         Trade, restaurants and hotels
## 18     TRA_VA 1702  1694                  Transport, storage and communication
## 19    TRA_EMP 1674  1662                  Transport, storage and communication
## 20    FIRE_VA 1702  1696 Finance, insurance, real estate and business services
## 21   FIRE_EMP 1674  1674 Finance, insurance, real estate and business services
## 22     GOV_VA 1702  1698                                   Government services
## 23    GOV_EMP 1674  1666                                   Government services
## 24     OTH_VA 1702  1695               Community, social and personal services
## 25    OTH_EMP 1674  1671               Community, social and personal services
## 26     SUM_VA 1702  1702                               Summation of sector GDP
## 27    SUM_EMP 1674  1674                               Summation of sector GDP</code></pre>
<p>Note how the variable labels were copied to each of the two variables created for each sector. It is also possible to pass argument <code>transpose = c("columns", "names")</code> to change the order of columns and/or naming of the casted columns. Wide pivots where multiple columns are cast do not have a well-defined reverse operation. It may nevertheless be very useful to analyze individual sectors.</p>
<p>The third useful way to reshape this data for analysis is to recast it such that each variable goes into a separate column and the sectors are stacked in one column:</p>
<pre class="r"><code># Pivot Recast
recast_df = pivot(GGDC10S, values = 6:16, 
      names = list(from = &quot;Variable&quot;, to = &quot;Sectorcode&quot;),
      labels = list(to = &quot;Sector&quot;), how = &quot;recast&quot;, na.rm = TRUE)
head(recast_df)
##   Country Regioncode             Region Year Sectorcode       Sector       VA      EMP
## 1     BWA        SSA Sub-saharan Africa 1964        AGR Agriculture  16.30154 152.1179
## 2     BWA        SSA Sub-saharan Africa 1965        AGR Agriculture  15.72700 153.2971
## 3     BWA        SSA Sub-saharan Africa 1966        AGR Agriculture  17.68066 153.8867
## 4     BWA        SSA Sub-saharan Africa 1967        AGR Agriculture  19.14591 155.0659
## 5     BWA        SSA Sub-saharan Africa 1968        AGR Agriculture  21.09957 156.2451
## 6     BWA        SSA Sub-saharan Africa 1969        AGR Agriculture  21.86221 157.4243</code></pre>
<p>This is useful, for example, if we wanted to run a regression with sector-fixed effects. The code to reverse this pivot is</p>
<pre class="r"><code># Reverse Pivot Recast 
pivot(recast_df, values = c(&quot;VA&quot;, &quot;EMP&quot;), 
      names = list(from = &quot;Sectorcode&quot;, to = &quot;Variable&quot;),
      labels = list(from = &quot;Sector&quot;), how = &quot;recast&quot;) |&gt; head(3)
##   Country Regioncode             Region Year Variable      AGR      MIN       MAN        PU       CON      WRT      TRA     FIRE      GOV      OTH      SUM
## 1     BWA        SSA Sub-saharan Africa 1964       VA 16.30154 3.494075 0.7365696 0.1043936 0.6600454 6.243732 1.658928 1.119194 4.822485 2.341328 37.48229
## 2     BWA        SSA Sub-saharan Africa 1965       VA 15.72700 2.495768 1.0181992 0.1350976 1.3462312 7.064825 1.939007 1.246789 5.695848 2.678338 39.34710
## 3     BWA        SSA Sub-saharan Africa 1966       VA 17.68066 1.970343 0.8038415 0.2026464 1.3462312 8.271015 2.154452 1.358193 6.365948 2.993436 43.14677</code></pre>
<p>This showcased just some of the functionality of <code>pivot()</code>, more extensive examples are available in the <a href="https://sebkrantz.github.io/collapse/reference/pivot.html">documentation</a> (<code>?pivot</code>). But the above is enough to demonstrate this unified API’s power and flexibility; it is also blazing fast.</p>
</div>
<div id="global-configurability-and-interactive-namespace-masking" class="section level1">
<h1>Global Configurability and Interactive Namespace Masking</h1>
<p>The third major feature of <em>collapse</em> 2.0 is its extensive <a href="https://sebkrantz.github.io/collapse/reference/collapse-options.html">global configurability</a> via the <code>set_collapse()</code> function, which includes the default behavior for missing values (<code>na.rm</code> arguments in all statistical functions and algorithms), sorted grouping (<code>sort</code>), multithreading and algorithmic optimizations (<code>nthreads</code>, <code>stable.algo</code>), presentational settings (<code>stub</code>, <code>digits</code>, <code>verbose</code>), and, surpassing all else, the package namespace itself (<code>mask</code>, <code>remove</code>).</p>
<p>Why should the namespace, in particular, be modifiable? The main reason is that <em>collapse</em> provides many enhanced and performance improved equivalents to functions present in base R and <em>dplyr</em>, such as the <a href="https://sebkrantz.github.io/collapse/reference/fast-statistical-functions.html"><em>Fast Statistical Functions</em></a>, fast <a href="https://sebkrantz.github.io/collapse/reference/fast-grouping-ordering.html"><em>Grouping and Ordering</em></a> functions and algorithms, <a href="https://sebkrantz.github.io/collapse/reference/fast-data-manipulation.html"><em>Data Manipulation</em></a> and <a href="https://sebkrantz.github.io/collapse/reference/time-series-panel-series.html"><em>Time Series</em></a> functions.</p>
<p><em>collapse</em> is intentionally fully compatible with the base R and <em>dplyr</em> namespaces by adding f-prefixes to these performance-improved functions where conflicts exist. Since v1.7.0, there exists a global option <code>"collapse_mask"</code> which can be set before the package is loaded to export non-prefixed versions of these functions, but this was somewhat tedious and best done with an <code>.Rprofile</code> file. <em>collapse</em> 2.0 also adds this option to <code>set_collapse()</code> and makes it fully interactive; that is, it can be set and changed at any point within the active session.</p>
<p>Concretely, what does this mean? Base R and <em>dplyr</em> are relatively slow compared to what can be achieved with group-level vectorization, SIMD instructions, and efficient algorithms, especially as data grows. To provide an example, I generate some large vectors and run some benchmarks for basic operations:</p>
<pre class="r"><code>ul &lt;- outer(letters, letters, paste0)
l &lt;- sample(ul, 1e7, replace = TRUE)
m &lt;- sample(outer(month.abb, month.abb, paste0), 1e7, replace = TRUE)
x &lt;- na_insert(rnorm(1e7), prop = 0.05)
data &lt;- data.frame(l, m, x)

library(microbenchmark)
microbenchmark(
  unique(l),
  table(l, m),
  sum(x, na.rm = TRUE),
  median(x, na.rm = TRUE),
  mean(x, na.rm = TRUE),
times = 10)
## Warning in microbenchmark(unique(l), table(l, m), sum(x, na.rm = TRUE), : less accurate nanosecond times to avoid potential integer overflows
## Unit: milliseconds
##                     expr       min        lq      mean    median        uq       max neval
##                unique(l)  82.07856  85.14671  91.08940  86.02663  87.65242 124.87604    10
##              table(l, m) 506.91215 546.24042 554.55146 549.27130 565.78729 640.67941    10
##     sum(x, na.rm = TRUE)  15.39316  15.40485  15.58453  15.45362  15.54109  16.57486    10
##  median(x, na.rm = TRUE) 155.55667 157.19572 164.38405 160.75044 165.29642 196.26446    10
##    mean(x, na.rm = TRUE)  53.79520  54.12406  60.39059  55.94202  57.72792  97.40887    10

library(dplyr)
microbenchmark(
  dplyr = data |&gt;
    subset(l %in% ul[1:500]) |&gt;
    group_by(l, m) |&gt;
    summarize(mean_x = mean(x, na.rm = TRUE), 
              median_x = median(x, na.rm = TRUE)), 
times = 10)
## Unit: seconds
##   expr      min       lq     mean   median       uq      max neval
##  dplyr 2.159383 2.193034 2.230608 2.226005 2.250952 2.370895    10</code></pre>
<p>The beauty of namespace masking is that we can turn parts or all of this code into <em>collapse</em> code by simply invoking the <code>mask</code> option to <code>set_collapse()</code>. The most comprehensive setting is <code>mask = "all"</code>. It is a secure option because invoking it instantly exports these functions in the <em>collapse</em> namespace and re-attaches the namespace to make sure it is at the top of the search path:</p>
<pre class="r"><code>set_collapse(mask = &quot;all&quot;)
# This is all collapse code now + no need to set na.rm = TRUE (default in collapse)
# We could use set_collapse(na.rm = FALSE) to bring collapse in-line with base R
microbenchmark(
  unique(l),
  table(l, m),
  sum(x),
  median(x),
  mean(x),
times = 10)
## Unit: milliseconds
##         expr       min        lq       mean     median         uq        max neval
##    unique(l) 14.215561 15.289105  15.516647  15.564871  15.786722  16.411152    10
##  table(l, m) 94.875968 97.539410 107.189014 108.895508 115.038948 118.004806    10
##       sum(x)  1.968984  1.998955   2.053936   2.037946   2.128843   2.152500    10
##    median(x) 90.736772 91.784609  93.486921  92.288725  94.812787  99.236277    10
##      mean(x)  2.384314  2.389931   2.454666   2.419861   2.456023   2.680621    10

microbenchmark(
  collapse = data |&gt;
    subset(l %in% ul[1:500]) |&gt;
    group_by(l, m) |&gt;
    summarize(mean_x = mean(x), 
              median_x = median(x)), 
times = 10)
## Unit: milliseconds
##      expr      min       lq     mean   median       uq      max neval
##  collapse 344.2272 351.6525 363.8762 358.4373 373.9692 402.6943    10

# Reset the masking 
# set_collapse(mask = NULL)</code></pre>
<p>Evidently, the <em>collapse</em> code runs much faster. The 5-10x speedups shown here are quite normal. Higher speedups can be experienced for grouped operations as the number of groups grows large and repetition in R becomes very costly. As indicated before, masking in <em>collapse</em> 2.0 is fully interactive and reversible: invoking <code>set_collapse(mask = NULL)</code> and running the same code again will execute it again with base R and <em>dplyr</em>.</p>
<p>So, in summary, <em>collapse</em> 2.0 provides fast R, in R, in a very simple and broadly accessible way. There are many other advantages to using <em>collapse</em>, e.g., given that its <a href="https://sebkrantz.github.io/collapse/reference/fast-statistical-functions.html"><em>Fast Statistical Functions</em></a> are S3 generic and support grouped and weighted aggregations and transformations out of the box, this saves many unnecessary calls to <code>apply()</code>, <code>lapply()</code> or <code>summarise()</code>, etc. (in addition to many unnecessary specifications of <code>na.rm = TRUE</code>) e.g.:</p>
<pre class="r"><code># S3 generic statistical functions save a lot of syntax
mean(mtcars)                 # = sapply(mtcars, mean)
##        mpg        cyl       disp         hp       drat         wt       qsec         vs         am       gear       carb 
##  20.090625   6.187500 230.721875 146.687500   3.596563   3.217250  17.848750   0.437500   0.406250   3.687500   2.812500
mean(mtcars, w = runif(32))  # = sapply(mtcars, weighted.mean, w = runif(32))
##         mpg         cyl        disp          hp        drat          wt        qsec          vs          am        gear        carb 
##  20.9009889   5.9682791 221.9436951 137.7951392   3.6740174   3.1670541  18.0195300   0.4878638   0.4240404   3.7486737   2.6293563
mean(mtcars$mpg, mtcars$cyl) # = tapply(mtcars$mpg, mtcars$cyl, mean)
##        4        6        8 
## 26.66364 19.74286 15.10000
mean(mtcars, TRA = &quot;-&quot;) |&gt;   # = sweep(mtcars, 2, sapply(mtcars, mean))
  head()
##                         mpg     cyl        disp       hp       drat       wt     qsec      vs       am    gear    carb
## Mazda RX4          0.909375 -0.1875  -70.721875 -36.6875  0.3034375 -0.59725 -1.38875 -0.4375  0.59375  0.3125  1.1875
## Mazda RX4 Wag      0.909375 -0.1875  -70.721875 -36.6875  0.3034375 -0.34225 -0.82875 -0.4375  0.59375  0.3125  1.1875
## Datsun 710         2.709375 -2.1875 -122.721875 -53.6875  0.2534375 -0.89725  0.76125  0.5625  0.59375  0.3125 -1.8125
## Hornet 4 Drive     1.309375 -0.1875   27.278125 -36.6875 -0.5165625 -0.00225  1.59125  0.5625 -0.40625 -0.6875 -1.8125
## Hornet Sportabout -1.390625  1.8125  129.278125  28.3125 -0.4465625  0.22275 -0.82875 -0.4375 -0.40625 -0.6875 -0.8125
## Valiant           -1.990625 -0.1875   -5.721875 -41.6875 -0.8365625  0.24275  2.37125  0.5625 -0.40625 -0.6875 -1.8125
mtcars |&gt; group_by(cyl, vs, am) |&gt; 
  mean() # = summarize(across(everything(), mean))
##   cyl vs am      mpg     disp        hp     drat       wt     qsec     gear     carb
## 1   4  0  1 26.00000 120.3000  91.00000 4.430000 2.140000 16.70000 5.000000 2.000000
## 2   4  1  0 22.90000 135.8667  84.66667 3.770000 2.935000 20.97000 3.666667 1.666667
## 3   4  1  1 28.37143  89.8000  80.57143 4.148571 2.028286 18.70000 4.142857 1.428571
## 4   6  0  1 20.56667 155.0000 131.66667 3.806667 2.755000 16.32667 4.333333 4.666667
## 5   6  1  0 19.12500 204.5500 115.25000 3.420000 3.388750 19.21500 3.500000 2.500000
## 6   8  0  0 15.05000 357.6167 194.16667 3.120833 4.104083 17.14250 3.000000 3.083333
## 7   8  0  1 15.40000 326.0000 299.50000 3.880000 3.370000 14.55000 5.000000 6.000000</code></pre>
</div>
<div id="concluding-reflections" class="section level1">
<h1>Concluding Reflections</h1>
<p>It has been a remarkable 3.5-year-long journey leading up to the development of <em>collapse</em> 2.0, and a tremendous feat of time, energy, and determination. I could probably have published 2 academic papers instead, but would still be writing horrible code like most economists, be trying to do complex things in econometrics, etc., using other mainstream data science libraries not really designed for that, or, worst case, just be stuck to commercial software. I’m happy I came out as an open-source developer and that I’ve been accompanied on this path by other great people from my profession.</p>
<p>I also never regretted choosing R as a primary language. I find it unique in its simplicity and parsimony, the ability to work with different objects like vectors, matrices, and data frames in a fluent way, and to do rather complex things like matching with a single function call.</p>
<p>On the other hand, R for me always lacked speed and the ability to do advanced statistical and data manipulation operations with ease, as I was used to coming from commercial environments (STATA, Mathematica).</p>
<p><em>collapse</em> is my determined attempt to bring statistical complexity, parsimony, speed, and joy to statistics and data manipulation in R, and I believe it is the most encompassing attempt out there and preserves the fundamental character of the language.</p>
<p>Notably, the <em>collapse</em> approach is not limited to a certain object (like e.g. <em>data.table</em>, which remains a great idea and implementation), and does not rely on data structures and syntax that are somewhat alien/external to the language and do not integrate with many of its first-order features (e.g. <em>arrow</em>, <em>polars</em>, <em>duckdb</em>). It is also arguably more successful than alternative ways to implement or compile the language (<a href="https://github.com/oracle/fastr"><em>FastR</em> / <em>Graal VM</em></a>), because the fundamental performance problem in R is algorithmic efficiency and the lack of low-level vectorization for repetitive tasks<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a>.
<!--
Concretely, I think collapse 2.0, with its broad set of functions and algorithms ranging from basic arithmetic and programming to basic and advanced statistics, unique values, matching, ordering, and broad data manipulation, together with the namespace masking options and global configurability, is probably the most encompassing attempt ever made to fundamentally speed up and enrich the R language itself.
--></p>
<p>By reimplementing core parts of the language using efficient algorithms and providing rich and flexible vectorizations for many statistical operations across columns and groups in a class-agnostic way supporting nearly all frequently used data structures, <em>collapse</em> solves the fundamental performance problem in a way that integrates seamlessly with the core of the language. It also adds much-needed statistical complexity, particularly for weighted statistics, time series, and panel data. In short, it provides advanced and fast R, inside GNU R.</p>
<p>It is not, and will never be, the absolute best that can be done in performance terms. The data formats used by the best-performing systems (such as the <em>arrow</em> columnar format underlying <em>polars</em>) are designed at the memory level to optimally use computer resources (SIMD etc.), with database applications in mind, and the people doing this did not study economics. But it is not yet clear that such architectures are very suitable for languages meant to do broad and linear-algebra heavy statistical computing tasks, and R just celebrated its 30th birthday this year. So, given the constraints imposed by a 30-year-old C-based language and API, frameworks like <em>collapse</em> and <em>data.table</em> are pushing the boundaries very far<a href="#fn3" class="footnote-ref" id="fnref3"><sup>3</sup></a>.</p>
<p>Let me stop here; <em>collapse</em> 2.0 is out. It changed my R life, and I hope it will change yours.</p>
</div>
<div class="footnotes footnotes-end-of-document">
<hr />
<ol>
<li id="fn1"><p>Some initial benchmarks were shared on <a href="https://twitter.com/collapse_R">Twitter</a>, and <em>collapse</em> is about to enter the <a href="https://duckdb.org/2023/04/14/h2oai.html#results">DuckDB database like ops benchmark</a>. <code>fmatch()</code> is also nearly an order of magnitude faster than <code>match()</code> for atomic vectors.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>See e.g. benchmarks for <a href="https://github.com/brodieG/r2c"><em>r2c</em></a>.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
<li id="fn3"><p>Actually, it is extremely impressive how well <em>data.table</em> still performs compared to modern libraries based on optimized memory models. The <a href="https://duckdblabs.github.io/db-benchmark/">benchmarks</a> also show that in high-cardinality settings (many groups relative to the data size), optimized memory models don’t pay off that much, indicating that there is always a tradeoff between the complexity of statistical operations and the possibility of vectorization/bulk processing on modern hardware.<a href="#fnref3" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>collapse and the fastverse: Reflections on the Past, Present and Future</title>
      <link>https://sebkrantz.github.io/Rblog/2023/04/12/collapse-and-the-fastverse-reflecting-the-past-present-and-future/</link>
      <pubDate>Wed, 12 Apr 2023 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2023/04/12/collapse-and-the-fastverse-reflecting-the-past-present-and-future/</guid>
      <description><![CDATA[ 


<p>Last week <a href="https://sebkrantz.github.io/collapse/"><em>collapse</em></a> reached 1M downloads off CRAN. This is a milestone that was largely unforeseen for a package that started 4 years ago as a collection of functions intended to ease the R life of an economics master student. Today, <em>collapse</em> provides cutting-edge performance in many areas of statistical computing and data manipulation, and a breadth of statistical algorithms that can meet applied economists’ or statisticians’ demands on a programming environment like R. It is also the only programming framework in R that is effectively class-agnostic. Version 1.9.5 just released to CRAN this week, is also the first version that includes Single Instruction Multiple Data (SIMD) instructions for a limited set of operations. The future will see more efforts to take advantage of the capabilities of modern processors.</p>
<p>Meanwhile, the <a href="https://fastverse.github.io/fastverse/"><em>fastverse</em></a> - a lightweight collection of C/C++-based R packages for statistical computing and data manipulation - is becoming more popular as an alternative to the <a href="https://www.tidyverse.org/"><em>tidyverse</em></a> for data analysis and as backends to statistical packages developed for R - a trend that is needed.</p>
<p>It is on this positive occasion that I decided it was the right time to provide you with a personal note, or rather, some reflections, regarding the history, present state, and the future of <em>collapse</em> and the <em>fastverse</em>.</p>
<div id="the-past" class="section level1">
<h1>The Past</h1>
<p><em>collapse</em> started in 2019 as a small package with only two functions: <code>collap()</code> - intended to facilitate the aggregation of mixed-type data in R, and <code>qsu()</code> - intended to facilitate summarizing panel data in R. Both were inspired by <a href="https://www.stata.com/"><em>STATA’s</em></a> <code>collapse</code> and <code>(xt)summarize</code> commands, and implemented with <a href="https://rdatatable.gitlab.io/data.table/"><em>data.table</em></a> as a backend. The package - called <em>collapse</em> alluding to the STATA command - would probably have stayed this way, had not unforeseen events affected my career plans.</p>
<p>Having completed a master’s in international economics in summer 2019, I was preparing for a two-year posting as an ODI Fellow in the Central Bank of Papua New Guinea in fall. However, things did not work out, I had difficulties getting a working visa and there were coordination issues with the Bank, so ODI decided to offer me a posting in the Ugandan Ministry of Finance, starting in January 2020. This gave me 4 months, September-December 2019, during which I ended up writing a new backend for <em>collapse</em> - in C++.</p>
<p>While <em>collapse</em> with <em>data.table</em> backed was never particularly slow, some of the underlying metaprogramming seemed arcane, especially because I wanted to utilize <em>data.table</em>’s GeForce optimizations which require the aggregation function to be recognizable in the call for <em>data.table</em> to internally replace it with an optimized version. But there were also statistical limitations. As an economist, I often employed sampling or trade weights in statistics, and in this respect, R was quite limited. There was also no straightforward way to aggregate categorical data, using, as I would have it, a (weighted) statistical mode function. I also felt R was lacking basic things in the time series domain - evidenced by the lengths I went to handle (irregular) trade panels. Finally, I felt limited by the division of software development around different classes in R. I found <em>data.table</em> useful for analytics, but the class too complex to behave in predictable ways. Thus I often ended up converting back to ‘data.frame’ or ‘tibble’ to use functions from a different package. Sometimes it would also have been practical to simply keep data as a vector or matrix - in linear-algebra-heavy programs - but I needed <em>data.table</em> to do something ‘by groups’. So in short, my workflow in R employed frequent object conversions, faced statistical limitations, and, in the case of early <em>collapse</em>’s <em>data.table</em> backend, also involved tedious metaprogramming.</p>
<p>The will for change pushed me to practically rethink the way statistics could be done in R. It required a framework that encompassed increased statistical complexity, including advanced statistical algorithms like (weighted) medians, quantiles, modes, support for (irregular) time series and panels etc., and enabling these operations to be vectored efficiently across many groups and columns without a limiting syntax that would again encourage metaprogramming. The framework would also need to be class-agnostic/support multiple R objects and classes, to easily integrate with different frameworks and reduce the need for object conversions. These considerations led to the creation of a comprehensive set of S3 generic grouped and weighted <a href="https://sebkrantz.github.io/collapse/reference/fast-statistical-functions.html"><em>Fast Statistical Functions</em></a> for vectors matrices and data.frame-like objects, initially programmed fully in C++. The functions natively supported R factors for grouping. To facilitate programming further, I created multivariate <a href="https://sebkrantz.github.io/collapse/reference/GRP.html">grouping (‘GRP’) objects</a> that could be used to perform multiple statistical operations across the same groups without grouping overhead. With this backend and hand, it was easy to reimplement <a href="https://sebkrantz.github.io/collapse/reference/collap.html"><code>collap()</code></a><a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a>, and also provide a whole array of other useful functions, including dplyr-like functions like <code>fgroup_by()</code>, and <a href="https://sebkrantz.github.io/collapse/reference/time-series-panel-series.html">time series functions</a> that could be used ad-hoc but also supported <a href="https://cran.r-project.org/package=plm"><em>plm</em>’s</a> indexed ‘pseries’ and ‘pdata.frame’ classes. <em>collapse</em> 1.0.0, released to CRAN on 19th March 2020 (me sitting in the Ugandan finance ministry) was already a substantial piece of statistical software offering cutting-edge performance (see the benchmarks in the <a href="https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/"><em>introductory blog post</em></a>).</p>
<p>To then cut a long story short, in the coming 3 years <em>collapse</em> became better, broader, and faster in multiple iterations. Additional speed came especially from rewriting central parts of the package in C - reimplementing some core algorithms in C rather than relying on the <a href="https://en.cppreference.com/w/cpp/algorithm"><em>C++ standard library</em></a> or <a href="https://dirk.eddelbuettel.com/code/rcpp/Rcpp-sugar.pdf"><em>Rcpp sugar</em></a> - as well as introducing <a href="https://sebkrantz.github.io/collapse/reference/TRA.html">data transformation by reference</a> and <a href="https://sebkrantz.github.io/collapse/reference/collapse-options.html">OpenMP multithreading</a>. For example, <a href="https://sebkrantz.github.io/collapse/reference/fmode.html"><code>fmode()</code></a>, rewritten from C++ to C for v1.8.0 (May 2022), became about 3x faster in serial mode (grouped execution), with additional gains through multithreading across groups. Other noteworthy functionality was a modern reimplementation of ‘pseries’ and ‘pdata.frame’, through <a href="https://sebkrantz.github.io/collapse/reference/indexing.html">‘indexed_frame’ and ‘indexed_series’ classes</a>, fully fledged <a href="https://sebkrantz.github.io/collapse/reference/fsummarise.html"><code>fsummarise()</code></a>, <a href="https://sebkrantz.github.io/collapse/reference/ftransform.html"><code>fmutate()</code></a> and <a href="https://sebkrantz.github.io/collapse/reference/across.html"><code>across()</code></a> functions enabling <em>tidyverse</em>-like programming with vectorization for <em>Fast Statistical Functions</em> in the backend, a set of functions facilitating <a href="https://sebkrantz.github.io/collapse/reference/efficient-programming.html">memory efficient R programming</a> and low-cost <a href="https://sebkrantz.github.io/collapse/reference/quick-conversion.html">data object conversions</a>, functions to effectively deal with <a href="https://sebkrantz.github.io/collapse/reference/list-processing.html">(nested) lists of data objects</a> - such as unlisting to data frame with <a href="https://sebkrantz.github.io/collapse/reference/unlist2d.html"><code>unlist2d()</code></a>, and additional <a href="https://sebkrantz.github.io/collapse/reference/summary-statistics.html">descriptive statistical tools</a> like <a href="https://sebkrantz.github.io/collapse/reference/qtab.html"><code>qtab()</code></a> and <a href="https://sebkrantz.github.io/collapse/reference/descr.html"><code>descr()</code></a>. Particularly 2022 has seen two major updates: v1.7 and v1.8, and the bulk of development for 1.9 - released in January 2023. <!-- These updates significantly improved the functionality and performance of *collapse*, making it one of the most powerful statistical libraries available in any software environment. --> In improving <em>collapse</em>, I always took inspiration from other packages, most notably <em>data.table</em>, <em>kit</em>, <em>dplyr</em>, <em>fixest</em>, and R itself, to which I am highly indebted. The presentation of <em>collapse</em> at <a href="https://sebkrantz.github.io/collapse/index.html#presentation-at-user-2022">UseR 2022</a> in June 2022 marks another milestone of its establishment in the R community.</p>
<p>While using R and improving <em>collapse</em>, I became increasingly aware that I was not alone in the pursuit of making R faster and statistically more powerful. Apart from well-established packages like <em>data.table</em>, <em>matrixStats</em>, and <em>fst</em>, I noticed and started using many smaller packages like <em>kit</em>, <em>roll</em>, <em>stringfish</em>, <em>qs</em>, <em>Rfast</em>, <em>coop</em>, <em>fastmap</em>, <em>fasttime</em>, <em>rrapply</em> etc. aimed at improving particular aspects of R in a statistical or computational sense, often offering clean C or C++ implementations with few R-level dependencies. I saw a pattern of common traits and development efforts that were largely complimentary and needed encouragement. My impression at the time - largely unaltered today - was that such efforts were ignored by large parts of the R user community. One reason is of course the lack of visibility and coordination, compared to institutional stakeholders like Rstudio and H2O backing the <em>tidyverse</em> and <em>data.table</em>. Another consideration, it seemed to me, was that the <em>tidyverse</em> is particularly popular simply because there exists an R package and website called <em>tidyverse</em> which loads a set of packages that work well together, and thus alleviates users of the burden of searching CRAN and choosing their own collection of data manipulation packages.</p>
<p>Thus I decided in early 2021 to also create a meta package and <a href="https://github.com/fastverse/fastverse">GitHub repo</a> called <a href="https://fastverse.github.io/fastverse/"><em>fastverse</em></a> and use it to promote high-performance R packages with few dependencies. The first version 0.1.6 made it to CRAN in August 2021, attaching 6 core packages (<em>data.table</em>, <em>collapse</em>, <em>matrixStats</em>, <em>kit</em>, <em>fst</em> and <em>magrittr</em>), and allowing easy extension with additional packages using the <code>fastverse_extend()</code> function. With 7 total dependencies instead of 80, it was a considerably more lightweight and computationally powerful alternative to the <em>tidyverse</em>. The <a href="https://github.com/fastverse/fastverse#suggested-extensions">README</a> of the GitHub repository has grown largely due to suggestions from the community and now lists many of the highest performing and (mostly) lightweight R packages. Over time I also introduced more useful functionality into the <em>fastverse</em> package, such as the ability to configure the environment and set of packages included using a <a href="https://fastverse.github.io/fastverse/articles/fastverse_intro.html#custom-fastverse-configurations-for-projects"><code>.fastverse</code></a> file, an option to <a href="https://fastverse.github.io/fastverse/reference/fastverse_extend.html">install missing packages on the fly</a>, and the <a href="https://fastverse.github.io/fastverse/reference/fastverse_child.html"><code>fastverse_child()</code></a> function to create wholly separate package verses. Observing my own frequent usage of <em>data.table</em>, <em>collapse</em>, <em>kit</em>, and <em>magrittr</em> in combination, I did a poll on Twitter in Fall 2022 suggesting the removal of <em>matrixStats</em> and <em>fst</em> from the core set of packages - which as accepted and implemented from v0.3.0 (November 2022). The <em>fastverse</em> package has thus become an extremely lightweight, customizable, and fast <em>tidyverse</em> alternative.</p>
<!-- . The proposal was accepted, thus since v0.3.0 (November 2022), the core *fastverse* only consists of *data.table*, *collapse*, *kit* and *magrittr*, depends only on *Rcpp*, and offers several possibilities of customization and extension with additional packages. It has become an extremely lightweight, flexible and fast *tidyverse* alternative. -->
</div>
<div id="the-present" class="section level1">
<h1>The Present</h1>
<p>Today, both <em>collapse</em> and <em>fastverse</em> are well established in a part of the R community closer to econometrics and high-performance statistics. A growing number of econometric packages benefit from <em>collapse</em> as a computational backend, most notably the well-known <a href="https://cran.r-project.org/package=plm"><em>plm</em></a> package - which experienced order-of-magnitude performance gains. I am also developing <a href="https://github.com/SebKrantz/dfms"><em>dfms</em></a> (first CRAN release October 2022), demonstrating that very efficient estimation of Dynamic Factor Models is possible in R combining <em>collapse</em> and <em>RcppArmadillo</em>. <em>collapse</em> is also powering various shiny apps across the web. I ended up creating a <em>collapse</em>-powered public <a href="https://mepd.finance.go.ug/apps/macro-data-portal/">macroeconomic data portal</a> for Uganda, and later, at the Kiel Institute for the World Economy, for <a href="https://africamonitor.ifw-kiel.de/">Africa at large</a>.</p>
<p>So <em>collapse</em> has made it into production in my own work and the work of others. Core benefits in my experience are that it is lightweight to install on a server, has very low baseline function execution speeds (of a few microseconds instead of milliseconds with most other frameworks) making for speedy reaction times, scales very well to large data, and supports multiple R objects and modes of programming - reducing the need for metaprogramming. Since my own work and the work of others depends on it, API stability has always been important. <em>collapse</em> has not seen any major API changes in updates v1.7-v1.9, and currently no further API changes are planned. This lightweight and robust nature - characteristic of all core <em>fastverse</em> packages esp. <em>data.table</em> - stands in contrast to <em>dplyr</em>, who’s core API involving <code>summarise()</code>, <code>mutate()</code> and <code>across()</code> keeps changing to an extent that at some point in 2022 I removed unit tests of <code>fsummarise()</code> and <code>fmutate()</code> against the <em>dplyr</em> versions from CRAN.</p>
<p>Apart from development, it has also been very fun using the <em>fastverse</em> in the wild for some research projects. Lately, I’ve been working a lot with geospatial data, where the <em>fastverse</em> has enabled numerous interesting applications.</p>
<p>For example, I was interested in how the area of OSM buildings needs to be scaled using a power weight to correlate optimally with nightlights luminosity within a million cells of populated places in Sub-Saharan Africa. Having extracted around 12 million buildings from OSM, I programmed the following objective function and optimized it for power weights between 0.0001 and 5.</p>
<pre class="r"><code>library(fastverse)       
library(microbenchmark)

a &lt;- abs(rnorm(12e6, 100, 100))            # Think of this as building areas in m^2
g &lt;- GRP(sample.int(1e6, 12e6, TRUE))      # Think of this as grid cells
y &lt;- fsum(a^1.5, g, use.g.names = FALSE) + # Think of this as nightlights 
     rnorm(g$N.groups, sd = 10000)  
length(y)
## [1] 999989

# Objective function
cor_ay &lt;- function(w, a, y) {
  aw_agg = fsum(a^w, g, use.g.names = FALSE, na.rm = FALSE)
  cor(aw_agg, y) 
}

# Checking the speed of the objective
microbenchmark(cor_ay(2, a, y))
## Unit: milliseconds
##             expr      min      lq     mean   median       uq      max neval
##  cor_ay(2, a, y) 30.42331 32.1136 35.02078 34.43118 36.75326 55.36505   100

# Now the optimization
system.time(res &lt;- optimise(cor_ay, c(0.0001, 5), a, y, maximum = TRUE))
##    user  system elapsed 
##   1.375   0.051   1.427
res
## $maximum
## [1] 1.501067
## 
## $objective
## [1] 0.5792703</code></pre>
<p>The speed of the objective due to <code>GRP()</code> and <code>fsum()</code><a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a> allowed further subdivision of buildings into different classes, and experimentation with finer spatial resolutions.</p>
<p>Another recent application involved finding the 100 nearest neighbors for each of around 100,000 cells (rows) in a rich geospatial dataset with about 50 variables (columns), and estimating a simple proximity-weighted linear regression of an outcome of interest <code>y</code> on a variable of interest <code>z</code>. Since computing a distance matrix on 100,000 rows up-front is infeasible memory-wise, I needed to go row-by-row. Here functions <code>dapply()</code>, <code>fdist()</code> and <code>flm()</code> from <em>collapse</em>, and <code>topn()</code> from <em>kit</em> became very handy.</p>
<pre class="r"><code># Generating the data
X &lt;- rnorm(5e6)       # 100,000 x 50 matrix of characteristics
dim(X) &lt;- c(1e5, 50)
z &lt;- rnorm(1e5)       # Characteristic of interest
y &lt;- z + rnorm(1e5)   # Outcome of interest
Xm &lt;- t(forwardsolve(t(chol(cov(X))), t(X)))    # Transform X to compute Mahalanobis distance (takes account of correlations)

# Coefficients for a single row
coef_i &lt;- function(row_i) {
    mdist = fdist(Xm, row_i, nthreads = 2L)                           # Mahalanobis distance
    best_idx = topn(mdist, 101L, hasna = FALSE, decreasing = FALSE)   # 100 closest points
    best_idx = best_idx[mdist[best_idx] &gt; 0]                          # Removing the point itself (mdist = 0)
    weights = 1 / mdist[best_idx]                                     # Distance weights
    flm(y[best_idx], z[best_idx], weights, add.icpt = TRUE)           # Weighted lm coefficients
}

# Benchmarking a single execution
microbenchmark(coef_i(Xm[1L, ]))
## Unit: microseconds
##              expr     min      lq     mean  median       uq      max neval
##  coef_i(Xm[1L, ]) 927.051 965.591 1149.184 998.473 1031.068 3167.988   100

# Compute coefficients for all rows
system.time(coefs &lt;- dapply(Xm, coef_i, MARGIN = 1))
##    user  system elapsed 
## 214.942  10.322 114.123
head(coefs, 3)
##         coef_i1  coef_i2
## [1,] 0.04329208 1.189331
## [2,] 0.20741015 1.107963
## [3,] 0.02860692 1.106427</code></pre>
<p>Due to the efficiency of <code>fdist()</code> and <code>topn()</code>, a single call to the function takes around 1.2 milliseconds on the M1, giving a total execution time of around 120 seconds for 100,000 iterations of the program - one for each row of <code>Xm</code>.</p>
<p>A final recent application involved creating geospatial GINI coefficients for South Africa using remotely sensed population and nightlights data. Since population data from <a href="https://hub.worldpop.org/geodata/listing?id=75">WorldPop</a> and Nightlights from <a href="https://developers.google.com/earth-engine/datasets/catalog/NOAA_VIIRS_DNB_MONTHLY_V1_VCMSLCFG">Google Earth Engine</a> are easily obtained from the web, I reproduce the exercise here in full.</p>
<pre class="r"><code># Downloading 1km2 UN-Adjusted population data for South Africa from WorldPop
pop_year &lt;- function(y) sprintf(&quot;https://data.worldpop.org/GIS/Population/Global_2000_2020_1km_UNadj/%i/ZAF/zaf_ppp_%i_1km_Aggregated_UNadj.tif&quot;, y, y)
for (y in 2014:2020) download.file(pop_year(y), mode = &quot;wb&quot;, destfile = sprintf(&quot;data/WPOP_SA_1km_UNadj/zaf_ppp_%i_1km_Aggregated_UNadj.tif&quot;, y))</code></pre>
<p>VIIRS Nightlights are available on <a href="https://developers.google.com/earth-engine/datasets/catalog/NOAA_VIIRS_DNB_MONTHLY_V1_VCMSLCFG">Google Earth Engine</a> on a monthly basis from 2014 to 2022. I extracted annual median composites for South Africa using instructions found <a href="https://worldbank.github.io/OpenNightLights/tutorials/mod3_6_making_VIIRS_annual_composites.html">here</a> and saved them to my <a href="https://drive.google.com/drive/folders/18xI75APNFkUx4pcTfFdX8Orm36lcLzva?usp=share_link">google drive</a><a href="#fn3" class="footnote-ref" id="fnref3"><sup>3</sup></a>.</p>
<pre class="r"><code># Reading population files using terra and creating a single data.table
pop_data &lt;- list.files(pop_path) %&gt;% 
  set_names(substr(., 9, 12)) %&gt;% 
  lapply(function(x) paste0(pop_path, &quot;/&quot;, x) %&gt;% 
           terra::rast() %&gt;% 
           terra::as.data.frame(xy = TRUE) %&gt;% 
           set_names(c(&quot;lon&quot;, &quot;lat&quot;, &quot;pop&quot;))) %&gt;% 
  unlist2d(&quot;year&quot;, id.factor = TRUE, DT = TRUE) %&gt;% 
  fmutate(year = as.integer(levels(year))[year]) %T&gt;% print(2)
##            year      lon       lat      pop
##           &lt;int&gt;    &lt;num&gt;     &lt;num&gt;    &lt;num&gt;
##        1:  2014 29.62792 -22.12958 38.21894
##        2:  2014 29.63625 -22.12958 19.25175
##       ---                                  
## 11420562:  2020 37.83625 -46.97958  0.00000
## 11420563:  2020 37.84458 -46.97958  0.00000

# Same for nightlights
nl_data &lt;- list.files(nl_annual_path) %&gt;% 
  set_names(substr(., 1, 4)) %&gt;% 
  lapply(function(x) paste0(nl_annual_path, &quot;/&quot;, x) %&gt;% 
           terra::rast() %&gt;% 
           terra::as.data.frame(xy = TRUE) %&gt;% 
           fsubset(avg_rad %!=% -9999)) %&gt;%  # Values outside land area of SA, coded using a mask in GEE
  unlist2d(&quot;year&quot;, id.factor = TRUE, DT = TRUE) %&gt;%
  frename(x = lon, y = lat) %&gt;% 
  fmutate(year = as.integer(levels(year))[year]) %T&gt;% print(2)
##            year      lon       lat    avg_rad
##           &lt;int&gt;    &lt;num&gt;     &lt;num&gt;      &lt;num&gt;
##        1:  2014 29.64583 -22.12500 0.08928293
##        2:  2014 29.65000 -22.12500 0.04348474
##       ---                                    
## 58722767:  2022 20.00833 -34.83333 0.37000000
## 58722768:  2022 20.01250 -34.83333 0.41000000</code></pre>
<p>Since nightlights are available up to 2022, but population only up to 2020, I did a crude cell-level population forecast for 2021 and 2022 based on 1.6 million linear models of cell-level population between 2014 and 2020.</p>
<pre class="r"><code># Unset unneeded options for greater efficiency
set_collapse(na.rm = FALSE, sort = FALSE)
system.time({
# Forecasting population in 1.6 million grid cells based on linear regression
pop_forecast &lt;- pop_data %&gt;% 
  fgroup_by(lat, lon) %&gt;% 
  fmutate(dm_year = fwithin(year)) %&gt;% 
  fsummarise(pop_2020 = flast(pop),
             beta = fsum(pop, dm_year) %/=% fsum(dm_year^2)) %&gt;% 
  fmutate(pop_2021 = pop_2020 + beta, 
          pop_2022 = pop_2021 + beta, 
          beta = NULL)
})
##    user  system elapsed 
##   0.210   0.054   0.263

head(pop_forecast)
##          lat      lon  pop_2020  pop_2021  pop_2022
##        &lt;num&gt;    &lt;num&gt;     &lt;num&gt;     &lt;num&gt;     &lt;num&gt;
## 1: -22.12958 29.62792  52.35952  54.35387  56.34821
## 2: -22.12958 29.63625  23.65122  24.44591  25.24060
## 3: -22.12958 29.64458  33.29427  34.10418  34.91409
## 4: -22.12958 29.65292 194.08760 216.78054 239.47348
## 5: -22.12958 29.66125 123.92527 139.52940 155.13353
## 6: -22.13792 29.56958  13.61020  13.39950  13.18880</code></pre>
<p>The above expression is an optimized version of univariate linear regression: <code>beta = cov(pop, year)/var(year) = sum(pop * dm_year) / sum(dm_year^2)</code>, where <code>dm_year = year - mean(year)</code>, that is fully vectorized across 1.6 million groups. Two further tricks are applied here: <code>fsum()</code> has an argument for sampling weights, which I utilize here instead of writing <code>fsum(pop * dm_year)</code>, which would require materializing a vector <code>pop * dm_year</code> before summing. The division by reference (<code>%/=%</code>) saves another unneeded copy. The expression could also have been written in one line as <code>fsummarise(beta = fsum(pop, W(year)) %/=% fsum(W(year)^2))</code>, given that 3/4 of the computation time here is actually spent on grouping 11.4 million records by <code>lat</code> and <code>lon</code>. <!-- This indicates just how fast vectorized operations with *collapse* are. --></p>
<pre class="r"><code># Appending population data with cell-level forecasts for 2021 and 2022
pop_data_forecast &lt;- rbind(pop_data,
  pop_forecast %&gt;% fselect(-pop_2020) %&gt;% rm_stub(&quot;pop_&quot;) %&gt;% 
  melt(1:2, variable.name = &quot;year&quot;, value.name = &quot;pop&quot;) %&gt;% 
  fmutate(year = as.integer(levels(year))[year]) %&gt;% 
  colorder(year))</code></pre>
<p>As you may have noticed, the nightlights data has a higher resolution of around 464m than the population data at 1km resolution. To match the two datasets, I use a function that transforms the coordinates to a rectilinear grid of a certain size in km, using an Approximation to the <a href="https://en.wikipedia.org/wiki/Haversine_formula">Haversine Formula</a> which rescales longitude coordinates based on the latitude coordinate (to have them approximately represent distance as at the equator). The coordinates are then divided by the grid size in km transformed to degrees at the equator, and the modulus from this division is removed. Afterward, half of the grid size is added again, reflecting the grid centroids. Finally, longitudes are rescaled back to their original extent using the same scale factor.</p>
<pre class="r"><code># Transform coordinates to cell centroids of a rectilinear square grid of a certain size in kms
round_to_kms_fast &lt;- function(lon, lat, km, round = TRUE, digits = 6) {
  degrees = km / (40075.017 / 360)             # Gets the degree-distance of the kms at the equator
  if(round) div = round(degrees, digits)       # Round to precision
  res_lat = TRA(lat, div, &quot;-%%&quot;) %+=% (div/2)  # This transforms the data to the grid centroid
  scale_lat = cos(res_lat * pi/180)            # Approx. scale factor based on grid centroid
  res_lon = setTRA(lon * scale_lat, div, &quot;-%%&quot;) %+=% (div/2) %/=% scale_lat  
  return(list(lon = res_lon, lat = res_lat))
}</code></pre>
<p>The virtue of this approach, while appearing crude and not fully respecting the spherical earth model, is that it allows arbitrary grid sizes and transforms coordinates from different datasets in the same way. To determine the grid size, I take the largest 2-digit grid size that keeps the population cells unique, i.e. that largest number such that:</p>
<pre class="r"><code>pop_data %&gt;% ftransform(round_to_kms_fast(lon, lat, 0.63)) %&gt;% 
  fselect(year, lat, lon) %&gt;% any_duplicated()
## [1] FALSE</code></pre>
<p>It turns out that 0.63km is the ideal grid size. I apply this to both datasets and merge them, aggregating nightlights using the mean<a href="#fn4" class="footnote-ref" id="fnref4"><sup>4</sup></a>.</p>
<pre class="r"><code>system.time({
nl_pop_data &lt;- pop_data_forecast %&gt;% 
   ftransform(round_to_kms_fast(lon, lat, 0.63)) %&gt;% 
   merge(nl_data %&gt;% ftransform(round_to_kms_fast(lon, lat, 0.63)) %&gt;% 
         fgroup_by(year, lat, lon) %&gt;% fmean(), 
         by = .c(year, lat, lon))
})
##    user  system elapsed 
##   8.195   1.380   4.280
head(nl_pop_data, 2)
## Key: &lt;year, lat, lon&gt;
##     year       lat      lon      pop    avg_rad
##    &lt;int&gt;     &lt;num&gt;    &lt;num&gt;    &lt;num&gt;      &lt;num&gt;
## 1:  2014 -34.82266 19.98068 2.140570 0.07518135
## 2:  2014 -34.82266 19.98758 4.118959 0.09241374</code></pre>
<p>Given the matched data, I define a function to compute the weighted GINI coefficient and an unweighted version for comparison.</p>
<pre class="r"><code># Taken from Wikipedia: with small-sample correction
gini_wiki &lt;- function(x) 1 + 2/(length(x)-1) * (sum(seq_along(x)*sort(x)) / sum(x) - length(x))

# No small-sample correction
gini_noss &lt;- function(x) 2/length(x) * sum(seq_along(x)*sort(x)) / sum(x) - (length(x)+1)/length(x) 

Skp1qm1 &lt;- function(k, q) (q-1)/2 * (2*(k+1) + q) + k + 1
all.equal(Skp1qm1(30-1, 70+1), sum(30:100))
## [1] TRUE

# Weighted GINI (by default without correction)
w_gini &lt;- function(x, w, sscor = FALSE) {
  o = radixorder(x)
  w = w[o]
  x = x[o]
  sw = sum(w)
  csw = cumsum(w)
  sx = Skp1qm1(c(0, csw[-length(csw)]), w) 
  if(sscor) return(1 + 2/(sw-1)*(sum(sx*x) / sum(x*w) - sw)) 
  2/sw * sum(sx*x) / sum(x*w) - (sw+1)/sw
}</code></pre>
<p>This computes the population-weighted and unweighted GINI on a percentage scale for each year.</p>
<pre class="r"><code>raw_gini_ts &lt;- nl_pop_data %&gt;% 
  fsubset(pop &gt; 0 &amp; avg_rad &gt; 0) %&gt;%
  fgroup_by(year) %&gt;% 
  fsummarise(gini = gini_noss(avg_rad)*100, 
             w_gini = w_gini(avg_rad, pop)*100) %T&gt;% print()
##     year     gini   w_gini
##    &lt;int&gt;    &lt;num&gt;    &lt;num&gt;
## 1:  2014 79.34750 55.51574
## 2:  2015 91.35048 55.06437
## 3:  2016 92.16993 54.75063
## 4:  2017 55.96135 53.53097
## 5:  2018 59.87219 52.84233
## 6:  2019 64.43899 52.23766
## 7:  2020 53.05498 51.15202
## 8:  2021 52.19359 50.26020
## 9:  2022 48.07294 49.69182

# Plotting
library(ggplot2)
raw_gini_ts %&gt;% melt(1) %&gt;% 
  ggplot(aes(x = year, y = value, colour = variable)) + 
      geom_line()</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2023/04/12/collapse-and-the-fastverse-reflecting-the-past-present-and-future/index_files/figure-html/unnamed-chunk-11-1.png" width="100%" /></p>
<p>As evident from the plot, the population-weighted GINI is more smooth, which could be due to unpopulated areas exhibiting greater fluctuations in nightlights (such as fires or flares).</p>
<p>A final thing that we can do is calibrate the GINI to an official estimate. I use the <a href="https://CRAN.R-project.org/package=africamonitor"><em>africamonitor</em></a> R API to get World Bank GINI estimates for South Africa.</p>
<pre class="r"><code>WB_GINI &lt;- africamonitor::am_data(&quot;ZAF&quot;, &quot;SI_POV_GINI&quot;) %T&gt;% print()
## Key: &lt;Date&gt;
##          Date SI_POV_GINI
##        &lt;Date&gt;       &lt;num&gt;
## 1: 1993-01-01        59.3
## 2: 2000-01-01        57.8
## 3: 2005-01-01        64.8
## 4: 2008-01-01        63.0
## 5: 2010-01-01        63.4
## 6: 2014-01-01        63.0</code></pre>
<p>The last estimate in the series is from 2014, estimating a GINI of 63%. To bring the nightlights data in line with this estimate, I again use <code>optimize()</code> to determine an appropriate power weight:</p>
<pre class="r"><code>np_pop_data_pos_14 &lt;- nl_pop_data %&gt;% 
  fsubset(pop &gt; 0 &amp; avg_rad &gt; 0 &amp; year == 2014, year, pop, avg_rad) 

objective &lt;- function(k) {
  nl_gini = np_pop_data_pos_14 %$% w_gini(avg_rad^k, pop) * 100
  abs(63 - nl_gini)
}

res &lt;- optimize(objective, c(0.0001, 5)) %T&gt;% print()
## $minimum
## [1] 1.308973
## 
## $objective
## [1] 0.0002598319</code></pre>
<p>With the ideal weight determined, it is easy to obtain a final calibrated nightlights-based GINI series and use it to extend the World Bank estimate.</p>
<pre class="r"><code>final_gini_ts &lt;- nl_pop_data %&gt;% 
  fsubset(pop &gt; 0 &amp; avg_rad &gt; 0) %&gt;%
  fgroup_by(year) %&gt;% 
  fsummarise(nl_gini = w_gini(avg_rad^res$minimum, pop)*100) %T&gt;% print()
##     year  nl_gini
##    &lt;int&gt;    &lt;num&gt;
## 1:  2014 62.99974
## 2:  2015 62.49832
## 3:  2016 62.22387
## 4:  2017 61.22247
## 5:  2018 60.54190
## 6:  2019 59.82656
## 7:  2020 58.83987
## 8:  2021 57.93322
## 9:  2022 57.51001

final_gini_ts %&gt;% 
  merge(WB_GINI %&gt;% fcompute(year = year(Date), wb_gini = SI_POV_GINI), 
        by = &quot;year&quot;, all = TRUE) %&gt;% 
  melt(&quot;year&quot;, na.rm = TRUE) %&gt;% 
  ggplot(aes(x = year, y = value, colour = variable)) + 
      geom_line() + scale_y_continuous(limits = c(50, 70))</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2023/04/12/collapse-and-the-fastverse-reflecting-the-past-present-and-future/index_files/figure-html/unnamed-chunk-14-1.png" width="100%" /></p>
<p>It should be noted, at this point, that this estimate and the declining trend it shows may be seriously misguided. Research by <a href="https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3786572">Galimberti et al. (2020)</a> using the old <a href="https://developers.google.com/earth-engine/datasets/catalog/NOAA_DMSP-OLS_NIGHTTIME_LIGHTS">DMSP OLS Nightlights</a> series from 1992-2013 for 234 countries and territories, shows that nightlights based inequality measures much better resemble the cross-sectional variation in inequality between countries than the time series dimension within countries.</p>
<p>The example is nevertheless instrumental in showing how the <em>fastverse</em>, in various respects, facilitates and enables complex data science in R.</p>
</div>
<div id="the-future" class="section level1">
<h1>The Future</h1>
<p>Future development of <em>collapse</em> will see an increased use of SIMD instructions to further increase performance. The impact of such instructions - visible in frameworks like Apache <a href="https://github.com/apache/arrow"><em>arrow</em></a> and Python’s <a href="https://github.com/pola-rs/polars"><em>polars</em></a> (which is based on <em>arrow</em>) can be considerable. The following shows a benchmark computing the means of a matrix with 100 columns and 1 million rows using base R, collapse 1.9.0 (no SIMD), and collapse 1.9.5 (with SIMD).</p>
<pre class="r"><code>library(collapse)
library(microbenchmark)

fmean19 &lt;- collapsedev19::fmean
m &lt;- rnorm(1e8)
dim(m) &lt;- c(1e6, 100) # matrix with 100 columns and 1 million rows

microbenchmark(colMeans(m), 
               fmean19(m, na.rm = FALSE), 
               fmean(m, na.rm = FALSE), 
               fmean(m), # default is na.rm = TRUE, can be changed with set_collapse()
               fmean19(m, nthreads = 4, na.rm = FALSE), 
               fmean(m, nthreads = 4, na.rm = FALSE), 
               fmean(m, nthreads = 4))
## Unit: milliseconds
##                                     expr      min       lq     mean   median       uq       max neval
##                              colMeans(m) 93.09308 97.52766 98.80975 97.99094 99.05563 190.67317   100
##                fmean19(m, na.rm = FALSE) 93.04056 97.47590 97.68101 98.05097 99.14058 100.48612   100
##                  fmean(m, na.rm = FALSE) 12.75202 13.04181 14.05289 13.49043 13.81448  18.79206   100
##                                 fmean(m) 12.67806 13.02974 14.02059 13.49638 13.81009  18.72581   100
##  fmean19(m, nthreads = 4, na.rm = FALSE) 24.84251 25.20573 26.12640 25.52416 27.08612  28.71300   100
##    fmean(m, nthreads = 4, na.rm = FALSE) 13.07941 13.18853 13.96326 13.38853 13.68627  18.04652   100
##                   fmean(m, nthreads = 4) 13.05813 13.18277 13.99704 13.33753 13.71505  19.18242   100</code></pre>
<p>Despite these impressive results, I am somewhat doubtful that much of <em>collapse</em> will benefit from SIMD. The main reason is that SIMD is a low-level vectorization that can be used to speed up simple operations like addition, subtraction, division, and multiplication. This is especially effective with large amounts of adjacent data. But with many groups and little data in each group, serial programming can be just as efficient or even more efficient if it allows writing grouped operations in a non-nested way. So it depends on the data to groups ratio. My <a href="https://github.com/SebKrantz/collapse/blob/master/misc/arrow%20benchmark/arrow_benchmark.md">arrow benchmark</a> from August 2022 showed just that: with few groups relative to the data size, <em>arrow</em> considerably outperforms <em>collapse</em> and <em>data.table</em>, but with more groups the latter catch up considerably and <em>collapse</em> took lead with many very small groups. More complex statistics algorithms like the median (involving selection) or mode / distinct value count (involving hashing), also cannot (to my knowledge) benefit from SIMD, and here <em>collapse</em> implementations are already pretty much state of the art.</p>
<p>Apart from additional vectorization, I am also considering a possible broadening of the package to support further data manipulation operations such as table joins. This may take a while for me to get into though, so I cannot promise an update including this in 2023. At this stage, I am very happy with the API, so no changes are planned here, and I will also try to keep <em>collapse</em> harmonious with other <em>fastverse</em> packages, in particular <em>data.table</em> and <em>kit</em>.</p>
<p>Most of all, I hope to see an increased breadth of statistical R packages using <em>collapse</em> as a backend, so that its potential for increasing the performance and complexity of statistical R packages is realized in the community. I have in the past assisted package maintainers interested in developing <em>collapse</em> backends and hope to increase further collaborations along these lines.</p>
<!--
I also hope that my own discipline of economics would realize the potential of *collapse* and the *fastverse* for economic research. The possibility to apply complex statistical operations effectively to large in-memory datasets - on which most modern day research is still based - should facilitate economic analysis along the lines outlined above. 
-->
<p>At last, I wish to thank all users that provided feedback and inspiration or promoted this software in the community, and more generally all people that encouraged, contributed to, and facilitated these projects. Much credit is also due to the CRAN maintainers who endured many of my mistakes and insisted on high standards, which made <em>collapse</em> better and more robust.</p>
</div>
<div class="footnotes footnotes-end-of-document">
<hr />
<ol>
<li id="fn1"><p><a href="https://sebkrantz.github.io/collapse/reference/qsu.html"><code>qsu()</code></a> was implemented fully in C++.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>Actually what is taking most time here is raising 12 million data points to a fractional power.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
<li id="fn3"><p>You can download the composites for South Africa from my drive. It actually took me a while to figure out how to properly extract the images from Earth Engine. You may find <a href="https://stackoverflow.com/questions/75822877/exporting-tif-images-from-google-earth-engine-to-google-drive-minimal-example-i">my answer</a> here helpful if you want to export images for other countries.<a href="#fnref3" class="footnote-back">↩︎</a></p></li>
<li id="fn4"><p>Not the sum, as there could be a differing amount of nightlights observations for each cell.<a href="#fnref4" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Introducing dfms: Efficient Estimation of Dynamic Factor Models in R</title>
      <link>https://sebkrantz.github.io/Rblog/2022/10/22/introducing-dfms-efficient-estimation-of-dynamic-factor-models-in-r/</link>
      <pubDate>Sat, 22 Oct 2022 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2022/10/22/introducing-dfms-efficient-estimation-of-dynamic-factor-models-in-r/</guid>
      <description><![CDATA[ 


<p>This short post notifies you of the CRAN release of a new R package, <a href="https://CRAN.R-project.org/package=dfms">dfms</a>, to efficiently estimate dynamic factor models in R using the Expectation Maximization (EM) algorithm and Kalman Filtering. Estimation can be done in 3 different ways following:</p>
<ul>
<li><p>Doz, C., Giannone, D., &amp; Reichlin, L. (2011). A two-step estimator for large approximate dynamic factor models based on Kalman filtering. <em>Journal of Econometrics, 164</em>(1), 188-205. &lt;doi.org/10.1016/j.jeconom.2011.02.012&gt;</p></li>
<li><p>Doz, C., Giannone, D., &amp; Reichlin, L. (2012). A quasi-maximum likelihood approach for large, approximate dynamic factor models. <em>Review of economics and statistics, 94</em>(4), 1014-1024. &lt;doi.org/10.1162/REST_a_00225&gt;</p></li>
<li><p>Banbura, M., &amp; Modugno, M. (2014). Maximum likelihood estimation of factor models on datasets with arbitrary pattern of missing data. <em>Journal of Applied Econometrics, 29</em>(1), 133-160. &lt;doi.org/10.1002/jae.2306&gt;</p></li>
</ul>
<p>The general model is</p>
<p><span class="math display">\[\textbf{x}_t = \textbf{C} \textbf{f}_t + \textbf{e}_t \ \sim\  N(\textbf{0}, \textbf{R})\]</span>
<span class="math display">\[\textbf{f}_t = \sum_{j=1}^p \textbf{A}_j \textbf{f}_{t-j} + \textbf{u}_t \ \sim\  N(\textbf{0}, \textbf{Q})\]</span></p>
<p>where the first equation is called the measurement or observation equation, the second equation is called transition, state or process equation, and</p>
<ul>
<li><span class="math inline">\(\textbf{x}_t\)</span> is a <span class="math inline">\(n \times 1\)</span> vector of observed series at time <span class="math inline">\(t\)</span></li>
<li><span class="math inline">\(\textbf{f}_t\)</span> is a <span class="math inline">\(r \times 1\)</span> vector of unobserved factors at time <span class="math inline">\(t\)</span></li>
<li><span class="math inline">\(\textbf{C}\)</span> is a <span class="math inline">\(n \times r\)</span> measurement (observation) matrix</li>
<li><span class="math inline">\(\textbf{A}_j\)</span> is a <span class="math inline">\(r \times r\)</span> state transition matrix at lag <span class="math inline">\(j\)</span></li>
<li><span class="math inline">\(\textbf{Q}\)</span> is a <span class="math inline">\(r \times r\)</span> state covariance matrix</li>
<li><span class="math inline">\(\textbf{R}\)</span> is a <span class="math inline">\(n \times n\)</span> measurement (observation) covariance matrix and assumed to be diagonal.</li>
</ul>
<p>Estimation is done by finding initial values of the model matrices through PCA, and using those to run a Kalman Filter and Smoother to obtain an estimate of <span class="math inline">\(\textbf{f}_t\)</span>. In EM estimation the system matrices are then updated with the estimates from the Kalman Filter and Smoother, and the data is filtered and smoothed again until convergence of the Kalman Filter log-likelihood.</p>
<p>Estimation with <em>dfms</em> is very efficient, powered by <a href="https://CRAN.R-project.org/package=RcppArmadillo">RcppArmadillo</a> and <a href="https://CRAN.R-project.org/package=collapse">collapse</a>, and supports arbitrary patterns of missing data following Banbura and Modugno (2014). A comprehensive set of methods allows for easy model interpretation and forecasting. The present release v0.1.3 does not support advanced DFM estimation features, such as accounting for serial correlation in <span class="math inline">\(\textbf{e}_t\)</span> or <span class="math inline">\(\textbf{u}_t\)</span>, series of mixed frequency in <span class="math inline">\(\textbf{x}_t\)</span>, time-varying system matrices <span class="math inline">\(\textbf{C}_t\)</span> and <span class="math inline">\(\textbf{A}_t\)</span> or structural breaks in the estimation. Some of these features may be added in the future.</p>
<p>To learn more about <em>dfms</em>, check out the <a href="https://sebkrantz.github.io/dfms/">website</a>, and in particular the <a href="https://sebkrantz.github.io/dfms/articles/introduction.html">introductory vignette</a>, which provides a short walk-through of the package.</p>
]]></description>
    </item>
    
    <item>
      <title>Introducing the fastverse: An Extensible Suite of High-Performance and Low-Dependency Packages for Statistical Computing and Data Manipulation</title>
      <link>https://sebkrantz.github.io/Rblog/2021/08/13/fastverse/</link>
      <pubDate>Fri, 13 Aug 2021 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2021/08/13/fastverse/</guid>
      <description><![CDATA[ 
<script src="/Rblog/rmarkdown-libs/header-attrs/header-attrs.js"></script>


<p><img src='fastverse_logo.png' width="350px" align="right" /></p>
<p>The <em>fastverse</em> is a suite of complementary high-performance packages for statistical computing and data manipulation in R. Developed independently by various people, <em>fastverse</em> packages jointly contribute to the objectives of:</p>
<ul>
<li>Speeding up R through heavy use of compiled code (C, C++, Fortran)</li>
<li>Enabling more complex statistical and data manipulation operations in R</li>
<li>Reducing the number of dependencies required for advanced computing in R.</li>
</ul>
<p>The <code>fastverse</code> package integrates, and provides utilities for easy installation, loading and management
of such packages. It is an extensible framework that allows users to freely (and permanently) add or remove packages to create a ‘verse’ of packages suiting their general needs. Separate ‘verses’ can be created for different projects.</p>
<p>The package thus extends the functionality provided by the <code>tidyverse</code> package, whereas <em>fastverse</em> packages themselves are quite different from <em>tidyverse</em> packages to the extent that they</p>
<ol style="list-style-type: decimal">
<li>Care less about having all data in tidy tibbbles but also support matrices and other objects</li>
<li>Take R to the limits in terms of performance</li>
<li>Don’t require a lot of dependencies (if any) and</li>
<li>Themselves implement and extend a lot of the statistical functionality found in <code>base</code> and <code>stats</code>.</li>
</ol>
<div id="core-packages" class="section level1">
<h1>Core Packages</h1>
<p>The <em>fastverse</em> installs 6 core packages (<em>data.table</em>, <em>collapse</em>, <em>matrixStats</em>, <em>kit</em>, <em>magrittr</em> and <em>fst</em>) that are attached with <code>library(fastverse)</code>. These packages were selected because they provide high quality compiled code for most common statistical and data manipulation tasks, have carefully managed APIs, jointly depend only on base R and <em>Rcpp</em>, and work very well together. The functionality offered by these packages can be summarized as follows:</p>
<ul>
<li><p><strong>data.table</strong>: Enhanced data frame class with concise data manipulation framework offering powerful aggregation, extremely flexible split-apply-combine computing, reshaping, joins, rolling statistics, set operations on tables, fast csv read/write, and various utilities such as transposition of data.</p></li>
<li><p><strong>collapse</strong>: Fast grouped &amp; weighted statistical computations, time series and panel data transformations, list-processing, data manipulation functions, summary statistics and various utilities such as support for variable labels. Class-agnostic framework designed to work with vectors, matrices, data frames, lists and related classes i.e. <em>xts</em>, <em>data.table</em>, <em>tibble</em>, <em>pdata.frame</em>, <em>sf</em>. <!-- *tsibble*, *tibbletime* --></p></li>
<li><p><strong>matrixStats</strong>: Efficient row-and column-wise (weighted) statistics on matrices and vectors, including computations on subsets of rows and columns.</p></li>
<li><p><strong>kit</strong>: Fast vectorized and nested switches, some parallel (row-wise) statistics, and some utilities such as efficient partial sorting and unique values.</p></li>
<li><p><strong>magrittr</strong>: Efficient pipe operators for enhanced programming and code unnesting.</p></li>
<li><p><strong>fst</strong>: A compressed data file format that is very fast to read and write. Full random access in both rows and columns allows reading subsets from a ‘.fst’ file.</p></li>
</ul>
<div id="fastverse-functions-and-options" class="section level2">
<h2><em>fastverse</em> Functions and Options</h2>
<p>The package then offers several functions starting with <code>fastverse_</code> that help manage dependencies, detect namespace conflicts, add/remove packages from the <em>fastverse</em> and update packages:</p>
<div id="functions-to-extend-or-reduce-the-number-of-packages-in-the-fastverse" class="section level4">
<h4>Functions to extend or reduce the number of packages in the <em>fastverse</em></h4>
<ul>
<li><code>fastverse_extend()</code></li>
<li><code>fastverse_detach()</code></li>
<li><code>fastverse_reset()</code></li>
</ul>
</div>
<div id="function-to-display-conflicts-for-fastverse-packages" class="section level4">
<h4>Function to display conflicts for <em>fastverse</em> packages</h4>
<ul>
<li><code>fastverse_conflicts()</code></li>
</ul>
</div>
<div id="function-to-update-fastverse-packages" class="section level4">
<h4>Function to update <em>fastverse</em> packages</h4>
<ul>
<li><code>fastverse_update()</code></li>
</ul>
</div>
<div id="utilities-to-retrieve-the-names-of-fastverse-packages-their-depenencies-and-produce-a-situation-report" class="section level4">
<h4>Utilities to retrieve the names of <em>fastverse</em> packages, their depenencies, and produce a situation report</h4>
<ul>
<li><code>fastverse_packages()</code></li>
<li><code>fastverse_deps()</code></li>
<li><code>fastverse_sitrep()</code></li>
</ul>
</div>
<div id="options" class="section level4">
<h4>Options</h4>
<ul>
<li><code>options(fastverse.quiet = TRUE)</code> disables all automatic messages (incl. conflict reporting).</li>
<li><code>options(fastverse.styling = FALSE)</code> disables all styling applied to text printed to the console.</li>
<li><code>options(fastverse.extend = c(...))</code> contains names of packages used to extend the <em>fastverse</em>.</li>
</ul>
</div>
</div>
<div id="extending-the-fastverse" class="section level2">
<h2>Extending the <em>fastverse</em></h2>
<p>As mentioned already, a notable feature of the <em>fastverse</em> is that it is not a ‘rigid’ set of core packages, but can be extended or reduced, temporarily or permanently, globally or for specific projects, thus allowing you to create ‘verses’ of packages to your liking.</p>
<div id="extending-for-the-session" class="section level3">
<h3>Extending for the Session</h3>
<p>Let’s say I have loaded the <em>fastverse</em> and I want to load additionally packages in my R session. I can do that with <code>fastverse_extend()</code>, which has the additional advantage that it checks for conflicts, and I can install any packages not available using <code>install = TRUE</code> before attaching them.</p>
<pre class="r"><code>options(fastverse.styling = FALSE) # Disabling coloured output for this post
library(fastverse)
## -- Attaching packages --------------------------------------- fastverse 0.1.8 --
## v data.table  1.14.0     v collapse    1.6.5 
## v magrittr    2.0.1      v matrixStats 0.60.0
## v kit         0.0.7      v fst         0.9.4

# Extending the fastverse for the session, installing packages if unavailable
fastverse_extend(fixest, robustbase, xts, roll, ggplot2, Rfast, install = TRUE)
## -- Attaching extension packages ----------------------------- fastverse 0.1.8 --
## v fixest     0.8.4      v roll       1.1.6 
## v robustbase 0.93.6     v ggplot2    3.3.5 
## v xts        0.12.1     v Rfast      2.0.1
## -- Conflicts ------------------------------------------ fastverse_conflicts() --
## x Rfast::colMads()    masks matrixStats::colMads()
## x Rfast::colMaxs()    masks matrixStats::colMaxs()
## x Rfast::colMedians() masks robustbase::colMedians(), matrixStats::colMedians()
## x Rfast::colMins()    masks matrixStats::colMins()
## x Rfast::colRanks()   masks matrixStats::colRanks()
## x Rfast::colVars()    masks matrixStats::colVars()
## x xts::first()        masks data.table::first()
## x xts::last()         masks data.table::last()
## x robustbase::plot()  masks graphics::plot(), base::plot()
## x Rfast::rowMads()    masks matrixStats::rowMads()
## x Rfast::rowMaxs()    masks matrixStats::rowMaxs()
## x Rfast::rowMedians() masks robustbase::rowMedians(), matrixStats::rowMedians()
## x Rfast::rowMins()    masks matrixStats::rowMins()
## x Rfast::rowRanks()   masks matrixStats::rowRanks()
## x Rfast::rowVars()    masks matrixStats::rowVars()
## x Rfast::transpose()  masks data.table::transpose()</code></pre>
<p>These packages are now part of the <em>fastverse</em>. For example when generating a situation report they and (by default) their direct dependencies are included:</p>
<pre class="r"><code># Generate situation report
fastverse_sitrep()
## -- fastverse 0.1.8: Situation Report -------------------------------- R 4.1.0 --
##  * Global config file: FALSE
##  * Project config file: FALSE
## -- Core packages --------------------------------------------------------------- 
##  * data.table    (1.14.0)
##  * magrittr      (2.0.1)
##  * kit           (0.0.7)
##  * collapse      (1.6.5)
##  * matrixStats   (0.60.0)
##  * fst           (0.9.4)
## -- Extension packages ---------------------------------------------------------- 
##  * fixest        (0.8.4 &lt; 0.9.0)
##  * robustbase    (0.93.6 &lt; 0.93.8)
##  * xts           (0.12.1)
##  * roll          (1.1.6)
##  * ggplot2       (3.3.5)
##  * Rfast         (2.0.1 &lt; 2.0.3)
## -- Dependencies ---------------------------------------------------------------- 
##  * DEoptimR      (1.0.8 &lt; 1.0.9)
##  * digest        (0.6.27)
##  * dreamerr      (1.2.3)
##  * glue          (1.4.2)
##  * gtable        (0.3.0)
##  * isoband       (0.2.5)
##  * MASS          (7.3.54)
##  * mgcv          (1.8.36)
##  * nlme          (3.1.152)
##  * numDeriv      (2016.8.1.1)
##  * Rcpp          (1.0.7)
##  * RcppArmadillo (0.10.2.1.0 &lt; 0.10.6.0.0)
##  * RcppParallel  (5.0.2 &lt; 5.1.4)
##  * RcppZiggurat  (0.1.5 &lt; 0.1.6)
##  * rlang         (0.4.11)
##  * sandwich      (3.0.0 &lt; 3.0.1)
##  * scales        (1.1.1)
##  * tibble        (3.1.3)
##  * withr         (2.4.2)
##  * zoo           (1.8.9)</code></pre>
<p>The packages are saved in <code>options("fastverse.extend")</code>. Detaching the <em>fastverse</em> does not (by default) remove packages from the option, but this can be changed with <code>fastverse_detach(..., session = TRUE)</code>:</p>
<pre class="r"><code># Extensions are here
options(&quot;fastverse.extend&quot;)
## $fastverse.extend
## [1] &quot;fixest&quot;     &quot;robustbase&quot; &quot;xts&quot;        &quot;roll&quot;       &quot;ggplot2&quot;   
## [6] &quot;Rfast&quot;

# This detaches Rfast and roll, also removing them from options(&quot;fastverse.extend&quot;)
fastverse_detach(Rfast, roll, session = TRUE)
# This confirms that they were removed
fastverse_packages()
##  [1] &quot;data.table&quot;  &quot;magrittr&quot;    &quot;kit&quot;         &quot;collapse&quot;    &quot;matrixStats&quot;
##  [6] &quot;fst&quot;         &quot;fixest&quot;      &quot;robustbase&quot;  &quot;xts&quot;         &quot;ggplot2&quot;    
## [11] &quot;fastverse&quot;
# Now detaching all packages
fastverse_detach()
# Attaching again includes extensions except for &quot;Rfast&quot; and &quot;roll&quot;
library(fastverse)
## -- Attaching packages --------------------------------------- fastverse 0.1.8 --
## v data.table  1.14.0     v fst         0.9.4 
## v magrittr    2.0.1      v fixest      0.8.4 
## v kit         0.0.7      v robustbase  0.93.6
## v collapse    1.6.5      v xts         0.12.1
## v matrixStats 0.60.0     v ggplot2     3.3.5
## -- Conflicts ------------------------------------------ fastverse_conflicts() --
## x matrixStats::colMedians() masks robustbase::colMedians()
## x xts::first()              masks data.table::first()
## x collapse::is.regular()    masks zoo::is.regular()
## x xts::last()               masks data.table::last()
## x robustbase::plot()        masks graphics::plot(), base::plot()
## x matrixStats::rowMedians() masks robustbase::rowMedians()

# This detaches all packages and clears all options
fastverse_detach(session = TRUE)</code></pre>
<p>Note that it is also possible to extend the <em>fastverse</em> for the session by simply populating <code>options("fastverse.extend")</code> before calling <code>library(fastverse)</code>, but using <code>fastverse_extend()</code> is more secure, especially with option <code>install = TRUE</code>.</p>
</div>
</div>
<div id="permanent-extensions" class="section level2">
<h2>Permanent Extensions</h2>
<p><code>fasvtverse_extend()</code> and <code>fastverse_detach()</code> both have an argument <code>permanent = TRUE</code>, which can be used to make these changes persist across R sessions. This is implemented using a global configuration file saved to the package directory<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a>.</p>
<p>For example, suppose most of my work involves time series analysis, and I would like to add <em>xts</em>, <em>zoo</em>, <em>roll</em>, and <em>dygraphs</em> to my <em>fastverse</em>. Let’s say I also don’t really use the <em>fst</em> file format, and I don’t really need <em>matrixStats</em> either as I can do most of the time series statistics I need with base R and <em>collapse</em>. Let’s finally say that I don’t want <code>xts::first</code> and <code>xts::last</code> to mask <code>data.table::first</code> and <code>data.table::last</code>.</p>
<p>Then I could permanently modify my <em>fastverse</em> as follows<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a>:</p>
<pre class="r"><code>options(fastverse.styling = FALSE)
library(fastverse)
## -- Attaching packages --------------------------------------- fastverse 0.1.8 --
## v data.table  1.14.0     v collapse    1.6.5 
## v magrittr    2.0.1      v matrixStats 0.60.0
## v kit         0.0.7      v fst         0.9.4
## -- Conflicts ------------------------------------------ fastverse_conflicts() --
## x collapse::is.regular() masks zoo::is.regular()

# Adding extensions
fastverse_extend(xts, zoo, roll, dygraphs, permanent = TRUE)
## -- Attaching extension packages ----------------------------- fastverse 0.1.8 --
## v xts      0.12.1      v dygraphs 1.1.1.6
## v roll     1.1.6
## -- Conflicts ------------------------------------------ fastverse_conflicts() --
## x zoo::as.Date()         masks base::as.Date()
## x zoo::as.Date.numeric() masks base::as.Date.numeric()
## x xts::first()           masks data.table::first()
## x collapse::is.regular() masks zoo::is.regular()
## x xts::last()            masks data.table::last()

# Removing some core packages
fastverse_detach(data.table, fst, matrixStats, permanent = TRUE)
# Adding data.table again, so it is attached last
fastverse_extend(data.table, permanent = TRUE)
## -- Attaching extension packages ----------------------------- fastverse 0.1.8 --
## v data.table 1.14.0
## -- Conflicts ------------------------------------------ fastverse_conflicts() --
## x data.table::first() masks xts::first()
## x data.table::last()  masks xts::last()</code></pre>
<p>Note that <code>options("fastverse.extend")</code> is still empty, because we have written those changes to a config file<a href="#fn3" class="footnote-ref" id="fnref3"><sup>3</sup></a>. Now lets see if our permanent modification worked:</p>
<pre class="r"><code># detach all packages and clear all options
fastverse_detach(session = TRUE)
options(fastverse.styling = FALSE)
library(fastverse) 
## -- Attaching packages --------------------------------------- fastverse 0.1.8 --
## v magrittr   2.0.1       v zoo        1.8.9  
## v kit        0.0.7       v roll       1.1.6  
## v collapse   1.6.5       v dygraphs   1.1.1.6
## v xts        0.12.1      v data.table 1.14.0
## -- Conflicts ------------------------------------------ fastverse_conflicts() --
## x zoo::as.Date()         masks base::as.Date()
## x zoo::as.Date.numeric() masks base::as.Date.numeric()
## x data.table::first()    masks xts::first()
## x zoo::is.regular()      masks collapse::is.regular()
## x data.table::last()     masks xts::last()</code></pre>
<p>After this permanent modification, the <em>fastverse</em> can still be extend for the session using <code>fastverse_extend()</code>. As long as the current installation of the <em>fastverse</em> is kept, these modifications will persist across R sessions, unless the config file is removed with <code>fastverse_reset()</code>. The <em>fastverse</em> also offers a more persistent mechanism to configure it inside projects:</p>
</div>
<div id="custom-fastverse-configurations-for-projects" class="section level2">
<h2>Custom <em>fastverse</em> Configurations for Projects</h2>
<p>The <em>fastverse</em> gives you the ability to put together a custom collection of packages for a project and load and manage them with <code>library(fastverse)</code>.</p>
<p>To do this you need to include a configuration file named <code>.fastverse</code> (no file extension) inside a project directory, and place inside that file the names of packages to be loaded as core <em>fastvere</em><a href="#fn4" class="footnote-ref" id="fnref4"><sup>4</sup></a>.</p>
<p>Using a config file in a project will ignore any global configuration as discussed in the previous section. You can still extend the <em>fastverse</em> inside a project session using <code>fastverse_extend()</code> (or <code>options("fastvers.extend")</code> before <code>library(fastverse)</code>).</p>
</div>
<div id="suggested-extensions" class="section level2">
<h2>Suggested Extensions</h2>
<p>The <em>fastverse</em> can be freely extended with any R package, but I have put together a list of suggested high-performance and low-dependency packages in the <a href="https://fastverse.github.io/fastverse/#suggested-extensions">README</a> file. You are free to contribute to this list, so that it becomes a comprehensive showcase of R’s high-performance world as far as statistical computing and data manipulation is concerned.</p>
</div>
<div id="installation-versions-of-the-fastverse" class="section level2">
<h2>Installation: Versions of the <em>fastverse</em></h2>
<p>Currently, there are 2 different versions of the <em>fastverse</em> on CRAN and GitHub. The GitHub version is recommended if you want to have <em>matrixStats</em> consistently preserve attributes of your matrices: it modifies functions in the <em>matrixStats</em> namespace making them preserve attributes consistently (and by default) whenever the <em>fastverse</em> is attached<a href="#fn5" class="footnote-ref" id="fnref5"><sup>5</sup></a>. The CRAN version takes <em>matrixStats</em> as it is, which means most functions do not (by default) preserve attributes such as dimension names in computations.</p>
<pre class="r"><code># Install the CRAN version
install.packages(&quot;fastverse&quot;)

# Install the GitHub version (Requires Rtools)
remotes::install_github(&quot;SebKrantz/fastverse&quot;)</code></pre>
<p><em>matrixStats</em> is slowly evolving towards greater consistency, but it might take more than half a year until dimension names are handled consistently by default - due to the large number of reverse dependencies. Until then CRAN and GitHub versions of the <em>fastverse</em> are released together.</p>
</div>
<div id="conclusion" class="section level2">
<h2>Conclusion</h2>
<p>The <em>fastverse</em> was developed principally for 2 reasons: to promote quality high-performance software development for R, and to provide a flexible approach to package loading and management in R, particularly for users wishing to combine various high-performance packages in statistical workflows.</p>
<p>This post already introduced most of what there is to know about the <em>fastverse</em>. Additional details are found on the <a href="https://fastverse.github.io/fastverse/">website</a>.</p>
<pre class="r"><code># Resetting the fastverse to defaults (clearing all permanent extensions and options)
fastverse_reset()
# Detaching 
fastverse_detach()</code></pre>
</div>
</div>
<div class="footnotes">
<hr />
<ol>
<li id="fn1"><p>Thus it will be removed when the <em>fastverse</em> is reinstalled.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>I note that namespace conflicts can also be detected and handled with the <a href="https://cran.r-project.org/package=conflicted">conflicted</a> package on CRAN.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
<li id="fn3"><p>When fetching the names of <em>fastverse</em> packages, <code>fastverse_packages</code> first checks any config file and then checks <code>options("fastverse.extend")</code>.<a href="#fnref3" class="footnote-back">↩︎</a></p></li>
<li id="fn4"><p>You can place package names in that file any manner you deem suitable: separated using spaces or commas, on one or multiple lines. Note that the file will be read from left to right and from top to bottom. Packages are attached in the order found in the file. Note that <strong>all</strong> packages to be loaded as core <em>fastverse</em> for your project need to be included in that file, in the order they should be attached.<a href="#fnref4" class="footnote-back">↩︎</a></p></li>
<li id="fn5"><p>The functions which are replaced are contained in a global variable <code>.matrixStats_replaced</code>, available in the GitHub version. Replacement happens whenever the <em>fastverse</em> is attached, regardless of whether <em>matrixStats</em> was attached before or is attached with the <em>fastverse</em>. Only if <em>matrixStats</em> is removed from the <em>fastverse</em> with <code>fastverse_detach(matrixStats, permanent = TRUE)</code> the functions will not be modified in the GitHub version.<a href="#fnref5" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Fast and Easy Aggregation of Multi-Type and Survey Data in R</title>
      <link>https://sebkrantz.github.io/Rblog/2021/01/09/advanced-data-aggregation/</link>
      <pubDate>Sat, 09 Jan 2021 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2021/01/09/advanced-data-aggregation/</guid>
      <description><![CDATA[ 
<link href="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.css" rel="stylesheet" />
<script src="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.js"></script>


<p><a href="https://sebkrantz.github.io/collapse/"><em>collapse</em></a> is a C/C++ based package to facilitate and speed up advanced statistical computations in R. One of the key objectives for creating it was to introduce in R a fast, consistent, and easy to use toolset for aggregating complex datasets. This post showcases this functionality by aggregating 3 quite different survey datasets I happened to have used recently for a project:</p>
<ul>
<li><p>A births dataset from the 2016 Demographic and Health Survey for Uganda (used for child mortality estimates, available <a href="https://dhsprogram.com/data/dataset/Uganda_Standard-DHS_2016.cfm?flag=0"><em>here</em></a>).</p></li>
<li><p>A dataset of poverty estimates from the Uganda National Household Survey 2016/17 (used to compute district level poverty indicators, not available for direct download, documented <a href="https://ubos.org/wp-content/uploads/publications/03_20182016_UNHS_FINAL_REPORT.pdf"><em>here</em></a>).</p></li>
<li><p>The Uganda National Population and Housing Census 2014 (for district level population estimates and other data, available <a href="https://mepd.shinyapps.io/Macro-Data-Portal/"><em>here</em></a> under UBOS).</p></li>
</ul>
<p>First, the STATA files are imported using the <em>haven</em> library. Columns with only missing values are removed from the DHS dataset, encoded columns are converted to factor variables.</p>
<pre class="r"><code>library(haven)
library(magrittr)
library(collapse)

# Uganda Demographic and Health Survey 2016: Birth Recode
DHSBR &lt;- paste0(DHS_path, &quot;/Data/UGBR7BDT - Births Recode/UGBR7BFL.dta&quot;) %&gt;%  
         read_dta %&gt;% get_vars(fNobs(.) &gt; 0L) %&gt;% as_factor

# Uganda National Household Survey 2016/17: Poverty Estimates
UNHSPOV &lt;- paste0(UNHS_path, &quot;/Household/pov16_rev1.dta&quot;) %&gt;% 
           read_dta %&gt;% as_factor

# Uganda National Population and Housing Census 2014
CENS &lt;- paste0(CENS_path, &quot;/UBOS 2014 Census.dta&quot;) %&gt;% read_dta </code></pre>
<p>We start with aggregating the DHS dataset. This data has 786 variables, most of which are categorical:</p>
<pre class="r"><code>fdim(DHSBR)
## [1] 57906   786

table(vclasses(DHSBR))
## 
## character    factor   numeric 
##         2       696        88</code></pre>
<p>We can obtain a detailed statistical summary of the data using <code>descr</code>. The output prints nicely to the console, but can also be converted to a data.frame.</p>
<pre class="r"><code>descr(DHSBR, table = FALSE) %&gt;% as.data.frame %&gt;% head(10)
##    Variable     Class                                         Label     N Ndist         Mean
## 1    caseid character                           case identification 57906 13745           NA
## 2      bidx   numeric                           birth column number 57906    18 3.486720e+00
## 3      v000 character                        country code and phase 57906     1           NA
## 4      v001   numeric                                cluster number 57906   696 3.557185e+02
## 5      v002   numeric                              household number 57906   221 2.558897e+01
## 6      v003   numeric                      respondent&#39;s line number 57906    20 1.960799e+00
## 7      v004   numeric                            ultimate area unit 57906   696 3.557185e+02
## 8      v005   numeric women&#39;s individual sample weight (6 decimals) 57906   686 9.848528e+05
## 9      v006   numeric                            month of interview 57906     7 8.630176e+00
## 10     v007   numeric                             year of interview 57906     1 2.016000e+03
##              SD   Min     Max        Skew      Kurt     1%     5%    25%    50%     75%     95%
## 1            NA    NA      NA          NA        NA     NA     NA     NA     NA      NA      NA
## 2  2.367381e+00     1      18  1.05848241  3.806124      1      1      2      3       5       8
## 3            NA    NA      NA          NA        NA     NA     NA     NA     NA      NA      NA
## 4  1.915351e+02     1     697  0.01173270  1.881827     13     53    195    356     519     664
## 5  2.926832e+01     1     545  3.89808066 31.759599      1      2     10     19      28      86
## 6  1.201193e+00     1      21  5.53129314 49.135251      1      1      1      2       2       3
## 7  1.915351e+02     1     697  0.01173270  1.881827     13     53    195    356     519     664
## 8  5.543562e+05 45069 5145429  1.78199379  9.540138 102618 227215 702216 896184 1186668 1973187
## 9  1.496144e+00     6      12 -0.01157971  2.034968      6      6      7      9      10      11
## 10 0.000000e+00  2016    2016         NaN       NaN   2016   2016   2016   2016    2016    2016
##        99%
## 1       NA
## 2       10
## 3       NA
## 4      691
## 5      140
## 6        8
## 7      691
## 8  3142092
## 9       11
## 10    2016</code></pre>
<!-- The sample is based on a stratified two-stage cluster design. 696 Enumeration Areas (Clusters) were drawn from the sampling frame of the 2014 Census and a sample of households is drawn from an updated list of households within the cluster (on average a cluster had 130 households). -->
<p>The DHS sample comprises 20,880 selected households and 18,506 women being interviewed. Of these women 13,745 had given birth and are recorded in this dataset. As the descriptive statistics above show, the first column gives the women-id (caseid), and the second column an integer id (bidx) for each of the born children.</p>
<p>The aggregation task for this dataset shall simply be to aggregate over the children for each women. A first step to decide how this aggregation is to be done is to examine which variables vary by women i.e. contain child characteristics.</p>
<pre class="r"><code># Tabulate child-variant variables
table(varying(DHSBR, ~ caseid))
## 
## FALSE  TRUE 
##   521   264

# Examine the numeric child-variant variables
DHSBR %&gt;% fgroup_by(caseid) %&gt;% num_vars %&gt;% 
  get_vars(varying(.)) %&gt;% namlab
##    Variable                                                                 Label
## 1      bidx                                                   birth column number
## 2      bord                                                    birth order number
## 3        b1                                                        month of birth
## 4        b2                                                         year of birth
## 5        b3                                                   date of birth (cmc)
## 6        b7                                        age at death (months, imputed)
## 7        b8                                                  current age of child
## 8       b11                                     preceding birth interval (months)
## 9       b12                                    succeeding birth interval (months)
## 10      b17                                                          day of birth
## 11      b18                                       century day code of birth (cdc)
## 12      b19 current age of child in months (months since birth for dead children)
## 13      b20                                                 duration of pregnancy
## 14     midx                                                index to birth history
## 15     hidx                                                index to birth history
## 16    hidxa                                                index to birth history
## 17    hwidx                                                index to birth history
## 18      hw1                                                 child&#39;s age in months
## 19    idxml                                                index to birth history
## 20    idx94                                                index to birth history</code></pre>
<p>These are all variables that we would prefer to aggregate using the average, not the sum or extreme values. It is also noteworthy that the weights don’t vary by child, but only by women, so weighted aggregation is actually not necessary in this case.</p>
<pre class="r"><code># Renaming weights variable
setrename(DHSBR, v005 = weights)
# Confirm that it does not vary by child
varying(DHSBR, weights ~ caseid)
## weights 
##   FALSE</code></pre>
<p>Thus aggregation in this case is very simple using the <code>collap()</code> function, which by default aggregates numeric columns using the mean, and categorical columns using the statistical mode (i.e. the most frequent value):</p>
<pre class="r"><code># Aggregating, same as collap(DHSBR, ~ caseid, fmean, fmode), or collapv(DHSBR, 1)
DHSBR_agg &lt;- collap(DHSBR, ~ caseid) %&gt;% fdroplevels

head(DHSBR_agg)
## # A tibble: 6 x 786
##   caseid    bidx v000   v001  v002  v003  v004   v005  v006  v007  v008 v008a  v009  v010  v011  v012
##   &lt;chr&gt;    &lt;dbl&gt; &lt;chr&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;  &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;
## 1 &quot;    00~   1.5 UG7       1     3     2     1 1.10e6     8  2016  1400 42613     7  1991  1099    25
## 2 &quot;    00~   1.5 UG7       1     4     1     1 1.10e6     8  2016  1400 42609    12  1975   912    40
## 3 &quot;    00~   1   UG7       1     4     2     1 1.10e6     8  2016  1400 42609     7  1995  1147    21
## 4 &quot;    00~   1.5 UG7       1     4     6     1 1.10e6     8  2016  1400 42611     1  1993  1117    23
## 5 &quot;    00~   1.5 UG7       1     4     7     1 1.10e6     8  2016  1400 42609     2  1986  1034    30
## 6 &quot;    00~   1   UG7       1     4     8     1 1.10e6     8  2016  1400 42609     5  1989  1073    27
## # ... with 770 more variables: v013 &lt;fct&gt;, v014 &lt;fct&gt;, v015 &lt;fct&gt;, v016 &lt;dbl&gt;, v017 &lt;dbl&gt;,
## #   v018 &lt;fct&gt;, v019 &lt;fct&gt;, v019a &lt;fct&gt;, v020 &lt;fct&gt;, v021 &lt;dbl&gt;, v022 &lt;fct&gt;, v023 &lt;fct&gt;, v024 &lt;fct&gt;,
## #   v025 &lt;fct&gt;, v027 &lt;dbl&gt;, v028 &lt;dbl&gt;, v030 &lt;dbl&gt;, v034 &lt;fct&gt;, v040 &lt;dbl&gt;, v042 &lt;fct&gt;, v044 &lt;fct&gt;,
## #   v045a &lt;fct&gt;, v045b &lt;fct&gt;, v045c &lt;fct&gt;, v046 &lt;fct&gt;, v101 &lt;fct&gt;, v102 &lt;fct&gt;, v104 &lt;fct&gt;,
## #   v105 &lt;fct&gt;, v106 &lt;fct&gt;, v107 &lt;fct&gt;, v113 &lt;fct&gt;, v115 &lt;fct&gt;, v116 &lt;fct&gt;, v119 &lt;fct&gt;, v120 &lt;fct&gt;,
## #   v121 &lt;fct&gt;, v122 &lt;fct&gt;, v123 &lt;fct&gt;, v124 &lt;fct&gt;, v125 &lt;fct&gt;, v127 &lt;fct&gt;, v128 &lt;fct&gt;, v129 &lt;fct&gt;,
## #   v130 &lt;fct&gt;, v131 &lt;fct&gt;, v133 &lt;fct&gt;, v135 &lt;fct&gt;, v136 &lt;dbl&gt;, v137 &lt;dbl&gt;, v138 &lt;dbl&gt;, v139 &lt;fct&gt;,
## #   v140 &lt;fct&gt;, v149 &lt;fct&gt;, v150 &lt;fct&gt;, v151 &lt;fct&gt;, v152 &lt;fct&gt;, v153 &lt;fct&gt;, awfactt &lt;dbl&gt;,
## #   awfactu &lt;dbl&gt;, awfactr &lt;dbl&gt;, awfacte &lt;dbl&gt;, awfactw &lt;dbl&gt;, v155 &lt;fct&gt;, v157 &lt;fct&gt;, v158 &lt;fct&gt;,
## #   v159 &lt;fct&gt;, v160 &lt;fct&gt;, v161 &lt;fct&gt;, v167 &lt;fct&gt;, v168 &lt;fct&gt;, v169a &lt;fct&gt;, v169b &lt;fct&gt;,
## #   v170 &lt;fct&gt;, v171a &lt;fct&gt;, v171b &lt;fct&gt;, v190 &lt;fct&gt;, v191 &lt;dbl&gt;, v190a &lt;fct&gt;, v191a &lt;dbl&gt;,
## #   ml101 &lt;fct&gt;, v201 &lt;dbl&gt;, v202 &lt;dbl&gt;, v203 &lt;dbl&gt;, v204 &lt;dbl&gt;, v205 &lt;dbl&gt;, v206 &lt;dbl&gt;, v207 &lt;dbl&gt;,
## #   v208 &lt;fct&gt;, v209 &lt;fct&gt;, v210 &lt;dbl&gt;, v211 &lt;dbl&gt;, v212 &lt;dbl&gt;, v213 &lt;fct&gt;, v214 &lt;dbl&gt;, v215 &lt;fct&gt;,
## #   v216 &lt;fct&gt;, v217 &lt;fct&gt;, v218 &lt;dbl&gt;, v219 &lt;dbl&gt;, ...

# Aggregating preserves column order and data types / classes + attributes
identical(namlab(DHSBR_agg, class = TRUE), 
          namlab(DHSBR, class = TRUE))
## [1] TRUE</code></pre>
<p>Apart from the simplicity and speed of this solution, <code>collap()</code> by default preserves the original column order (argument <code>keep.col.order = TRUE</code>) and all attributes of columns and the data frame itself. So we can truly speak of an aggregated / collapsed version of this dataset. Calling <code>fdroplevels</code> on the result is a likewise highly optimized and non-destructive solution to dropping any redundant factor levels from any of the 696 aggregated factor variables.</p>
<p>Let us now consider the poverty estimates dataset:</p>
<pre class="r"><code>fdim(UNHSPOV)
## [1] 15636    44

table(vclasses(UNHSPOV))
## 
##  factor numeric 
##      17      27

descr(UNHSPOV, table = FALSE) %&gt;% as.data.frame %&gt;% head(10)
##    Variable   Class                          Label     N Ndist         Mean           SD       Min
## 1      hhid numeric  Unique identifier  in 2016/17 15636 15636 89610.296943 50753.531112 201.00000
## 2  finalwgt numeric                           &lt;NA&gt; 15636  1731   540.811778   519.368731  10.65561
## 3  district  factor                  District Code 15636   112           NA           NA        NA
## 4        ea numeric               Enumeration area 15636    67     9.157265    10.810512   1.00000
## 5     urban  factor         Urban/Rural Identifier 15636     2           NA           NA        NA
## 6    subreg  factor         15      sub     region 15636    15           NA           NA        NA
## 7    region  factor Region of Residence in 2016/17 15636     4           NA           NA        NA
## 8    regurb  factor             RegionxRural/Urban 15636     8           NA           NA        NA
## 9     equiv numeric                    (sum) equiv 15636  9448     3.438747     1.897926   0.71000
## 10    hsize numeric                    (sum) hsize 15636    20     4.515285     2.548680   1.00000
##             Max        Skew      Kurt         1%           5%          25%          50%          75%
## 1  178010.00000 0.002337925  1.833309 2102.35000 9907.7500000 46178.250000 89401.500000 1.327083e+05
## 2    5156.81494 3.097397657 18.780390   34.65487   76.0465393   207.895950   399.305145 6.978978e+02
## 3            NA          NA        NA         NA           NA           NA           NA           NA
## 4      90.00000 3.683418249 21.263899    1.00000    1.0000000     3.000000     6.000000 1.100000e+01
## 5            NA          NA        NA         NA           NA           NA           NA           NA
## 6            NA          NA        NA         NA           NA           NA           NA           NA
## 7            NA          NA        NA         NA           NA           NA           NA           NA
## 8            NA          NA        NA         NA           NA           NA           NA           NA
## 9      17.28507 0.904448197  4.183096    0.77380    0.8743333     2.009667     3.146083 4.559833e+00
## 10     23.00000 0.734721072  3.761180    1.00000    1.0000000     3.000000     4.000000 6.000000e+00
##             95%          99%
## 1  1.695023e+05 176403.65000
## 2  1.444975e+03   2700.59717
## 3            NA           NA
## 4  2.800000e+01     60.00000
## 5            NA           NA
## 6            NA           NA
## 7            NA           NA
## 8            NA           NA
## 9  6.972708e+00      8.84461
## 10 9.000000e+00     12.00000</code></pre>
<p>Using the <code>qsu()</code> function, we can also summarize the variation in two of the key variables between district averages and within districts, separated for rural and urban areas. This can give us an idea of the variation in poverty levels we are erasing by aggregating this data to the district level.</p>
<pre class="r"><code>qsu(UNHSPOV, fexp30 + welfare ~ urban, ~ district, ~ finalwgt, 
    vlabels = TRUE)[,&quot;SD&quot;,,] # Showing only the standard deviation (SD)
## , , fexp30: Monthly food expenses
## 
##           Overall     Between      Within
## Rural  168101.761  47831.6226  161254.386
## Urban   243424.17  56966.9794  240210.089
## 
## , , welfare: Welfare based on usual members present
## 
##           Overall     Between      Within
## Rural  99872.8917  35288.1075  95355.6836
## Urban  202069.239   64221.637  195061.104</code></pre>
<p>The variance breakdown shows that apart from rural welfare, most of the variation in food expenditure and welfare levels is between district averages rather than within districts. We can again examine the numeric variables:</p>
<pre class="r"><code>UNHSPOV %&gt;% num_vars %&gt;% namlab
##    Variable                                                                            Label
## 1      hhid                                                    Unique identifier  in 2016/17
## 2  finalwgt                                                                             &lt;NA&gt;
## 3        ea                                                                 Enumeration area
## 4     equiv                                                                      (sum) equiv
## 5     hsize                                                                      (sum) hsize
## 6    fexp30                                                            Monthly food expenses
## 7    rexp30                     Monthly household expenditures after adjusting for inflation
## 8   rrfxp30                                                                             &lt;NA&gt;
## 9   rrexp30 Monthly household expenditures in real prices after adjusting for regional price
## 10 nrrexp30 Monthly nominal household expenditures in market prices &amp; after regional price a
## 11  cpexp30 Monthly household expenditures in constant prices after adjusting for regional p
## 12 fcpexp30 Monthly household food expenditures in constant prices after adjusting for regio
## 13     mult                                                                             &lt;NA&gt;
## 14    rmult                                                                             &lt;NA&gt;
## 15  welfare                                           Welfare based on usual members present
## 16 fwelfare                                                                             &lt;NA&gt;
## 17    hmult                                                                             &lt;NA&gt;
## 18   plinen                                                    Poverty line in nominal terms
## 19  ctpline                                                  Poverty line in constant prices
## 20   hpline                                     Food poverty line in 2009/10 constant prices
## 21   spline                                          Poverty line in 2009/10 constant prices
## 22 fpoor_16                                      food Poor in 2016 based on welfare variable
## 23   decile                                                                   Quantile group
## 24      pid                                                           Individual indentifier
## 25    hhage                                                           Age in completed years
## 26  hhedyrs                                                 Number of school years completed
## 27  hhelder                                                                             &lt;NA&gt;</code></pre>
<p>These are also all variables that we would aggregate using a measure of central tendency. The categorical variables are mostly identifiers and also some categorical versions of welfare variables (welfare quintiles), which can all sensibly be aggregated using the statistical mode:</p>
<pre class="r"><code>UNHSPOV %&gt;% cat_vars %&gt;% namlab
##        Variable                                                           Label
## 1      district                                                   District Code
## 2         urban                                          Urban/Rural Identifier
## 3        subreg                                          15      sub     region
## 4        region                                  Region of Residence in 2016/17
## 5        regurb                                              RegionxRural/Urban
## 6       poor_16                                                  Poverty status
## 7        quints                      Quintiles based on the national population
## 8        qurban                       Quintiles based on rural/urban population
## 9       qregion                          Quintiles based on regional population
## 10        hhrel  Relationship of household member  to the head of the household
## 11        mstat                            Marital  status of  household member
## 12        hhsex                     RECODE of R02 (Sex of the household member)
## 13      hhedlev                                                            &lt;NA&gt;
## 14 hhstatus_emp                          Activity status(employed, subsistence)
## 15     hhstatus Activity status(employed, subsistence, unemployed, not working)
## 16       hhindu                                                   RECODE of B4b
## 17     hhmrtsex                                             Marital by headship</code></pre>
<p>Below we aggregate this dataset, applying the weighted median to numeric data and the weighted mode (default) to categorical data, this time using <code>collapg</code> which is a wrapper around <code>collap</code> operating on grouped data frames / tibbles.</p>
<pre class="r"><code># Weighted aggregation by district, after removing household id and enumeration area
UNHSPOV %&gt;% 
  fselect(-hhid, -ea) %&gt;% 
  fgroup_by(district) %&gt;% 
  collapg(fmedian, w = finalwgt) %&gt;%
  fdroplevels %&gt;% 
  head
## # A tibble: 6 x 42
##   district finalwgt urban subreg region regurb equiv hsize fexp30 rexp30 rrfxp30 rrexp30 nrrexp30
##   &lt;fct&gt;       &lt;dbl&gt; &lt;fct&gt; &lt;fct&gt;  &lt;fct&gt;  &lt;fct&gt;  &lt;dbl&gt; &lt;dbl&gt;  &lt;dbl&gt;  &lt;dbl&gt;   &lt;dbl&gt;   &lt;dbl&gt;    &lt;dbl&gt;
## 1 &quot;KALANG~   12994. Rural Centr~ Centr~ Centr~  1.87     2 2.46e5 1.83e5 240877. 180432.  324962.
## 2 &quot;KAMPAL~  460128. Urban Kampa~ Centr~ Centr~  2.30     3 2.89e5 4.17e5 267612. 402942.  662020.
## 3 &quot;KIBOGA&quot;   20524. Rural Centr~ Centr~ Centr~  3.16     4 1.81e5 2.45e5 171290. 233323.  418979.
## 4 &quot;     L~  118868. Rural Centr~ Centr~ Centr~  2.77     4 2.11e5 2.26e5 199803. 220439.  386698.
## 5 &quot;MASAKA&quot;   92389. Urban Centr~ Centr~ Centr~  2.64     3 2.42e5 2.74e5 224339. 269409.  473376.
## 6 &quot;MPIGI&quot;    65521. Rural Centr~ Centr~ Centr~  2.81     4 2.29e5 2.49e5 222739. 240048.  428228.
## # ... with 29 more variables: cpexp30 &lt;dbl&gt;, fcpexp30 &lt;dbl&gt;, mult &lt;dbl&gt;, rmult &lt;dbl&gt;, welfare &lt;dbl&gt;,
## #   fwelfare &lt;dbl&gt;, hmult &lt;dbl&gt;, plinen &lt;dbl&gt;, ctpline &lt;dbl&gt;, hpline &lt;dbl&gt;, spline &lt;dbl&gt;,
## #   poor_16 &lt;fct&gt;, fpoor_16 &lt;dbl&gt;, quints &lt;fct&gt;, decile &lt;dbl&gt;, qurban &lt;fct&gt;, qregion &lt;fct&gt;,
## #   pid &lt;dbl&gt;, hhrel &lt;fct&gt;, hhage &lt;dbl&gt;, mstat &lt;fct&gt;, hhsex &lt;fct&gt;, hhedyrs &lt;dbl&gt;, hhedlev &lt;fct&gt;,
## #   hhstatus_emp &lt;fct&gt;, hhstatus &lt;fct&gt;, hhindu &lt;fct&gt;, hhelder &lt;dbl&gt;, hhmrtsex &lt;fct&gt;</code></pre>
<p>Note in the result above that the weighting variable is also aggregated. The default is <code>wFUN = fsum</code> so the weights in each group are summed.</p>
<p>At last let’s consider the census dataset. On first sight it is a bit simpler than the other two, consisting of 5 character identifiers from the macro-region to the parish level, followed by 270 numeric variables.</p>
<pre class="r"><code>fdim(CENS)
## [1] 7653  275

table(vclasses(CENS))
## 
## character   numeric 
##         5       270</code></pre>
<p>The specialty of this data is however that some variables are recorded in population totals, and some in percentage terms.</p>
<pre class="r"><code>descr(CENS, table = FALSE) %&gt;% as.data.frame %&gt;% head(15)
##         Variable     Class                                                     Label    N Ndist
## 1         Region character                                                      &lt;NA&gt; 7653     4
## 2       District character                                                      &lt;NA&gt; 7653   122
## 3         County character                                                      &lt;NA&gt; 7653   199
## 4      Subcounty character                                                      &lt;NA&gt; 7653  1382
## 5         Parish character                                                      &lt;NA&gt; 7653  6438
## 6          POP_M   numeric                                     Population Size: Male 7557  3548
## 7          POP_F   numeric                                   Population Size: Female 7557  3664
## 8         POP_SR   numeric                                Population Size: Sex Ratio 7557   609
## 9            POP   numeric                                    Population Size: Total 7557  4923
## 10       HHEAD_M   numeric        Headship of Households by Sex: Male Headed: Number 7557  1736
## 11     HHEAD_M_P   numeric       Headship of Households by Sex: Male Headed: Percent 7557   359
## 12       HHEAD_F   numeric      Headship of Households by Sex: Female Headed: Number 7557   846
## 13     HHEAD_F_P   numeric     Headship of Households by Sex: Female Headed: Percent 7557   359
## 14   HHEAD_10_17   numeric  Household Headship by specific age groups: 10-17: Number 7557    70
## 15 HHEAD_10_17_P   numeric Household Headship by specific age groups: 10-17: Percent 7556    40
##            Mean           SD  Min     Max       Skew       Kurt      1%      5%    25%    50%    75%
## 1            NA           NA   NA      NA         NA         NA      NA      NA     NA     NA     NA
## 2            NA           NA   NA      NA         NA         NA      NA      NA     NA     NA     NA
## 3            NA           NA   NA      NA         NA         NA      NA      NA     NA     NA     NA
## 4            NA           NA   NA      NA         NA         NA      NA      NA     NA     NA     NA
## 5            NA           NA   NA      NA         NA         NA      NA      NA     NA     NA     NA
## 6  2236.0525341 2060.3798193 39.0 45834.0  5.8878678  68.350438 335.000  549.00 1155.0 1782.0 2686.0
## 7  2347.0690750 2285.1063696 26.0 52061.0  6.3804915  77.223950 324.000  550.60 1193.0 1852.0 2831.0
## 8    97.1208813   10.7985572 35.0   365.2  5.2374120  86.423031  78.300   85.20   91.9   95.8  100.5
## 9  4583.1216091 4338.2687374 65.0 97895.0  6.1578818  73.263475 668.680 1101.60 2350.0 3634.0 5520.0
## 10  733.6140003  795.4130787  3.0 19855.0  7.5065928 101.724761 106.000  175.00  362.0  565.0  861.0
## 11   77.0265979    6.0370928 21.3    95.5 -0.5516445   5.158277  61.956   67.28   73.1   77.3   81.2
## 12  232.9163689  300.3926888  1.0  7018.0  7.3292989  91.895443  20.000   38.00  100.0  167.0  267.0
## 13   22.9735477    6.0371554  4.5    78.7  0.5516337   5.158115  10.600   13.70   18.8   22.7   26.9
## 14    4.7338891    7.3239515  0.0   148.0  5.0812704  49.771747   0.000    0.00    1.0    3.0    6.0
## 15    0.4547512    0.4600549  0.0     9.2  4.0165231  49.845440   0.000    0.00    0.2    0.4    0.6
##         95%       99%
## 1        NA        NA
## 2        NA        NA
## 3        NA        NA
## 4        NA        NA
## 5        NA        NA
## 6   5102.40 10264.160
## 7   5331.80 11562.360
## 8    112.40   133.732
## 9  10449.40 22273.800
## 10  1677.00  3929.520
## 11    86.30    89.400
## 12   568.00  1590.760
## 13    32.72    38.044
## 14    16.00    37.000
## 15     1.20     1.900</code></pre>
<p>The population counts are easily aggregated by simply computing a sum, but variables providing percentages of the population need to be aggregated using a weighted mean, where the total population serves as the weighting variable. This shows the percentage change variables:</p>
<pre class="r"><code># gvr is a shorthand for get_vars(..., regex = TRUE)
gvr(CENS, &quot;_P$&quot;) %&gt;% namlab %&gt;% head(10)
##         Variable                                                                    Label
## 1      HHEAD_M_P                      Headship of Households by Sex: Male Headed: Percent
## 2      HHEAD_F_P                    Headship of Households by Sex: Female Headed: Percent
## 3  HHEAD_10_17_P                Household Headship by specific age groups: 10-17: Percent
## 4  HHEAD_18_30_P                Household Headship by specific age groups: 18-30: Percent
## 5  HHEAD_M_A60_P                  Household Headship by specific age groups: 60+: Percent
## 6    HPOP_0_17_P                        Household Population by Broad Ages: 0-17: Percent
## 7   HPOP_18_30_P                       Household Population by Broad Ages: 18-30: Percent
## 8   HPOP_31_59_P                       Household Population by Broad Ages: 31-59: Percent
## 9     HPOP_A60_P                         Household Population by Broad Ages: 60+: Percent
## 10      POP_L1_P Population Distribution by Special Age groups: Less than 1 year: Percent

# Making sure all of these variables are indeed on a percentage scale
range(fmax(gvr(CENS, &quot;_P$&quot;)))
## [1]   8.9 100.0</code></pre>
<p>To aggregate this data with <code>collap</code>, we need to supply the names or indices of both percentage and non-percentage variables together with the corresponding aggregator functions in a list passed to the <code>custom</code> argument. Weights are passed to the <code>w</code> argument. A specialty here is that we are using <code>fsum_uw</code> instead of <code>fsum</code>. The postfix <code>_uw</code> prevents the weights from being passed to <code>fsum</code>, which would otherwise calculate a survey total (i.e. a weighted sum) instead of a simple summation.</p>
<pre class="r"><code>perc_vars &lt;- gvr(CENS, &quot;_P$&quot;, return = &quot;indices&quot;)
pop_vars &lt;- setdiff(num_vars(CENS, &quot;indices&quot;), perc_vars)

collap(CENS, ~ Region + District, w = ~ POP,
       custom = list(fmean = perc_vars, fsum_uw = pop_vars), 
       keep.w = FALSE) %&gt;% head
## # A tibble: 6 x 272
##   Region District  POP_M  POP_F POP_SR    POP HHEAD_M HHEAD_M_P HHEAD_F HHEAD_F_P HHEAD_10_17
##   &lt;chr&gt;  &lt;chr&gt;     &lt;dbl&gt;  &lt;dbl&gt;  &lt;dbl&gt;  &lt;dbl&gt;   &lt;dbl&gt;     &lt;dbl&gt;   &lt;dbl&gt;     &lt;dbl&gt;       &lt;dbl&gt;
## 1 Centr~ Buikwe   207324 215447  6807. 422771   71148      72.8   26685      27.2         691
## 2 Centr~ Bukoman~  75109  76304  2442. 151413   23426      68.3   10902      31.7         177
## 3 Centr~ Butamba~  50082  50758  2495. 100840   15128      69.8    6550      30.2         139
## 4 Centr~ Buvuma    48414  41476  4703.  89890   20289      81.3    4830      18.7         211
## 5 Centr~ Gomba     82167  77755  3923. 159922   25794      73.3    9446      26.7         207
## 6 Centr~ Kalanga~  31349  22944  2353   54293   15493      77.1    4548      22.9         123
## # ... with 261 more variables: HHEAD_10_17_P &lt;dbl&gt;, HHEAD_18_30 &lt;dbl&gt;, HHEAD_18_30_P &lt;dbl&gt;,
## #   HHEAD_M_A60 &lt;dbl&gt;, HHEAD_M_A60_P &lt;dbl&gt;, HHEAD &lt;dbl&gt;, HPOP_0_17 &lt;dbl&gt;, HPOP_0_17_P &lt;dbl&gt;,
## #   HPOP_18_30 &lt;dbl&gt;, HPOP_18_30_P &lt;dbl&gt;, HPOP_31_59 &lt;dbl&gt;, HPOP_31_59_P &lt;dbl&gt;, HPOP_A60 &lt;dbl&gt;,
## #   HPOP_A60_P &lt;dbl&gt;, HPOP &lt;dbl&gt;, POP_L1 &lt;dbl&gt;, POP_L1_P &lt;dbl&gt;, POP_0_4 &lt;dbl&gt;, POP_0_4_P &lt;dbl&gt;,
## #   POP_0_8 &lt;dbl&gt;, POP_0_8_P &lt;dbl&gt;, POP_2_8 &lt;dbl&gt;, POP_2_8_P &lt;dbl&gt;, POP_2_17 &lt;dbl&gt;,
## #   POP_2_17_P &lt;dbl&gt;, POP_6_12 &lt;dbl&gt;, POP_6_12_P &lt;dbl&gt;, POP_6_15 &lt;dbl&gt;, POP_6_15_P &lt;dbl&gt;,
## #   POP_10_15 &lt;dbl&gt;, POP_10_15_P &lt;dbl&gt;, POP_10_17 &lt;dbl&gt;, POP_10_17_P &lt;dbl&gt;, POP_15_24 &lt;dbl&gt;,
## #   POP_15_24_P &lt;dbl&gt;, POP_16_24 &lt;dbl&gt;, POP_16_24_P &lt;dbl&gt;, POP_15_29 &lt;dbl&gt;, POP_15_29_P &lt;dbl&gt;,
## #   POP_A2 &lt;dbl&gt;, POP_A2_P &lt;dbl&gt;, POP_A10 &lt;dbl&gt;, POP_A10_P &lt;dbl&gt;, POP_A15 &lt;dbl&gt;, POP_A15_P &lt;dbl&gt;,
## #   POP_A18 &lt;dbl&gt;, POP_A18_P &lt;dbl&gt;, POP_A20 &lt;dbl&gt;, POP_A20_P &lt;dbl&gt;, POP_A65 &lt;dbl&gt;, POP_A65_P &lt;dbl&gt;,
## #   EDU_6_12_NAS_M &lt;dbl&gt;, EDU_6_12_NAS_M_P &lt;dbl&gt;, EDU_6_12_NAS_F &lt;dbl&gt;, EDU_6_12_NAS_F_P &lt;dbl&gt;,
## #   EDU_6_12_NAS &lt;dbl&gt;, EDU_6_12_NAS_P &lt;dbl&gt;, EDU_6_12_PRI_M &lt;dbl&gt;, EDU_6_12_PRI_M_P &lt;dbl&gt;,
## #   EDU_6_12_PRI_F &lt;dbl&gt;, EDU_6_12_PRI_F_P &lt;dbl&gt;, EDU_6_12_PRI &lt;dbl&gt;, EDU_6_12_PRI_P &lt;dbl&gt;,
## #   EDU_13_18_SEC_M &lt;dbl&gt;, EDU_13_18_SEC_M_P &lt;dbl&gt;, EDU_13_18_SEC_F &lt;dbl&gt;, EDU_13_18_SEC_F_P &lt;dbl&gt;,
## #   EDU_13_18_SEC &lt;dbl&gt;, EDU_13_18_SEC_P &lt;dbl&gt;, EDU_A15_BS4_M &lt;dbl&gt;, EDU_A15_BS4_M_P &lt;dbl&gt;,
## #   EDU_A15_BS4_F &lt;dbl&gt;, EDU_A15_BS4_F_P &lt;dbl&gt;, EDU_A15_BS4 &lt;dbl&gt;, EDU_A15_BS4_P &lt;dbl&gt;,
## #   EDU_A18_HO_M &lt;dbl&gt;, EDU_A18_HO_M_P &lt;dbl&gt;, EDU_A18_HO_F &lt;dbl&gt;, EDU_A18_HO_F_P &lt;dbl&gt;,
## #   EDU_A18_HO &lt;dbl&gt;, EDU_A18_HO_P &lt;dbl&gt;, EDU_A20_HA_M &lt;dbl&gt;, EDU_A20_HA_M_P &lt;dbl&gt;,
## #   EDU_A20_HA_F &lt;dbl&gt;, EDU_A20_HA_F_P &lt;dbl&gt;, EDU_A20_HA &lt;dbl&gt;, EDU_A20_HA_P &lt;dbl&gt;, IL_A18_M &lt;dbl&gt;,
## #   IL_A18_M_P &lt;dbl&gt;, IL_A18_F &lt;dbl&gt;, IL_A18_F_P &lt;dbl&gt;, IL_A18 &lt;dbl&gt;, IL_A18_P &lt;dbl&gt;,
## #   IL_10_17 &lt;dbl&gt;, IL_10_17_P &lt;dbl&gt;, IL_18_30 &lt;dbl&gt;, IL_18_30_P &lt;dbl&gt;, IL_A60 &lt;dbl&gt;,
## #   IL_A60_P &lt;dbl&gt;, BR_L1 &lt;dbl&gt;, ...</code></pre>
<p>Also with the custom argument, the columns are by default (<code>keep.col.order = TRUE</code>) rearranged into the order in which they occur. Here we additionally use <code>keep.w = FALSE</code>, because the variable <code>POP</code> is both used as the weighting variable but also contained in <code>pop_vars</code>, and we don’t want to have it twice in the output.</p>
<p>Since we are only aggregating numeric data, we may compare the computation speed with a matching <em>data.table</em> expression<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a>:</p>
<pre class="r"><code>library(microbenchmark)
library(data.table)
setDT(CENS)

microbenchmark(
  data.table = cbind(CENS[, lapply(.SD, weighted.mean, POP), by = .(Region, District), .SDcols = perc_vars], 
                     CENS[, lapply(.SD, sum), by = .(Region, District), .SDcols = pop_vars][, -(1:2)]), 
  collapse = collap(CENS, ~ Region + District, w = ~ POP,
                     custom = list(fmean = perc_vars, fsum_uw = pop_vars), 
                     keep.w = FALSE)
)
## Unit: milliseconds
##        expr        min         lq       mean     median        uq       max neval cld
##  data.table 153.317076 169.257733 181.740319 175.767603 191.12234 346.31187   100   b
##    collapse   8.997704   9.260768   9.990888   9.837097  10.24251  14.12287   100  a</code></pre>
<div class="footnotes">
<hr />
<ol>
<li id="fn1"><p>Which does however not maintain the original column order.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Forecasting Tax Revenue with Error Correction Models</title>
      <link>https://sebkrantz.github.io/Rblog/2020/12/02/ecm-for-tax-revenue-forecasting/</link>
      <pubDate>Wed, 02 Dec 2020 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2020/12/02/ecm-for-tax-revenue-forecasting/</guid>
      <description><![CDATA[ 
<link href="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.css" rel="stylesheet" />
<script src="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.js"></script>


<p>There are several ways to forecast tax revenue. The IMF <a href="https://courses.edx.org/asset-v1:IMFx+FPP.1x+1T2017+type@asset+block@FPP1x_Manual.pdf"><em>Financial Programming Manual</em></a> reviews 3 of them: (i) the effective tax rate approach; (ii) the elasticity approach; and (iii) the regression approach. Approach (iii) typically results in the most accurate short-term forecasts. The simple regression approach regresses tax revenue on its own lags and GDP with some lags.</p>
<p>In the absence of large abrupt shifts in the tax base, domestic revenue can be assumed to have a linear relationship with GDP. Since however both revenue and GDP are typically non-stationary series, this relationship often takes the form of cointegration. The correct way to deal with cointegrated variables is to specify and Error Correction Model (ECM). This blog post will briefly demonstrate the specification of an ECM to forecast the tax revenue of a developing economy<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a>. First we examine the data, which is in local currency and was transformed using the natural logarithm.</p>
<pre class="r"><code>library(haven)      # Import from STATA
library(collapse)   # Data transformation and time series operators
library(magrittr)   # Pipe operators %&gt;% 
library(tseries)    # Time series tests
library(lmtest)     # Linear model tests
library(sandwich)   # Robust standard errors
library(jtools)     # Enhanced regression summary
library(xts)        # Extensible time-series + pretty plots

# Loading the data from STATA
data &lt;- read_dta(&quot;data.dta&quot;) %&gt;% as_factor

# Generating a date variable
settfm(data, Date = as.Date(paste(Year, unattrib(Quarter) * 3L, &quot;1&quot;, sep = &quot;/&quot;)))

# Creating time series matrix X
X &lt;- data %$% xts(cbind(lrev, lgdp), order.by = Date, frequency = 4L)

# (Optional) seasonal adjustment using X-13 ARIMA SEATS
  # library(seasonal)
  # X &lt;- dapply(X, function(x) predict(seas(ts(x, start = c(1997L, 3L), frequency = 4L))))
  # # X &lt;- X[&quot;2015/&quot;, ] # Optionally restricting the sample to after 2014

# Plotting the raw data
plot(na_omit(X)[-1L, ] %&gt;% setColnames(.c(Revenue, GDP)), 
     multi.panel = TRUE, yaxis.same = FALSE, 
     main = &quot;Domestic Revenue and GDP (in Logs)&quot;, 
     major.ticks = &quot;years&quot;, grid.ticks.on = &quot;years&quot;)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/12/02/ecm-for-tax-revenue-forecasting/figure-html/unnamed-chunk-1-1.png" width="100%" /></p>
<pre class="r"><code># Plotting the log-differenced data
plot(na_omit(D(X)), legend.loc = &quot;topleft&quot;, 
     main = &quot;Revenue and GDP in Quarterly Log-Differences&quot;,
     major.ticks = &quot;years&quot;, grid.ticks.on = &quot;years&quot;)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/12/02/ecm-for-tax-revenue-forecasting/figure-html/unnamed-chunk-1-2.png" width="100%" />
The data was not seasonally adjusted as revenue and GDP exhibit similar seasonal patterns. Summarizing the log-differenced using a function designed for panel data allows us to assess the extent of seasonality relative to overall variation.</p>
<pre class="r"><code># Summarize between and within quarters
tfmv(data, 3:4, D) %&gt;% qsu(pid = lrev + lgdp ~ Quarter)
## , , lrev
## 
##            N/T    Mean      SD      Min     Max
## Overall     91  0.0316  0.1545  -0.5456  0.6351
## Between      4  0.0302  0.1275  -0.0997  0.1428
## Within   22.75  0.0316  0.1077  -0.4144  0.5239
## 
## , , lgdp
## 
##            N/T    Mean      SD      Min     Max
## Overall     45  0.0271   0.183  -0.3702  0.5888
## Between      4  0.0291  0.0767  -0.0593  0.1208
## Within   11.25  0.0271    0.17  -0.3771  0.4951</code></pre>
<p>For log revenue, the standard deviation between quarters is actually slightly higher than the within-quarter standard deviation, indicating a strong seasonal component. The summary also shows that we have 23 years of quarterly revenue data but only 11 years of quarterly GDP data.</p>
<p>An ECM is only well specified if both series are integrated of the same order and cointegrated. This requires a battery of tests to examine the properties of the data before specifying a model<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a>. For simplicity I will follow the 2-Step approach of Engele &amp; Granger here, although I note that the more sophisticated Johannsen procedure is available in the <em>urca</em> package.</p>
<pre class="r"><code># Testing log-transformed series for stationarity: Revenue is clearly non-stationary
adf.test(X[, &quot;lrev&quot;])
## 
##  Augmented Dickey-Fuller Test
## 
## data:  X[, &quot;lrev&quot;]
## Dickey-Fuller = -0.90116, Lag order = 4, p-value = 0.949
## alternative hypothesis: stationary

kpss.test(X[, &quot;lrev&quot;], null = &quot;Trend&quot;)
## 
##  KPSS Test for Trend Stationarity
## 
## data:  X[, &quot;lrev&quot;]
## KPSS Trend = 0.24371, Truncation lag parameter = 3, p-value = 0.01

# ADF test fails to reject the null of non-stationarity at 5% level
adf.test(na_omit(X[, &quot;lgdp&quot;]))
## 
##  Augmented Dickey-Fuller Test
## 
## data:  na_omit(X[, &quot;lgdp&quot;])
## Dickey-Fuller = -3.4532, Lag order = 3, p-value = 0.06018
## alternative hypothesis: stationary

kpss.test(na_omit(X[, &quot;lgdp&quot;]), null = &quot;Trend&quot;)
## 
##  KPSS Test for Trend Stationarity
## 
## data:  na_omit(X[, &quot;lgdp&quot;])
## KPSS Trend = 0.065567, Truncation lag parameter = 3, p-value = 0.1

# Cointegrated: We reject the null of no cointegration
po.test(X[, .c(lrev, lgdp)])
## 
##  Phillips-Ouliaris Cointegration Test
## 
## data:  X[, .c(lrev, lgdp)]
## Phillips-Ouliaris demeaned = -33.219, Truncation lag parameter = 0, p-value = 0.01</code></pre>
<p>The differenced revenue and GDP series are stationary (tests not shown), so both series are I(1), and GDP is possibly trend-stationary. The Phillips-Ouliaris test rejected the null that both series are not cointegrated.</p>
<p>Below the cointegration relationship is estimated. A dummy is included for extreme GDP fluctuations between Q3 2013 and Q3 2014, which may also be related to a GDP rebasing. Since the nature of these events is an increase in volatility rather than the level of GDP, the dummy is not a very effective way of dealing with this irregularity in the data, but for simplicity we will go with it.</p>
<pre class="r"><code># Adding extreme GDP events dummy
X &lt;- cbind(X, GDPdum = 0)
X[&quot;2013-09/2014-09&quot;, &quot;GDPdum&quot;] &lt;- 1

# This estimates the cointegration equation
cieq &lt;- lm(lrev ~ lgdp + GDPdum, X)

# Summarizing the model with heteroskedasticity and autocorrelation consistent (HAC) errors
summ(cieq, digits = 4L, vcov = vcovHAC(cieq))</code></pre>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;">
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
Observations
</td>
<td style="text-align:right;">
46 (46 missing obs. deleted)
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Dependent variable
</td>
<td style="text-align:right;">
lrev
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Type
</td>
<td style="text-align:right;">
OLS linear regression
</td>
</tr>
</tbody>
</table>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;">
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
F(2,43)
</td>
<td style="text-align:right;">
64.4122
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
R²
</td>
<td style="text-align:right;">
0.7497
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Adj. R²
</td>
<td style="text-align:right;">
0.7381
</td>
</tr>
</tbody>
</table>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;border-bottom: 0;">
<thead>
<tr>
<th style="text-align:left;">
</th>
<th style="text-align:right;">
Est.
</th>
<th style="text-align:right;">
S.E.
</th>
<th style="text-align:right;">
t val.
</th>
<th style="text-align:right;">
p
</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
(Intercept)
</td>
<td style="text-align:right;">
-4.7667
</td>
<td style="text-align:right;">
1.2958
</td>
<td style="text-align:right;">
-3.6787
</td>
<td style="text-align:right;">
0.0006
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
lgdp
</td>
<td style="text-align:right;">
1.1408
</td>
<td style="text-align:right;">
0.1293
</td>
<td style="text-align:right;">
8.8208
</td>
<td style="text-align:right;">
0.0000
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
GDPdum
</td>
<td style="text-align:right;">
0.0033
</td>
<td style="text-align:right;">
0.2080
</td>
<td style="text-align:right;">
0.0160
</td>
<td style="text-align:right;">
0.9873
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td style="padding: 0; " colspan="100%">
<sup></sup> Standard errors: User-specified
</td>
</tr>
</tfoot>
</table>
<pre class="r"><code># Residuals of cointegration equation
res &lt;- resid(cieq) %&gt;% as.xts(dateFormat = &quot;Date&quot;)
plot(res[-1L, ], main = &quot;Residuals from Cointegration Equation&quot;, 
     major.ticks = &quot;years&quot;, grid.ticks.on = &quot;years&quot;)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/12/02/ecm-for-tax-revenue-forecasting/figure-html/unnamed-chunk-4-1.png" width="100%" /></p>
<pre class="r"><code># Testing residuals: Stationary
adf.test(res)
## 
##  Augmented Dickey-Fuller Test
## 
## data:  res
## Dickey-Fuller = -4.3828, Lag order = 3, p-value = 0.01
## alternative hypothesis: stationary

kpss.test(res, null = &quot;Trend&quot;)
## 
##  KPSS Test for Trend Stationarity
## 
## data:  res
## KPSS Trend = 0.045691, Truncation lag parameter = 3, p-value = 0.1</code></pre>
<p>Apart from a cointegration relationship which governs the medium-term relationship of revenue and GDP, revenue may also be affected by past revenue collection and short-term fluctuations in GDP. A sensible and simple specification to forecast revenue in the short to medium term (assuming away shifts in the tax base) is thus provided by the general form of a bivariate ECM:</p>
<p><span class="math display">\[\begin{equation}
A(L)\Delta r_t = \gamma + B(L)\Delta y_t + \alpha (r_{t-t} - \beta_0 - \beta_i y_{t-1}) + v_t,
\end{equation}\]</span>
where
<span class="math display">\[\begin{align*}
A(L) &amp;= 1- \sum_{i=1}^p L^i = 1 - L - L^2 -  \dots - L^p, \\
B(L) &amp;= \sum_{i=0}^q L^i= 1 + L + L^2 + \dots + L^q
\end{align*}\]</span>
are polynomials in the lag operator <span class="math inline">\(L\)</span> of order <span class="math inline">\(p\)</span> and <span class="math inline">\(q\)</span>, respectively. Some empirical investigation of the fit of the model for different lag-orders <span class="math inline">\(p\)</span> and <span class="math inline">\(q\)</span> established that <span class="math inline">\(p = 2\)</span> and <span class="math inline">\(q = 1\)</span> gives a good fit, so that the model estimated is</p>
<p><span class="math display">\[\begin{equation} 
\Delta r_t = \gamma  + \Delta r_{t-1} + \Delta r_{t-2}  + \Delta y_t + \Delta y_{t-1} + \alpha (r_{t-t} - \beta_0 - \beta_i y_{t-1}) + v_t.
\end{equation}\]</span></p>
<pre class="r"><code># Estimating Error Correction Model (ECM)
ecm &lt;- lm(D(lrev) ~ L(D(lrev), 1:2) + L(D(lgdp), 0:1) + L(res) + GDPdum, merge(X, res))

summ(ecm, digits = 4L, vcov = vcovHAC(ecm))</code></pre>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;">
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
Observations
</td>
<td style="text-align:right;">
44 (48 missing obs. deleted)
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Dependent variable
</td>
<td style="text-align:right;">
D(lrev)
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Type
</td>
<td style="text-align:right;">
OLS linear regression
</td>
</tr>
</tbody>
</table>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;">
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
F(6,37)
</td>
<td style="text-align:right;">
12.9328
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
R²
</td>
<td style="text-align:right;">
0.6771
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Adj. R²
</td>
<td style="text-align:right;">
0.6248
</td>
</tr>
</tbody>
</table>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;border-bottom: 0;">
<thead>
<tr>
<th style="text-align:left;">
</th>
<th style="text-align:right;">
Est.
</th>
<th style="text-align:right;">
S.E.
</th>
<th style="text-align:right;">
t val.
</th>
<th style="text-align:right;">
p
</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
(Intercept)
</td>
<td style="text-align:right;">
0.0817
</td>
<td style="text-align:right;">
0.0197
</td>
<td style="text-align:right;">
4.1440
</td>
<td style="text-align:right;">
0.0002
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(D(lrev), 1:2)L1
</td>
<td style="text-align:right;">
-0.9195
</td>
<td style="text-align:right;">
0.1198
</td>
<td style="text-align:right;">
-7.6747
</td>
<td style="text-align:right;">
0.0000
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(D(lrev), 1:2)L2
</td>
<td style="text-align:right;">
-0.3978
</td>
<td style="text-align:right;">
0.1356
</td>
<td style="text-align:right;">
-2.9342
</td>
<td style="text-align:right;">
0.0057
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(D(lgdp), 0:1)–
</td>
<td style="text-align:right;">
0.1716
</td>
<td style="text-align:right;">
0.0942
</td>
<td style="text-align:right;">
1.8211
</td>
<td style="text-align:right;">
0.0767
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(D(lgdp), 0:1)L1
</td>
<td style="text-align:right;">
-0.2654
</td>
<td style="text-align:right;">
0.1128
</td>
<td style="text-align:right;">
-2.3532
</td>
<td style="text-align:right;">
0.0240
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(res)
</td>
<td style="text-align:right;">
-0.2412
</td>
<td style="text-align:right;">
0.1096
</td>
<td style="text-align:right;">
-2.2008
</td>
<td style="text-align:right;">
0.0341
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
GDPdum
</td>
<td style="text-align:right;">
0.0212
</td>
<td style="text-align:right;">
0.0207
</td>
<td style="text-align:right;">
1.0213
</td>
<td style="text-align:right;">
0.3138
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td style="padding: 0; " colspan="100%">
<sup></sup> Standard errors: User-specified
</td>
</tr>
</tfoot>
</table>
<pre class="r"><code># Regression diagnostic plots
# plot(ecm)

# No heteroskedasticity (null of homoskedasticity not rejected)
bptest(ecm)
## 
##  studentized Breusch-Pagan test
## 
## data:  ecm
## BP = 9.0161, df = 6, p-value = 0.1727

# Some autocorrelation remainig in the residuals, but negative 
cor.test(resid(ecm), L(resid(ecm)))
## 
##  Pearson&#39;s product-moment correlation
## 
## data:  resid(ecm) and L(resid(ecm))
## t = -1.8774, df = 41, p-value = 0.06759
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.5363751  0.0207394
## sample estimates:
##       cor 
## -0.281357

dwtest(ecm)
## 
##  Durbin-Watson test
## 
## data:  ecm
## DW = 2.552, p-value = 0.9573
## alternative hypothesis: true autocorrelation is greater than 0

dwtest(ecm, alternative = &quot;two.sided&quot;)
## 
##  Durbin-Watson test
## 
## data:  ecm
## DW = 2.552, p-value = 0.08548
## alternative hypothesis: true autocorrelation is not 0</code></pre>
<p>The regression table shows that the log-difference in revenue strongly responds to its own lags, the lagged log-difference of GDP and the deviation from the previous period equilibrium, with an adjustment speed of <span class="math inline">\(\alpha = -0.24\)</span>.</p>
<p>The statistical properties of the equation are also acceptable. Errors are homoskedastic and serially uncorrelated at the 5% level. The model is nevertheless reported with heteroskedasticity and autocorrelation consistent (HAC) standard errors.</p>
<p>Curiously, changes in revenue in the current quarter do not seem to be very strongly related to changes in GDP in the current quarter, which could also be accounted for by data being published with a lag. For forecasting this is advantageous since if a specification without the difference of GDP can be estimated that fits the data well, then it may not be necessary to first forecast quarterly GDP and include it in the model in order to get a decent forecasts of the revenue number for the next quarter. Below a specification without the difference in GDP is estimated.</p>
<pre class="r"><code># Same using only lagged differences in GDP
ecm2 &lt;- lm(D(lrev) ~ L(D(lrev), 1:2) + L(D(lgdp)) + L(res) + GDPdum, merge(X, res))

summ(ecm2, digits = 4L, vcov = vcovHAC(ecm2))</code></pre>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;">
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
Observations
</td>
<td style="text-align:right;">
45 (47 missing obs. deleted)
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Dependent variable
</td>
<td style="text-align:right;">
D(lrev)
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Type
</td>
<td style="text-align:right;">
OLS linear regression
</td>
</tr>
</tbody>
</table>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;">
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
F(5,39)
</td>
<td style="text-align:right;">
15.1630
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
R²
</td>
<td style="text-align:right;">
0.6603
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
Adj. R²
</td>
<td style="text-align:right;">
0.6168
</td>
</tr>
</tbody>
</table>
<table class="table table-striped table-hover table-condensed table-responsive" style="width: auto !important; margin-left: auto; margin-right: auto;border-bottom: 0;">
<thead>
<tr>
<th style="text-align:left;">
</th>
<th style="text-align:right;">
Est.
</th>
<th style="text-align:right;">
S.E.
</th>
<th style="text-align:right;">
t val.
</th>
<th style="text-align:right;">
p
</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left;font-weight: bold;">
(Intercept)
</td>
<td style="text-align:right;">
0.0839
</td>
<td style="text-align:right;">
0.0206
</td>
<td style="text-align:right;">
4.0653
</td>
<td style="text-align:right;">
0.0002
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(D(lrev), 1:2)L1
</td>
<td style="text-align:right;">
-0.9111
</td>
<td style="text-align:right;">
0.1162
</td>
<td style="text-align:right;">
-7.8424
</td>
<td style="text-align:right;">
0.0000
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(D(lrev), 1:2)L2
</td>
<td style="text-align:right;">
-0.3910
</td>
<td style="text-align:right;">
0.1305
</td>
<td style="text-align:right;">
-2.9950
</td>
<td style="text-align:right;">
0.0047
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(D(lgdp))
</td>
<td style="text-align:right;">
-0.2345
</td>
<td style="text-align:right;">
0.0995
</td>
<td style="text-align:right;">
-2.3574
</td>
<td style="text-align:right;">
0.0235
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
L(res)
</td>
<td style="text-align:right;">
-0.1740
</td>
<td style="text-align:right;">
0.0939
</td>
<td style="text-align:right;">
-1.8524
</td>
<td style="text-align:right;">
0.0716
</td>
</tr>
<tr>
<td style="text-align:left;font-weight: bold;">
GDPdum
</td>
<td style="text-align:right;">
0.0244
</td>
<td style="text-align:right;">
0.0328
</td>
<td style="text-align:right;">
0.7428
</td>
<td style="text-align:right;">
0.4621
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td style="padding: 0; " colspan="100%">
<sup></sup> Standard errors: User-specified
</td>
</tr>
</tfoot>
</table>
<pre class="r"><code># plot(ecm2)

bptest(ecm2)
## 
##  studentized Breusch-Pagan test
## 
## data:  ecm2
## BP = 7.0511, df = 5, p-value = 0.2169

cor.test(resid(ecm2), L(resid(ecm2)))
## 
##  Pearson&#39;s product-moment correlation
## 
## data:  resid(ecm2) and L(resid(ecm2))
## t = -1.701, df = 42, p-value = 0.09634
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.51214976  0.04651674
## sample estimates:
##        cor 
## -0.2538695

dwtest(ecm2)
## 
##  Durbin-Watson test
## 
## data:  ecm2
## DW = 2.4973, p-value = 0.942
## alternative hypothesis: true autocorrelation is greater than 0

dwtest(ecm2, alternative = &quot;two.sided&quot;)
## 
##  Durbin-Watson test
## 
## data:  ecm2
## DW = 2.4973, p-value = 0.1161
## alternative hypothesis: true autocorrelation is not 0</code></pre>
<p>We can also compare the fitted values of the two models:</p>
<pre class="r"><code># Get ECM fitted values
ECM1_fit &lt;- fitted(ecm) %&gt;% as.xts(dateFormat = &quot;Date&quot;)
ECM2_fit &lt;- fitted(ecm2) %&gt;% as.xts(dateFormat = &quot;Date&quot;)

# Plot together with revenue
plot(D(X[, &quot;lrev&quot;]) %&gt;% merge(ECM1_fit, ECM2_fit) %&gt;% na_omit, 
     main = &quot;Dlog Revenue and ECM Fit&quot;, 
     legend.loc = &quot;topleft&quot;, major.ticks = &quot;years&quot;, grid.ticks.on = &quot;years&quot;)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/12/02/ecm-for-tax-revenue-forecasting/figure-html/unnamed-chunk-7-1.png" width="100%" /></p>
<p>Both the fit statistics and fitted values suggest that ECM2 is a feasible forecasting specification that avoids the need to first forecast quarterly GDP.</p>
<p>The true forecasting performance of the model can only be estimated through out of sample forecasts. Below I compute 1 quarter ahead forecasts for quarters 2018Q1 through 2019Q4 using an expanding window where both the cointegration equation and the ECM are re-estimated for each new period.</p>
<pre class="r"><code># Function to forecast with expanding window from start year (using ECM2 specification)
forecast_oos &lt;- function(x, start = 2018) {
  n &lt;- nrow(x[paste0(&quot;/&quot;, start - 1), ])
  fc &lt;- numeric(0L)
  xdf &lt;- qDF(x)
  # Forecasting with expanding window
  for(i in n:(nrow(x)-1L)) {
    samp &lt;- ss(xdf, 1:i)
    ci &lt;- lm(lrev ~ lgdp + GDPdum, samp)
    samp &lt;- tfm(samp, res = resid(ci))
    mod &lt;- lm(D(lrev) ~ L(D(lrev)) + L(D(lrev), 2L) + L(D(lgdp)) + L(res) + GDPdum, samp)
    fc &lt;- c(fc, flast(predict(mod, newdata = rbind(samp, 0)))) # predict does not re-estimate
  }
  xfc &lt;- cbind(D(x[, &quot;lrev&quot;]), ECM2_fc = NA)
  xfc[(n+1L):nrow(x), &quot;ECM2_fc&quot;] &lt;- unattrib(fc)
  return(xfc)
}

# Forecasting
ECM_oos_fc &lt;- forecast_oos(na_omit(X))

# Plotting
plot(ECM_oos_fc[&quot;2009/&quot;, ], 
     main = &quot;Out of Sample Expanding Window Forecast from ECM&quot;, 
     legend.loc = &quot;topleft&quot;, major.ticks = &quot;years&quot;, grid.ticks.on = &quot;years&quot;)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/12/02/ecm-for-tax-revenue-forecasting/figure-html/unnamed-chunk-8-1.png" width="100%" /></p>
<!-- Old forecasting: Problematic !!
# library(dynlm)
# forecast_oos <- function(x, start = 2018) {
#   n <- nrow(x[paste0("/", start - 1), ]) 
#   xzoo <- as.zoo(x)
#   fc <- numeric(0L)
#   # Forecasting with expanding window
#   for(i in n:(nrow(x)-1L)) {
#     samp <- xzoo[1:i, ]
#     ci <- dynlm(lrev ~ lgdp + GDPdum, samp)
#     samp <- cbind(samp, res = resid(ci))
#     mod <- dynlm(D(lrev) ~ L(D(lrev)) + L(D(lrev), 2L) + L(D(lgdp)) + L(res) + GDPdum, samp)
#     fc <- c(fc, flast(predict(mod, newdata = samp))) # predict does not re-estimate
#   }
#   xfc <- cbind(D(x[, "lrev"]), ECM2_fc = NA)
#   xfc[(n+1L):nrow(x), "ECM2_fc"] <- unattrib(fc)
#   return(xfc)
# }
-->
<p>The graph suggests that the forecasting performance is quite acceptable. When seasonally adjusting GDP and revenue beforehand, the forecast becomes less accurate, so a part of this fit is accounted for by seasonal patterns in the two series. Finally, we could formally evaluate the forecast computing a sophisticated set of forecast evaluation metrics and also comparing the forecast to a naive forecast provided by the value of revenue in the previous quarter.</p>
<pre class="r"><code>eval_forecasts &lt;- function(y, fc, add.naive = TRUE, n.ahead = 1) {
  mfc &lt;- eval(substitute(qDF(fc))) # eval substitute to get the name of the forecast if only a vector is passed
  lagy &lt;- flag(y, n.ahead)
  if (add.naive) mfc &lt;- c(list(Naive = lagy), mfc)
  if (!all(length(y) == lengths(mfc))) stop(&quot;All supplied quantities must be of equal length&quot;)
  res &lt;- vapply(mfc, function(fcy) {
    # Preparation
    cc &lt;- complete.cases(y, fcy)
    y &lt;- y[cc]
    fcy &lt;- fcy[cc]
    lagycc &lt;- lagy[cc]
    n &lt;- sum(cc)
    nobessel &lt;- sqrt((n - 1) / n) # Undo bessel correction (n-1) instead of n in denominator
    sdy &lt;- sd(y) * nobessel
    sdfcy &lt;- sd(fcy) * nobessel
    diff &lt;- fcy - y
    # Calculate Measures
    bias &lt;- sum(diff) / n         # Bias
    MSE &lt;- sum(diff^2) / n        # Mean Squared Error
    BP &lt;- bias^2 / MSE            # Bias Proportion
    VP &lt;- (sdy - sdfcy)^2 / MSE   # Variance Proportion
    CP &lt;- 2 * (1 - cor(y, fcy)) * sdy * sdfcy / MSE # Covariance Proportion
    RMSE &lt;- sqrt(MSE)             # Root MSE
    R2 &lt;- 1 - MSE / sdy^2         # R-Squared
    SE &lt;- sd(diff) * nobessel     # Standard Forecast Error
    MAE &lt;- sum(abs(diff)) / n     # Mean Absolute Error
    MPE &lt;- sum(diff / y) / n * 100 # Mean Percentage Error
    MAPE &lt;- sum(abs(diff / y)) / n * 100 # Mean Absolute Percentage Error
    U1 &lt;- RMSE / (sqrt(sum(y^2) / n) + sqrt(sum(fcy^2) / n))   # Theils U1
    U2 &lt;- sqrt(mean.default((diff / lagycc)^2, na.rm = TRUE) / # Theils U2 (= MSE(fc)/MSE(Naive))
               mean.default((y / lagycc - 1)^2, na.rm = TRUE))
    # Output
    return(c(Bias = bias, MSE = MSE, RMSE = RMSE, `R-Squared` = R2, SE = SE,
      MAE = MAE, MPE = MPE, MAPE = MAPE, U1 = U1, U2 = U2,
      `Bias Prop.` = BP, `Var. Prop.` = VP, `Cov. Prop.` = CP))
  }, numeric(13))
  attr(res, &quot;naive.added&quot;) &lt;- add.naive
  attr(res, &quot;n.ahead&quot;) &lt;- n.ahead
  attr(res, &quot;call&quot;) &lt;- match.call()
  class(res) &lt;- &quot;eval_forecasts&quot;
  return(res)
}

# Print method
print.eval_forecasts &lt;- function(x, digits = 3, ...) print.table(round(x, digits))

ECM_oos_fc_cc &lt;- na_omit(ECM_oos_fc)
eval_forecasts(ECM_oos_fc_cc[, &quot;D1.lrev&quot;], ECM_oos_fc_cc[, &quot;ECM2_fc&quot;])
##               Naive  ECM2_fc
## Bias         -0.041   -0.001
## MSE           0.072    0.007
## RMSE          0.268    0.082
## R-Squared    -2.414    0.653
## SE            0.265    0.082
## MAE           0.260    0.066
## MPE        -194.319  -48.213
## MAPE        194.319   48.213
## U1            0.974    0.364
## U2            1.000    0.423
## Bias Prop.    0.024    0.000
## Var. Prop.    0.006    0.564
## Cov. Prop.    0.970    0.436</code></pre>
<p>The metrics show that the ECM forecast is clearly better than a naive forecast using the previous quarters value. The bias proportion of the forecast error is 0, but the variance proportion 0.56, suggesting, together with the plot, that the variance of the forecasts is too small compared to the variance of the data.</p>
<div id="further-references-on-vecms" class="section level3">
<h3>Further References on (V)ECM’s</h3>
<p>Engle, Robert, and Clive Granger. 1987. <em>Co-integration and Error Correction: Representation, Estimation and Testing.</em> Econometrica 55 (2): 251–76.</p>
<p>Johansen, Søren (1991). <em>Estimation and Hypothesis Testing of Cointegration Vectors in Gaussian Vector Autoregressive Models</em>. Econometrica. 59 (6): 1551–1580. JSTOR 2938278.</p>
<p>Enders, Walter (2010). <em>Applied Econometric Time Series (Third ed.).</em> New York: John Wiley &amp; Sons. pp. 272–355. ISBN 978-0-470-50539-7.</p>
<p>Lütkepohl, Helmut (2006). <em>New Introduction to Multiple Time Series Analysis.</em> Berlin: Springer. pp. 237–352. ISBN 978-3-540-26239-8.</p>
<p>Alogoskoufis, G., &amp; Smith, R. (1991). <em>On error correction models: specification, interpretation, estimation.</em> Journal of Economic Surveys, 5(1), 97-128.</p>
<p><a href="https://en.wikipedia.org/wiki/Error_correction_model" class="uri">https://en.wikipedia.org/wiki/Error_correction_model</a></p>
<p><a href="https://www.econometrics-with-r.org/16-3-cointegration.html" class="uri">https://www.econometrics-with-r.org/16-3-cointegration.html</a></p>
<p><a href="https://bookdown.org/ccolonescu/RPoE4/time-series-nonstationarity.html#the-error-correction-model" class="uri">https://bookdown.org/ccolonescu/RPoE4/time-series-nonstationarity.html#the-error-correction-model</a></p>
<p><a href="https://www.youtube.com/watch?v=wYQ_v_0tk_c" class="uri">https://www.youtube.com/watch?v=wYQ_v_0tk_c</a></p>
</div>
<div class="footnotes">
<hr />
<ol>
<li id="fn1"><p>The data is unpublished so I will not make public which country it is<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>The Augmented Dickey Fuller test tests the null of non-stationarity against the alternative of trend stationarity. The Kwiatkowski–Phillips–Schmidt–Shin (KPSS) test tests the null of trend stationarity. The Phillips-Ouliaris test tests the null hypothesis that the variables are not cointegrated.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Fast Class-Agnostic Data Manipulation in R</title>
      <link>https://sebkrantz.github.io/Rblog/2020/11/14/class-agnostic-data-manipulation/</link>
      <pubDate>Sat, 14 Nov 2020 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2020/11/14/class-agnostic-data-manipulation/</guid>
      <description><![CDATA[ 
<link href="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.css" rel="stylesheet" />
<script src="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.js"></script>


<p>In <a href="https://sebkrantz.github.io/Rblog/"><em>previous posts</em></a> I introduced <a href="https://sebkrantz.github.io/collapse/"><em>collapse</em></a>, a powerful (C/C++ based) new framework for data transformation and statistical computing in R - providing advanced grouped, weighted, time series, panel data and recursive computations in R at superior execution speeds, greater flexibility and programmability.</p>
<!--  up to 20x faster than the popular *data.table* package -->
<p><em>collapse</em> 1.4 released this week additionally introduces an enhanced attribute handling system which enables non-destructive manipulation of vector, matrix or data frame based objects in R. With this post I aim to briefly introduce this attribute handling system and demonstrate that:</p>
<ol style="list-style-type: decimal">
<li><p><em>collapse</em> non-destructively handles <strong>all</strong> major matrix (time series) and data frame based classes in R.</p></li>
<li><p>Using <em>collapse</em> functions on these objects yields uniform handling at higher computation speeds.</p></li>
</ol>
<div id="data-frame-based-objects" class="section level1">
<h1>Data Frame Based Objects</h1>
<p>The three major data frame based classes in R are the base R <em>data.frame</em>, the <em>data.table</em> and the <em>tibble</em>, for which there also exists grouped (<em>dplyr</em>) and time based (<em>tsibble</em>, <em>tibbletime</em>) versions. Additional notable classes are the panel data frame (<em>plm</em>) and the spatial features data frame (<em>sf</em>).</p>
<p>For the former three <em>collapse</em> offer extremely fast and versatile converters <code>qDF</code>, <code>qDT</code> and <code>qTBL</code> that can be used to turn many R objects into <em>data.frame</em>’s, <em>data.table</em>’s or <em>tibble</em>’s, respectively:</p>
<pre class="r"><code>library(collapse); library(data.table); library(tibble)
options(datatable.print.nrows = 10, 
        datatable.print.topn = 2)

identical(qDF(mtcars), mtcars)
## [1] TRUE

mtcarsDT &lt;- qDT(mtcars, row.names.col = &quot;car&quot;)
mtcarsDT
##               car  mpg cyl disp  hp drat    wt  qsec vs am gear carb
##  1:     Mazda RX4 21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
##  2: Mazda RX4 Wag 21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
## ---                                                                 
## 31: Maserati Bora 15.0   8  301 335 3.54 3.570 14.60  0  1    5    8
## 32:    Volvo 142E 21.4   4  121 109 4.11 2.780 18.60  1  1    4    2

mtcarsTBL &lt;- qTBL(mtcars, row.names.col = &quot;car&quot;)
print(mtcarsTBL, n = 3)
## # A tibble: 32 x 12
##   car             mpg   cyl  disp    hp  drat    wt  qsec    vs    am  gear  carb
##   &lt;chr&gt;         &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;
## 1 Mazda RX4      21       6   160   110  3.9   2.62  16.5     0     1     4     4
## 2 Mazda RX4 Wag  21       6   160   110  3.9   2.88  17.0     0     1     4     4
## 3 Datsun 710     22.8     4   108    93  3.85  2.32  18.6     1     1     4     1
## # ... with 29 more rows</code></pre>
<p>These objects can then be manipulated using an advanced and attribute preserving set of (S3 generic) statistical and data manipulation functions. The following infographic summarizes the core <em>collapse</em> namespace:</p>
<p><img src='collapse_header.png' width="100%" align="center"></p>
<p>More details are provided in the freshly released <a href="https://raw.githubusercontent.com/SebKrantz/cheatsheets/master/collapse.pdf"><em>cheat sheet</em></a>, and further in the <a href="https://sebkrantz.github.io/collapse/reference/index.html"><em>documentation</em></a> and <a href="https://sebkrantz.github.io/collapse/articles/index.html"><em>vignettes</em></a>.</p>
<!--

The following graphic summarized the core fun `fselect(<-)`, `fsubset`, `(f/set)transform(<-)`, `fgroup_by`, `(f/set)rename`, `roworder`, `colorder`, `funique` `get_vars`, `(type)_vars`, `add_vars`, `na_omit`, etc.., as well as full sets of fast generic statistical and data transformation functions (supporting vectors, matrices and data frames) and further specialized functions.
-->
<!-- ```{r} -->
<!-- # Macro storing names of collapse fast functions for statistical computation and data transformation -->
<!-- .FAST_FUN  -->
<!-- # Some additional convenience operators for data transformations -->
<!-- .OPERATOR_FUN -->
<!-- # Also relevant: Aggregating, sweeping, row-and column apply -->
<!-- .c(collap, BY, TRA, dapply) -->
<!-- # Recode and replace values -->
<!-- .c(recode_num, recode_char, replace_NA, replace_Inf, replace_outliers) -->
<!-- ``` -->
<p>The statistical functions internally handle grouped and / or weighted computations on vectors, matrices and data frames, and seek to keep the attributes of the object.</p>
<pre class="r"><code># Simple data frame: Grouped mean by cyl -&gt; groups = row.names  
fmean(fselect(mtcars, mpg, disp, drat), g = mtcars$cyl)
##        mpg     disp     drat
## 4 26.66364 105.1364 4.070909
## 6 19.74286 183.3143 3.585714
## 8 15.10000 353.1000 3.229286</code></pre>
<p>With <code>fgroup_by</code>, collapse also introduces a fast grouping mechanism that works together with <em>grouped_df</em> versions of all statistical and transformation functions:</p>
<pre class="r"><code># Using Pipe operators and grouped data frames
library(magrittr)
mtcars %&gt;% fgroup_by(cyl) %&gt;% 
  fselect(mpg, disp, drat, wt) %&gt;% fmean  
##   cyl      mpg     disp     drat       wt
## 1   4 26.66364 105.1364 4.070909 2.285727
## 2   6 19.74286 183.3143 3.585714 3.117143
## 3   8 15.10000 353.1000 3.229286 3.999214

# This is still a data.table 
mtcarsDT %&gt;% fgroup_by(cyl) %&gt;% 
  fselect(mpg, disp, drat, wt) %&gt;% fmean
##    cyl      mpg     disp     drat       wt
## 1:   4 26.66364 105.1364 4.070909 2.285727
## 2:   6 19.74286 183.3143 3.585714 3.117143
## 3:   8 15.10000 353.1000 3.229286 3.999214

# Same with tibble: here computing weighted group means -&gt; also saves sum of weights in each group
mtcarsTBL %&gt;% fgroup_by(cyl) %&gt;% 
  fselect(mpg, disp, drat, wt) %&gt;% fmean(wt)
## # A tibble: 3 x 5
##     cyl sum.wt   mpg  disp  drat
##   &lt;dbl&gt;  &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;
## 1     4   25.1  25.9  110.  4.03
## 2     6   21.8  19.6  185.  3.57
## 3     8   56.0  14.8  362.  3.21</code></pre>
<p>A specialty of the grouping mechanism is that it fully preserves the structure / attributes of the object, and thus permits the creation of a grouped version of any data frame like object.</p>
<pre class="r"><code># This created a grouped data.table
gmtcarsDT &lt;- mtcarsDT %&gt;% fgroup_by(cyl)
gmtcarsDT
##               car  mpg cyl disp  hp drat    wt  qsec vs am gear carb
##  1:     Mazda RX4 21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
##  2: Mazda RX4 Wag 21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
## ---                                                                 
## 31: Maserati Bora 15.0   8  301 335 3.54 3.570 14.60  0  1    5    8
## 32:    Volvo 142E 21.4   4  121 109 4.11 2.780 18.60  1  1    4    2
## 
## Grouped by:  cyl  [3 | 11 (3.5)]
# The print shows: [N. groups | Avg. group size (SD around avg. group size)]

# Subsetting drops groups 
gmtcarsDT[1:2]
##              car mpg cyl disp  hp drat    wt  qsec vs am gear carb
## 1:     Mazda RX4  21   6  160 110  3.9 2.620 16.46  0  1    4    4
## 2: Mazda RX4 Wag  21   6  160 110  3.9 2.875 17.02  0  1    4    4

# Any class-specific methods are independent of the attached groups
gmtcarsDT[, new := mean(mpg)]
gmtcarsDT[, lapply(.SD, mean), by = vs, .SDcols = -1L] # Again groups are dropped
##    vs      mpg      cyl     disp        hp     drat       wt     qsec        am     gear     carb
## 1:  0 16.61667 7.444444 307.1500 189.72222 3.392222 3.688556 16.69389 0.3333333 3.555556 3.611111
## 2:  1 24.55714 4.571429 132.4571  91.35714 3.859286 2.611286 19.33357 0.5000000 3.857143 1.785714
##         new
## 1: 20.09062
## 2: 20.09062

# Groups are always preserved in column-subsetting operations
gmtcarsDT[, 9:13] 
##     vs am gear carb      new
##  1:  0  1    4    4 20.09062
##  2:  0  1    4    4 20.09062
## ---                         
## 31:  0  1    5    8 20.09062
## 32:  1  1    4    2 20.09062
## 
## Grouped by:  cyl  [3 | 11 (3.5)]</code></pre>
<p>The grouping is also dropped in aggregations, but preserved in transformations keeping data dimensions:</p>
<pre class="r"><code># Grouped medians 
fmedian(gmtcarsDT[, 9:13])
##    cyl vs am gear carb      new
## 1:   4  1  1    4  2.0 20.09062
## 2:   6  1  0    4  4.0 20.09062
## 3:   8  0  0    3  3.5 20.09062
# Note: unique grouping columns are stored in the attached grouping object 
# and added if keep.group_vars = TRUE (the default)

# Replacing data by grouped median (grouping columns are not selected and thus not present)
fmedian(gmtcarsDT[, 4:5], TRA = &quot;replace&quot;)
##      disp    hp
##  1: 167.6 110.0
##  2: 167.6 110.0
## ---            
## 31: 350.5 192.5
## 32: 108.0  91.0
## 
## Grouped by:  cyl  [3 | 11 (3.5)]

# Weighted scaling and centering data (here also selecting grouping column)
mtcarsDT %&gt;% fgroup_by(cyl) %&gt;% 
  fselect(cyl, mpg, disp, drat, wt) %&gt;% fscale(wt)
##     cyl    wt         mpg       disp      drat
##  1:   6 2.620  0.96916875 -0.6376553 0.7123846
##  2:   6 2.875  0.96916875 -0.6376553 0.7123846
## ---                                           
## 31:   8 3.570  0.07335466 -0.8685527 0.9844833
## 32:   4 2.780 -1.06076989  0.3997723 0.2400387
## 
## Grouped by:  cyl  [3 | 11 (3.5)]</code></pre>
<p>As mentioned, this works for any data frame like object, even a suitable list:</p>
<pre class="r"><code># Here computing a weighted grouped standard deviation
as.list(mtcars) %&gt;% fgroup_by(cyl, vs, am) %&gt;% 
  fsd(wt) %&gt;% str
## List of 11
##  $ cyl   : num [1:7] 4 4 4 6 6 8 8
##  $ vs    : num [1:7] 0 1 1 0 1 0 0
##  $ am    : num [1:7] 1 0 1 1 0 0 1
##  $ sum.wt: num [1:7] 2.14 8.8 14.2 8.27 13.55 ...
##  $ mpg   : num [1:7] 0 1.236 4.833 0.655 1.448 ...
##  $ disp  : num [1:7] 0 11.6 19.25 7.55 39.93 ...
##  $ hp    : num [1:7] 0 17.3 22.7 32.7 8.3 ...
##  $ drat  : num [1:7] 0 0.115 0.33 0.141 0.535 ...
##  $ qsec  : num [1:7] 0 1.474 0.825 0.676 0.74 ...
##  $ gear  : num [1:7] 0 0.477 0.32 0.503 0.519 ...
##  $ carb  : num [1:7] 0 0.477 0.511 1.007 1.558 ...
##  - attr(*, &quot;row.names&quot;)= int [1:7] 1 2 3 4 5 6 7</code></pre>
<p>The function <code>fungroup</code> can be used to undo any grouping operation.</p>
<pre class="r"><code>identical(mtcarsDT,
          mtcarsDT %&gt;% fgroup_by(cyl, vs, am) %&gt;% fungroup)
## [1] TRUE</code></pre>
<p>Apart from the grouping mechanism with <code>fgroup_by</code>, which is very fast and versatile, <em>collapse</em> also supports regular grouped tibbles created with <em>dplyr</em>:</p>
<pre class="r"><code>library(dplyr)
# Same as summarize_all(sum) and considerably faster
mtcars %&gt;% group_by(cyl) %&gt;% fsum
## # A tibble: 3 x 11
##     cyl   mpg  disp    hp  drat    wt  qsec    vs    am  gear  carb
##   &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;
## 1     4  293. 1157.   909  44.8  25.1  211.    10     8    45    17
## 2     6  138. 1283.   856  25.1  21.8  126.     4     3    27    24
## 3     8  211. 4943.  2929  45.2  56.0  235.     0     2    46    49

# Same as muatate_all(sum)
mtcars %&gt;% group_by(cyl) %&gt;% fsum(TRA = &quot;replace_fill&quot;) %&gt;% head(3)
## # A tibble: 3 x 11
## # Groups:   cyl [2]
##     cyl   mpg  disp    hp  drat    wt  qsec    vs    am  gear  carb
##   &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;
## 1     6  138. 1283.   856  25.1  21.8  126.     4     3    27    24
## 2     6  138. 1283.   856  25.1  21.8  126.     4     3    27    24
## 3     4  293. 1157.   909  44.8  25.1  211.    10     8    45    17</code></pre>
<p>One major goal of the package is to make R suitable for (large) panel data, thus <em>collapse</em> also supports panel-data.frames created with the <em>plm</em> package:</p>
<pre class="r"><code>library(plm)
pwlddev &lt;- pdata.frame(wlddev, index = c(&quot;iso3c&quot;, &quot;year&quot;))

# Centering (within-transforming) columns 9-12 using the within operator W()
head(W(pwlddev, cols = 9:12), 3)
##          iso3c year W.PCGDP  W.LIFEEX W.GINI W.ODA
## ABW-1960   ABW 1960      NA -6.547351     NA    NA
## ABW-1961   ABW 1961      NA -6.135351     NA    NA
## ABW-1962   ABW 1962      NA -5.765351     NA    NA

# Computing growth rates of columns 9-12 using the growth operator G()
head(G(pwlddev, cols = 9:12), 3)
##          iso3c year G1.PCGDP G1.LIFEEX G1.GINI G1.ODA
## ABW-1960   ABW 1960       NA        NA      NA     NA
## ABW-1961   ABW 1961       NA 0.6274558      NA     NA
## ABW-1962   ABW 1962       NA 0.5599782      NA     NA</code></pre>
<p>Perhaps a note about operators is necessary here before proceeding: <em>collapse</em> offers a set of transformation operators for its vector-valued fast functions:</p>
<pre class="r"><code># Operators
.OPERATOR_FUN
##  [1] &quot;STD&quot;  &quot;B&quot;    &quot;W&quot;    &quot;HDB&quot;  &quot;HDW&quot;  &quot;L&quot;    &quot;F&quot;    &quot;D&quot;    &quot;Dlog&quot; &quot;G&quot;

# Corresponding (programmers) functions
setdiff(.FAST_FUN, .FAST_STAT_FUN)
## [1] &quot;fscale&quot;     &quot;fbetween&quot;   &quot;fwithin&quot;    &quot;fHDbetween&quot; &quot;fHDwithin&quot;  &quot;flag&quot;       &quot;fdiff&quot;     
## [8] &quot;fgrowth&quot;</code></pre>
<p>These operators are principally just function shortcuts that exist for parsimony and in-formula use (e.g. to specify dynamic or fixed effects models using <code>lm()</code>, see the <a href="https://sebkrantz.github.io/collapse/reference/index.html">documentation</a>). They however also have some useful extra features in the data.frame method, such as internal column-subsetting using the <code>cols</code> argument or stub-renaming transformed columns (adding a ‘W.’ or ‘Gn.’ prefix as shown above). They also permit grouping variables to be passed using formulas, including options to keep (default) or drop those variables in the output. We will see this feature when using time series below.</p>
<p>To round things off for data frames, I demonstrate the use of <em>collapse</em> with classes it was not directly built to support but can also handle very well. Through it’s built in capabilities for handling panel data, <em>tsibble</em>’s can seamlessly be utilized:</p>
<pre class="r"><code>library(tsibble)
tsib &lt;- as_tsibble(EuStockMarkets)

# Computing daily and annual growth rates on tsibble
head(G(tsib, c(1, 260), by = ~ key, t = ~ index), 3)
## # A tsibble: 3 x 4 [1s] &lt;UTC&gt;
## # Key:       key [1]
##   key   index               G1.value L260G1.value
##   &lt;chr&gt; &lt;dttm&gt;                 &lt;dbl&gt;        &lt;dbl&gt;
## 1 DAX   1991-07-01 02:18:33   NA               NA
## 2 DAX   1991-07-02 12:00:00   -0.928           NA
## 3 DAX   1991-07-03 21:41:27   -0.441           NA

# Computing a compounded annual growth rate
head(G(tsib, 260, by = ~ key, t = ~ index, power = 1/260), 3)
## # A tsibble: 3 x 3 [1s] &lt;UTC&gt;
## # Key:       key [1]
##   key   index               L260G1.value
##   &lt;chr&gt; &lt;dttm&gt;                     &lt;dbl&gt;
## 1 DAX   1991-07-01 02:18:33           NA
## 2 DAX   1991-07-02 12:00:00           NA
## 3 DAX   1991-07-03 21:41:27           NA</code></pre>
<p>Similarly for <em>tibbletime</em>:</p>
<pre class="r"><code>library(tibbletime); library(tsbox)
# Using the tsbox converter
tibtm &lt;- ts_tibbletime(EuStockMarkets)

# Computing daily and annual growth rates on tibbletime
head(G(tibtm, c(1, 260), t = ~ time), 3)
## # A time tibble: 3 x 9
## # Index: time
##   time                G1.DAX L260G1.DAX G1.SMI L260G1.SMI G1.CAC L260G1.CAC G1.FTSE L260G1.FTSE
##   &lt;dttm&gt;               &lt;dbl&gt;      &lt;dbl&gt;  &lt;dbl&gt;      &lt;dbl&gt;  &lt;dbl&gt;      &lt;dbl&gt;   &lt;dbl&gt;       &lt;dbl&gt;
## 1 1991-07-01 02:18:27 NA             NA NA             NA  NA            NA  NA              NA
## 2 1991-07-02 12:01:32 -0.928         NA  0.620         NA  -1.26         NA   0.679          NA
## 3 1991-07-03 21:44:38 -0.441         NA -0.586         NA  -1.86         NA  -0.488          NA
# ...</code></pre>
<p>Finally lets consider the simple features data frame:</p>
<pre class="r"><code>library(sf)
nc &lt;- st_read(system.file(&quot;shape/nc.shp&quot;, package=&quot;sf&quot;))
## Reading layer `nc&#39; from data source `C:\Users\Sebastian Krantz\Documents\R\win-library\4.0\sf\shape\nc.shp&#39; using driver `ESRI Shapefile&#39;
## Simple feature collection with 100 features and 14 fields
## geometry type:  MULTIPOLYGON
## dimension:      XY
## bbox:           xmin: -84.32385 ymin: 33.88199 xmax: -75.45698 ymax: 36.58965
## geographic CRS: NAD27

# Fast selecting columns (need to add &#39;geometry&#39; column to not break the class)
plot(fselect(nc, AREA, geometry))</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/11/14/class-agnostic-data-manipulation/figure-html/unnamed-chunk-13-1.png" width="100%" /></p>
<pre class="r"><code>
# Subsetting 
fsubset(nc, AREA &gt; 0.23, NAME, AREA, geometry)
## Simple feature collection with 3 features and 2 fields
## geometry type:  MULTIPOLYGON
## dimension:      XY
## bbox:           xmin: -84.32385 ymin: 33.88199 xmax: -75.45698 ymax: 36.58965
## geographic CRS: NAD27
##       NAME  AREA                       geometry
## 1  Sampson 0.241 MULTIPOLYGON (((-78.11377 3...
## 2  Robeson 0.240 MULTIPOLYGON (((-78.86451 3...
## 3 Columbus 0.240 MULTIPOLYGON (((-78.65572 3...

# Standardizing numeric columns (by reference)
settransformv(nc, is.numeric, STD, apply = FALSE)
# Note: Here using using operator STD() instead of fscale() to stub-rename standardized columns.
# apply = FALSE uses STD.data.frame on all numeric columns instead of lapply(data, STD)
head(nc, 2)
## Simple feature collection with 2 features and 26 fields
## geometry type:  MULTIPOLYGON
## dimension:      XY
## bbox:           xmin: -81.74107 ymin: 36.23436 xmax: -80.90344 ymax: 36.58965
## geographic CRS: NAD27
##    AREA PERIMETER CNTY_ CNTY_ID      NAME  FIPS FIPSNO CRESS_ID BIR74 SID74 NWBIR74 BIR79 SID79
## 1 0.114     1.442  1825    1825      Ashe 37009  37009        5  1091     1      10  1364     0
## 2 0.061     1.231  1827    1827 Alleghany 37005  37005        3   487     0      10   542     3
##   NWBIR79                       geometry  STD.AREA STD.PERIMETER STD.CNTY_ STD.CNTY_ID STD.FIPSNO
## 1      19 MULTIPOLYGON (((-81.47276 3... -0.249186    -0.4788595 -1.511125   -1.511125  -1.568344
## 2      12 MULTIPOLYGON (((-81.23989 3... -1.326418    -0.9163351 -1.492349   -1.492349  -1.637282
##   STD.CRESS_ID  STD.BIR74  STD.SID74 STD.NWBIR74  STD.BIR79  STD.SID79 STD.NWBIR79
## 1    -1.568344 -0.5739411 -0.7286824  -0.7263602 -0.5521659 -0.8863574  -0.6750055
## 2    -1.637282 -0.7308990 -0.8571979  -0.7263602 -0.7108697 -0.5682866  -0.6785480</code></pre>
<!-- A salient feature of this class is the added 'geometry' column, so to select columns one has to also select this column to not break the class: -->
<!-- ```{r} -->
<!-- fselect(nc, NAME, AREA, geometry) %>% head(3) -->
<!-- ``` -->
</div>
<div id="matrix-based-objects" class="section level1">
<h1>Matrix Based Objects</h1>
<p><em>collapse</em> also offers a converter <code>qM</code> to efficiently convert various objects to matrix:</p>
<pre class="r"><code>m &lt;- qM(mtcars)</code></pre>
<p>Grouped and / or weighted computations and transformations work as with with data frames:</p>
<pre class="r"><code># Grouped means
fmean(m, g = mtcars$cyl)
##        mpg cyl     disp        hp     drat       wt     qsec        vs        am     gear     carb
## 4 26.66364   4 105.1364  82.63636 4.070909 2.285727 19.13727 0.9090909 0.7272727 4.090909 1.545455
## 6 19.74286   6 183.3143 122.28571 3.585714 3.117143 17.97714 0.5714286 0.4285714 3.857143 3.428571
## 8 15.10000   8 353.1000 209.21429 3.229286 3.999214 16.77214 0.0000000 0.1428571 3.285714 3.500000

# Grouped and weighted standardizing
head(fscale(m, g = mtcars$cyl, w = mtcars$wt), 3)
##                      mpg cyl        disp         hp       drat         wt       qsec         vs
## Mazda RX4      0.9691687 NaN -0.63765527 -0.5263758  0.7123846 -1.6085211 -1.0438559 -1.2509539
## Mazda RX4 Wag  0.9691687 NaN -0.63765527 -0.5263758  0.7123846 -0.8376064 -0.6921302 -1.2509539
## Datsun 710    -0.7333024 NaN -0.08822497  0.4896429 -0.5526066 -0.1688057 -0.4488514  0.2988833
##                     am        gear      carb
## Mazda RX4     1.250954  0.27612029  0.386125
## Mazda RX4 Wag 1.250954  0.27612029  0.386125
## Datsun 710    0.719370 -0.09429567 -1.133397</code></pre>
<p>Various matrix-based time series classes such as <em>xts</em> / <em>zoo</em> and <em>timeSeries</em> are also easily handled:</p>
<pre class="r"><code># ts / mts
# Note: G() by default renames the columns, fgrowth() does not
plot(G(EuStockMarkets))</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/11/14/class-agnostic-data-manipulation/figure-html/unnamed-chunk-16-1.png" width="100%" /></p>
<pre class="r"><code># xts
library(xts) 
ESM_xts &lt;- ts_xts(EuStockMarkets) # using tsbox::ts_xts
head(G(ESM_xts), 3)
##                         G1.DAX     G1.SMI    G1.CAC    G1.FTSE
## 1991-07-01 02:18:27         NA         NA        NA         NA
## 1991-07-02 12:01:32 -0.9283193  0.6197485 -1.257897  0.6793256
## 1991-07-03 21:44:38 -0.4412412 -0.5863192 -1.856612 -0.4877652

plot(G(ESM_xts), legend.loc = &quot;bottomleft&quot;)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/11/14/class-agnostic-data-manipulation/figure-html/unnamed-chunk-16-2.png" width="100%" /></p>
<pre class="r"><code># timeSeries
library(timeSeries) # using tsbox::ts_timeSeries
ESM_timeSeries &lt;- ts_timeSeries(EuStockMarkets)
# Note: G() here also renames the columns but the names of the series are also stored in an attribute
head(G(ESM_timeSeries), 3)
## GMT
##                            DAX        SMI       CAC       FTSE
## 1991-06-30 23:18:27         NA         NA        NA         NA
## 1991-07-02 09:01:32 -0.9283193  0.6197485 -1.257897  0.6793256
## 1991-07-03 18:44:38 -0.4412412 -0.5863192 -1.856612 -0.4877652

plot(G(ESM_timeSeries), plot.type = &quot;single&quot;, at = &quot;pretty&quot;)
legend(&quot;bottomleft&quot;, colnames(G(qM(ESM_timeSeries))), lty = 1, col = 1:4)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/11/14/class-agnostic-data-manipulation/figure-html/unnamed-chunk-16-3.png" width="100%" /></p>
<p>Aggregating these objects yields a plain matrix with groups in the row-names:</p>
<pre class="r"><code># Aggregating by year: creates plain matrix with row-names (g is second argument)
EuStockMarkets %&gt;% fmedian(round(time(.)))
##           DAX     SMI    CAC    FTSE
## 1991 1628.750 1678.10 1772.8 2443.60
## 1992 1649.550 1733.30 1863.5 2558.50
## 1993 1606.640 2061.70 1837.5 2773.40
## 1994 2089.770 2727.10 2148.0 3111.40
## 1995 2072.680 2591.60 1918.5 3091.70
## 1996 2291.820 3251.60 1946.2 3661.65
## 1997 2861.240 3906.55 2297.1 4075.35
## 1998 4278.725 6077.40 3002.7 5222.20
## 1999 5905.150 8102.70 4205.4 5884.50

# Same thing with the other objects
all_obj_equal(ESM_xts %&gt;% fmedian(substr(time(.), 1L, 4L)),
              ESM_timeSeries %&gt;% fmedian(substr(time(.), 1L, 4L)))
## [1] TRUE</code></pre>
</div>
<div id="benchmarks" class="section level1">
<h1>Benchmarks</h1>
<!-- , particularly for `:=` transformations and weighted aggregations -->
<p>Extensive benchmarks and examples against native <em>dplyr</em> / <em>tibble</em> and <em>plm</em> are provided <a href="https://sebkrantz.github.io/collapse/articles/collapse_and_dplyr.html#3-benchmarks"><em>here</em></a> and <a href="https://sebkrantz.github.io/collapse/articles/collapse_and_plm.html#benchmarks-1"><em>here</em></a>, making it evident that <em>collapse</em> provides both greater versatility and massive performance improvements over the methods defined for these objects. Benchmarks against <em>data.table</em> were provided in a <a href="https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/"><em>previous post</em></a>, where <em>collapse</em> compared favorably on a 2-core machine (particularly for weighted and <code>:=</code> type operations). In general <em>collapse</em> functions are extremely well optimized, with basic execution speeds below 30 microseconds, and efficiently scale to larger operations. Most importantly, they preserve the data structure and attributes (including column attributes) of the objects passed to them. They also efficiently skip missing values and avoid some of the undesirable behavior endemic of base R<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a>.</p>
<p>Here I will add to the above resources just a small benchmark to prove that computations with <em>collapse</em> are also faster than any native methods and suggested programming principles for the various time series classes:</p>
<pre class="r"><code>library(dplyr) # needed for tibbletime / tsibble comparison    
library(microbenchmark)

# Computing the first difference
microbenchmark(ts = diff(EuStockMarkets),
               collapse_ts = fdiff(EuStockMarkets),
               xts = diff(ESM_xts),
               collapse_xts = fdiff(ESM_xts),
               timeSeries = diff(ESM_timeSeries),
               collapse_timeSeries = fdiff(ESM_timeSeries),
               # taking difference function from tsibble
               dplyr_tibbletime = mutate_at(tibtm, 2:5, difference, order_by = tibtm$time),
               collapse_tibbletime_D = D(tibtm, t = ~ time),
               # collapse equivalent to the dplyr method (tfmv() abbreviates ftransformv())
               collapse_tibbletime_tfmv = tfmv(tibtm, 2:5, fdiff, t = time, apply = FALSE),
               # dplyr helpers provided by tsibble package
               dplyr_tsibble = mutate(group_by_key(tsib), value = difference(value, order_by = index)),
               collapse_tsibble_D = D(tsib, 1, 1, ~ key, ~ index),
               # Again we can do the same using collapse (tfm() abbreviates ftransform())
               collapse_tsibble_tfm = tfm(tsib, value = fdiff(value, 1, 1, key, index)))
## Unit: microseconds
##                      expr       min         lq       mean     median         uq        max neval cld
##                        ts  1681.018  2061.2220  2719.8758  2313.1290  3055.6870   9473.855   100  a 
##               collapse_ts    25.883    56.4505   163.3896    70.9540    88.1340   8732.636   100  a 
##                       xts    93.713   176.7150   279.5615   200.3655   257.0395   3656.561   100  a 
##              collapse_xts    42.840    78.0940   116.1231    99.7370   126.5120    597.973   100  a 
##                timeSeries  1964.386  2287.4700  3185.6565  2742.4210  3310.7180  17093.102   100  a 
##       collapse_timeSeries    52.658    93.9355  2483.3061   108.4390   132.7590 235259.074   100  a 
##          dplyr_tibbletime  8097.625 10173.1265 14784.9888 11176.0700 12863.3355 239144.113   100   b
##     collapse_tibbletime_D   568.967   630.5490   857.0829   751.2595   850.3270   8017.301   100  a 
##  collapse_tibbletime_tfmv   501.137   585.4780   773.2819   664.2410   817.0815   2922.035   100  a 
##             dplyr_tsibble 10386.434 12641.1040 14690.4243 13558.5910 15970.5640  32364.171   100   b
##        collapse_tsibble_D   963.897  1056.4935  1338.5404  1210.8955  1477.7525   3665.485   100  a 
##      collapse_tsibble_tfm   940.246   993.5720  1272.6027  1114.9525  1420.6320   6188.128   100  a

# Sequence of lagged/leaded and iterated differences (not supported by either of these methods)
head(fdiff(ESM_xts, -1:1, diff = 1:2)[, 1:6], 3) 
##                     FD1.DAX FD2.DAX     DAX D1.DAX D2.DAX FD1.SMI
## 1991-07-01 02:18:27   15.12    8.00 1628.75     NA     NA   -10.4
## 1991-07-02 12:01:32    7.12   21.65 1613.63 -15.12     NA     9.9
## 1991-07-03 21:44:38  -14.53  -17.41 1606.51  -7.12      8    -5.5
head(D(tibtm, -1:1, diff = 1:2, t = ~ time), 3)
## # A time tibble: 3 x 21
## # Index: time
##   time                FD1.DAX FD2.DAX   DAX D1.DAX D2.DAX FD1.SMI FD2.SMI   SMI D1.SMI D2.SMI FD1.CAC
##   &lt;dttm&gt;                &lt;dbl&gt;   &lt;dbl&gt; &lt;dbl&gt;  &lt;dbl&gt;  &lt;dbl&gt;   &lt;dbl&gt;   &lt;dbl&gt; &lt;dbl&gt;  &lt;dbl&gt;  &lt;dbl&gt;   &lt;dbl&gt;
## 1 1991-07-01 02:18:27   15.1     8.00 1629.  NA     NA      -10.4   -20.3 1678.   NA     NA      22.3
## 2 1991-07-02 12:01:32    7.12   21.7  1614. -15.1   NA        9.9    15.4 1688.   10.4   NA      32.5
## 3 1991-07-03 21:44:38  -14.5   -17.4  1607.  -7.12   8.00    -5.5    -3   1679.   -9.9  -20.3     9.9
## # ... with 9 more variables: FD2.CAC &lt;dbl&gt;, CAC &lt;dbl&gt;, D1.CAC &lt;dbl&gt;, D2.CAC &lt;dbl&gt;, FD1.FTSE &lt;dbl&gt;,
## #   FD2.FTSE &lt;dbl&gt;, FTSE &lt;dbl&gt;, D1.FTSE &lt;dbl&gt;, D2.FTSE &lt;dbl&gt;
head(D(tsib, -1:1, diff = 1:2, ~ key, ~ index), 3)
## # A tsibble: 3 x 7 [1s] &lt;UTC&gt;
## # Key:       key [1]
##   key   index               FD1.value FD2.value value D1.value D2.value
##   &lt;chr&gt; &lt;dttm&gt;                  &lt;dbl&gt;     &lt;dbl&gt; &lt;dbl&gt;    &lt;dbl&gt;    &lt;dbl&gt;
## 1 DAX   1991-07-01 02:18:33     15.1       8.00 1629.    NA       NA   
## 2 DAX   1991-07-02 12:00:00      7.12     21.7  1614.   -15.1     NA   
## 3 DAX   1991-07-03 21:41:27    -14.5     -17.4  1607.    -7.12     8.00

microbenchmark(collapse_xts = fdiff(ESM_xts, -1:1, diff = 1:2),
               collapse_tibbletime = D(tibtm, -1:1, diff = 1:2, t = ~ time),
               collapse_tsibble = D(tsib, -1:1, diff = 1:2, ~ key, ~ index))
## Unit: microseconds
##                 expr      min        lq      mean   median       uq       max neval cld
##         collapse_xts  147.263  218.2155  358.0477  254.362  290.508  8398.843   100 a  
##  collapse_tibbletime  750.144  988.4405 1348.9559 1026.149 1085.722 13947.942   100  b 
##     collapse_tsibble 1258.421 1653.3505 1793.5130 1707.793 1757.327  8411.784   100   c</code></pre>
</div>
<div id="conclusion" class="section level1">
<h1>Conclusion</h1>
<p>This concludes this short demonstration. <em>collapse</em> is an advanced, fast and versatile data manipulation package. If you have followed until here I am convinced you will find it very useful, particularly if you are working in advanced statistics, econometrics, surveys, time series, panel data and the like, or if you care much about performance and non-destructive working in R. For more information about the package see the <a href="https://sebkrantz.github.io/collapse/index.html"><em>website</em></a>, study the <a href="https://raw.githubusercontent.com/SebKrantz/cheatsheets/master/collapse.pdf"><em>cheat sheet</em></a> or call <code>help("collapse-documentation")</code> after install to bring up the built-in documentation.</p>
</div>
<div id="appendix-so-how-does-this-all-actually-work" class="section level1">
<h1>Appendix: So how does this all actually work?</h1>
<p>Statistical functions like <code>fmean</code> are S3 generic with user visible ‘default’, ‘matrix’ and ‘data.frame’ methods, and hidden ‘list’ and ‘grouped_df’ methods. Transformation functions like <code>fwithin</code> additionally have ‘pseries’ and ‘pdata.frame’ methods to support <em>plm</em> objects.</p>
<p>The ‘default’, ‘matrix’ and ‘data.frame’ methods handle object attributes intelligently. In the case of ‘data.frame’s’ only the ‘row.names’ attribute is modified accordingly, other attributes (including column attributes) are preserved. This also holds for data manipulation functions like <code>fselect</code>, <code>fsubset</code>, <code>ftransform</code> etc.. ‘default’ and ‘matrix’ methods preserve attributes as long as the data dimensions are kept.</p>
<p>In addition, the ‘default’ method checks if its argument is actually a matrix, and calls the matrix method if <code>is.matrix(x) &amp;&amp; !inherits(x, "matrix")</code> is <code>TRUE</code>. This prevents classed matrix-based objects (such as <em>xts</em> time series) not inheriting from ‘matrix’ being handled by the default method.</p>
</div>
<div class="footnotes">
<hr />
<ol>
<li id="fn1"><p>For example. <code>mean(NA, na.rm = TRUE)</code> gives <code>NaN</code>, <code>sum(NA, na.rm = TRUE)</code> gives <code>0</code> and <code>max(NA, na.rm = TRUE)</code> gives <code>-Inf</code> whereas <code>all_identical(NA_real_, fmean(NA), fsum(NA), fmax(NA))</code>. <code>na.rm = TRUE</code> is the default setting for all <em>collapse</em> functions. Setting <code>na.rm = FALSE</code> also checks for missing values and returns <code>NA</code> if found instead of just running through the entire computation and then returning a <code>NA</code> or <code>NaN</code> value which is unreliable and inefficient.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Efficient Grouped Programming in R and/or C/C&#43;&#43; - with the collapse Package</title>
      <link>https://sebkrantz.github.io/Rblog/2020/09/13/programming-with-collapse/</link>
      <pubDate>Sun, 13 Sep 2020 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2020/09/13/programming-with-collapse/</guid>
      <description><![CDATA[ 
<link href="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.css" rel="stylesheet" />
<script src="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.js"></script>


<p><a href="https://sebkrantz.github.io/collapse/"><em>collapse</em></a> is a C/C++ based package for data transformation and statistical computing in R. Among other features it introduces an excellent and highly efficient architecture for grouped (and weighted) statistical programming in R. This post briefly explains this architecture and demonstrates:</p>
<ol style="list-style-type: decimal">
<li><p>How to program highly efficient grouped statistical computations and data manipulations in R using the grouped functions supplied by <em>collapse</em>.</p></li>
<li><p>How to use the grouping mechanism of <em>collapse</em> with custom C/C++ code to create further efficient grouped functions/operations in R.</p></li>
</ol>
<div id="essentials-collapse-grouping-objects" class="section level1">
<h1>Essentials: <em>collapse</em> Grouping Objects</h1>
<p><em>collapse</em> uses grouping objects as essential inputs for grouped computations. These objects are created from vectors or lists of vectors (i.e. data frames) using the function <code>GRP()</code>:</p>
<pre class="r"><code>library(collapse)
# A dataset supplied with collapse providing sectoral value added (VA) and employment (EMP)
head(GGDC10S, 3)
##   Country Regioncode             Region Variable Year AGR MIN MAN PU CON WRT TRA FIRE GOV OTH SUM
## 1     BWA        SSA Sub-saharan Africa       VA 1960  NA  NA  NA NA  NA  NA  NA   NA  NA  NA  NA
## 2     BWA        SSA Sub-saharan Africa       VA 1961  NA  NA  NA NA  NA  NA  NA   NA  NA  NA  NA
## 3     BWA        SSA Sub-saharan Africa       VA 1962  NA  NA  NA NA  NA  NA  NA   NA  NA  NA  NA
fdim(GGDC10S)
## [1] 5027   16

# Creating a grouping object (by default return.order = FALSE as the ordering is typically not needed)
g &lt;- GRP(GGDC10S, c(&quot;Country&quot;, &quot;Variable&quot;), return.order = TRUE)
# Printing it
print(g)
## collapse grouping object of length 5027 with 85 ordered groups
## 
## Call: GRP.default(X = GGDC10S, by = c(&quot;Country&quot;, &quot;Variable&quot;), return.order = TRUE), X is unordered
## 
## Distribution of group sizes: 
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##    4.00   53.00   62.00   59.14   63.00   65.00 
## 
## Groups with sizes: 
## ARG.EMP  ARG.VA BOL.EMP  BOL.VA BRA.EMP  BRA.VA 
##      62      62      61      62      62      62 
##   ---
## VEN.EMP  VEN.VA ZAF.EMP  ZAF.VA ZMB.EMP  ZMB.VA 
##      62      63      52      52      52      52
# Plotting it
plot(g)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/09/13/programming-with-collapse/figure-html/unnamed-chunk-1-1.png" width="100%" />
Grouping is done very efficiently using radix-based ordering in C (thanks to <em>data.table</em> source code). The structure of this object is shown below:</p>
<pre class="r"><code>str(g)
## List of 8
##  $ N.groups   : int 85
##  $ group.id   : int [1:5027] 8 8 8 8 8 8 8 8 8 8 ...
##  $ group.sizes: int [1:85] 62 62 61 62 62 62 52 52 63 62 ...
##  $ groups     :&#39;data.frame&#39;: 85 obs. of  2 variables:
##   ..$ Country : chr [1:85] &quot;ARG&quot; &quot;ARG&quot; &quot;BOL&quot; &quot;BOL&quot; ...
##   .. ..- attr(*, &quot;label&quot;)= chr &quot;Country&quot;
##   .. ..- attr(*, &quot;format.stata&quot;)= chr &quot;%9s&quot;
##   ..$ Variable: chr [1:85] &quot;EMP&quot; &quot;VA&quot; &quot;EMP&quot; &quot;VA&quot; ...
##   .. ..- attr(*, &quot;label&quot;)= chr &quot;Variable&quot;
##   .. ..- attr(*, &quot;format.stata&quot;)= chr &quot;%9s&quot;
##  $ group.vars : chr [1:2] &quot;Country&quot; &quot;Variable&quot;
##  $ ordered    : Named logi [1:2] TRUE FALSE
##   ..- attr(*, &quot;names&quot;)= chr [1:2] &quot;GRP.sort&quot; &quot;initially.ordered&quot;
##  $ order      : int [1:5027] 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 ...
##   ..- attr(*, &quot;starts&quot;)= int [1:85] 1 63 125 186 248 310 372 424 476 539 ...
##   ..- attr(*, &quot;maxgrpn&quot;)= int 65
##   ..- attr(*, &quot;sorted&quot;)= logi FALSE
##  $ call       : language GRP.default(X = GGDC10S, by = c(&quot;Country&quot;, &quot;Variable&quot;), return.order = TRUE)
##  - attr(*, &quot;class&quot;)= chr &quot;GRP&quot;</code></pre>
<p>The first three slots of this object provide the number of unique groups, a group-id matching each value/row to a group<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a>, and a vector of group-sizes. The fourth slot provides the unique groups (default <code>return.groups = TRUE</code>), followed by the names of the grouping variables, a logical vector showing whether the grouping is ordered (default <code>sort = TRUE</code>), and the ordering vector which can be used to sort the data alphabetically according to the grouping variables (default <code>return.order = FALSE</code>).</p>
</div>
<div id="grouped-programming-in-r" class="section level1">
<h1>Grouped Programming in R</h1>
<p><em>collapse</em> provides a whole ensemble of C++ based generic statistical functions that can use these ‘GRP’ objects to internally perform (column-wise) grouped (and weighted) computations on vectors, matrices and data frames in R. Their names are contained in the global macro <code>.FAST_FUN</code>:</p>
<pre class="r"><code>.FAST_FUN
##  [1] &quot;fmean&quot;      &quot;fmedian&quot;    &quot;fmode&quot;      &quot;fsum&quot;       &quot;fprod&quot;      &quot;fsd&quot;        &quot;fvar&quot;      
##  [8] &quot;fmin&quot;       &quot;fmax&quot;       &quot;fnth&quot;       &quot;ffirst&quot;     &quot;flast&quot;      &quot;fNobs&quot;      &quot;fNdistinct&quot;
## [15] &quot;fscale&quot;     &quot;fbetween&quot;   &quot;fwithin&quot;    &quot;fHDbetween&quot; &quot;fHDwithin&quot;  &quot;flag&quot;       &quot;fdiff&quot;     
## [22] &quot;fgrowth&quot;</code></pre>
<p>Additional functions supporting grouping objects are <code>TRA</code> (grouped replacing and sweeping out statistics), <code>BY</code> (split-apply-combine computing) and <code>collap</code> (advanced data aggregation with multiple functions).</p>
<p>To provide a brief example, we can compute a grouped mean of the above data using:</p>
<pre class="r"><code>head(fmean(GGDC10S[6:16], g))
##                AGR         MIN         MAN           PU        CON         WRT        TRA
## ARG.EMP  1419.8013    52.08903   1931.7602   101.720936   742.4044   1982.1775   648.5119
## ARG.VA  14951.2918  6454.94152  36346.5456  2722.762554  9426.0033  26633.1292 14404.6626
## BOL.EMP   964.2103    56.03295    235.0332     5.346433   122.7827    281.5164   115.4728
## BOL.VA   3299.7182  2846.83763   3458.2904   664.289574   729.0152   2757.9795  2727.4414
## BRA.EMP 17191.3529   206.02389   6991.3710   364.573404  3524.7384   8509.4612  2054.3731
## BRA.VA  76870.1456 30916.64606 223330.4487 43549.277879 70211.4219 178357.8685 89880.9743
##                 FIRE        GOV        OTH         SUM
## ARG.EMP    627.79291   2043.471   992.4475   10542.177
## ARG.VA    8547.37278  25390.774  7656.3565  152533.839
## BOL.EMP     44.56442         NA   395.5650    2220.524
## BOL.VA    1752.06208         NA  4383.5425   22619.177
## BRA.EMP   4413.54448   5307.280  5710.2665   54272.985
## BRA.VA  183027.46189 249135.452 55282.9748 1200562.671</code></pre>
<p>By default (<code>use.g.names = TRUE</code>), group names are added as names (vectors) or row-names (matrices and data frames) to the result. For data frames we can also add the grouping columns again using<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a>:</p>
<pre class="r"><code>head(add_vars(g[[&quot;groups&quot;]], fmean(get_vars(GGDC10S, 6:16), g, use.g.names = FALSE)))
##   Country Variable        AGR         MIN         MAN           PU        CON         WRT        TRA
## 1     ARG      EMP  1419.8013    52.08903   1931.7602   101.720936   742.4044   1982.1775   648.5119
## 2     ARG       VA 14951.2918  6454.94152  36346.5456  2722.762554  9426.0033  26633.1292 14404.6626
## 3     BOL      EMP   964.2103    56.03295    235.0332     5.346433   122.7827    281.5164   115.4728
## 4     BOL       VA  3299.7182  2846.83763   3458.2904   664.289574   729.0152   2757.9795  2727.4414
## 5     BRA      EMP 17191.3529   206.02389   6991.3710   364.573404  3524.7384   8509.4612  2054.3731
## 6     BRA       VA 76870.1456 30916.64606 223330.4487 43549.277879 70211.4219 178357.8685 89880.9743
##           FIRE        GOV        OTH         SUM
## 1    627.79291   2043.471   992.4475   10542.177
## 2   8547.37278  25390.774  7656.3565  152533.839
## 3     44.56442         NA   395.5650    2220.524
## 4   1752.06208         NA  4383.5425   22619.177
## 5   4413.54448   5307.280  5710.2665   54272.985
## 6 183027.46189 249135.452 55282.9748 1200562.671</code></pre>
<p>The execution cost of all of these functions is extremely small, so the performance is essentially limited by C++, not by R.</p>
<pre class="r"><code>library(microbenchmark) 
microbenchmark(call = add_vars(g[[&quot;groups&quot;]], fmean(get_vars(GGDC10S, 6:16), g, use.g.names = FALSE)))
## Unit: microseconds
##  expr     min       lq     mean  median      uq      max neval
##  call 394.038 434.6465 582.3857 471.685 566.959 2403.495   100</code></pre>
<p>We can use these functions to write very efficient grouped code in R. This shows a simple application in panel data econometrics comparing a pooled OLS to a group means, a between and a within estimator computed on the demeaned data<a href="#fn3" class="footnote-ref" id="fnref3"><sup>3</sup></a>:</p>
<pre class="r"><code>Panel_Ests &lt;- function(formula, data, pids, weights = NULL, ...) {
  # Get variables as character string, first variable is dependent variable
  vars &lt;- all.vars(formula)
  # na_omit is a fast replacement for na.omit
  data_cc &lt;- na_omit(get_vars(data, c(vars, pids, weights)))
  # Create grouped object (pids should be a character vector)
  g &lt;- GRP(data_cc, pids, return.groups = FALSE, call = FALSE)
  # Getting weights from data (weights should be character string)
  if(length(weights)) weights &lt;- .subset2(data_cc, weights)
  # qM is a faster as.matrix
  data_cc &lt;- qM(get_vars(data_cc, vars))
  # Computing group means 
  mean_data_cc &lt;- fmean(data_cc, g, weights, use.g.names = FALSE)
  # This computes regression coefficients, dots options regard different fitting methods
  reg &lt;- function(x, w = weights, ...) 
    drop(flm(x[, 1L], x[, -1L, drop = FALSE], w, add.icpt = TRUE, ...))
  
  qM(list(Pooled = reg(data_cc),
          Means = reg(mean_data_cc, w = if(length(weights)) fsum(weights, g) else NULL),
          # This replaces data values with the group-mean -&gt; between-group estimator
          Between = reg(TRA(data_cc, mean_data_cc, &quot;replace_fill&quot;, g)),
          # This subtracts the group-means -&gt; within-group estimator
          Within = reg(TRA(data_cc, mean_data_cc, &quot;-&quot;, g))))
}

library(magrittr)  # Pipe operators

# Calculating Value Added Percentage Shares (data is in local currency)
VA_shares &lt;- fsubset(GGDC10S, Variable == &quot;VA&quot;) %&gt;% ftransformv(6:16, `*`, 100/SUM)
  
# Value Added data (regressing Government on Agriculture, Manufactoring and Finance &amp; Real Estate)
Panel_Ests(GOV ~ AGR + MAN + FIRE, VA_shares, &quot;Country&quot;) %&gt;% round(4)
##              Pooled   Means Between  Within
## (Intercept) 25.8818 26.6702 26.5828  0.0000
## AGR         -0.3425 -0.3962 -0.3749 -0.2124
## MAN         -0.2339 -0.1744 -0.2215 -0.2680
## FIRE        -0.2083 -0.3337 -0.2572 -0.0742

# Employment data
fsubset(GGDC10S, Variable == &quot;EMP&quot;) %&gt;% ftransformv(6:16, `*`, 100/SUM) %&gt;%
  Panel_Ests(formula = GOV ~ AGR + MAN + FIRE, &quot;Country&quot;) %&gt;% round(4)
##              Pooled   Means Between  Within
## (Intercept) 33.2047 34.6626 35.4332  0.0000
## AGR         -0.3543 -0.3767 -0.3873 -0.2762
## MAN         -0.4444 -0.4595 -0.4790 -0.4912
## FIRE        -0.1721 -0.3097 -0.2892 -0.1087</code></pre>
<p>A benchmark below shows that this series of estimators is executed very efficiently and scales nicely to large data (quite a bit faster than using <code>plm</code> to do it).</p>
<pre class="r"><code>VA_shares$weights &lt;- abs(rnorm(fnrow(VA_shares))) # Random weights

# Benchmark on VA data    
microbenchmark(lm.fit = Panel_Ests(SUM ~ AGR + MIN + MAN, VA_shares, &quot;Country&quot;), 
               chol = Panel_Ests(SUM ~ AGR + MIN + MAN, VA_shares, &quot;Country&quot;, method = &quot;chol&quot;),
               lm.fit.w = Panel_Ests(SUM ~ AGR + MIN + MAN, VA_shares, &quot;Country&quot;, &quot;weights&quot;), 
               chol.w = Panel_Ests(SUM ~ AGR + MIN + MAN, VA_shares, &quot;Country&quot;, &quot;weights&quot;, method = &quot;chol&quot;))
## Unit: milliseconds
##      expr      min       lq     mean   median       uq       max neval cld
##    lm.fit 1.490470 1.663838 2.185618 1.942743 2.187956  9.982579   100   a
##      chol 1.469943 1.668300 2.161204 1.903919 2.140430  8.107889   100   a
##  lm.fit.w 1.686819 1.926232 2.469664 2.243068 2.611892 10.030328   100   a
##    chol.w 1.719842 1.935603 2.500210 2.326963 2.647592  7.600058   100   a</code></pre>
<p>There are lots and lots of other applications that can be devised in R using the <code>.FAST_FUN</code> and efficient programming with grouping objects.</p>
<div id="creating-grouped-functions-in-cc" class="section level2">
<h2>Creating Grouped Functions in C/C++</h2>
<p>It is also possible to just use ‘GRP’ objects as input to new grouped functions written in C or C++. Below I use <em>Rcpp</em> to create a generic grouped <code>anyNA</code> function for vectors:</p>
<pre class="cpp"><code>// [[Rcpp::plugins(cpp11)]]
#include &lt;Rcpp.h&gt;
using namespace Rcpp;

// Inputs: 
// x - A vector of any type 
// ng - The number of groups - supplied by GRP() in R
// g - An integer grouping vector - supplied by GRP() in R

// Output: A plain logical vector of size ng

template &lt;int RTYPE&gt;
LogicalVector ganyNACppImpl(Vector&lt;RTYPE&gt; x, int ng, IntegerVector g) {
  int l = x.size();
  if(l != g.size()) stop(&quot;length(x) must match length(g)&quot;);
  LogicalVector out(ng); // Initializes as false

  if(RTYPE == REALSXP) { // Numeric vector: all logical operations on NA/NaN evaluate to false, except != which is true.
    for(int i = 0; i &lt; l; ++i) {
      if(x[i] != x[i] &amp;&amp; !out[g[i]-1]) out[g[i]-1] = true;
    }
  } else { // other vectors
    for(int i = 0; i &lt; l; ++i) {
      if(x[i] == Vector&lt;RTYPE&gt;::get_na() &amp;&amp; !out[g[i]-1]) out[g[i]-1] = true;
    }
  }

  return out;
}

// Disabling complex and non-atomic vector types
template &lt;&gt;
LogicalVector ganyNACppImpl(Vector&lt;CPLXSXP&gt; x, int ng, IntegerVector) {
  stop(&quot;Not supported SEXP type!&quot;);
}

template &lt;&gt;
LogicalVector ganyNACppImpl(Vector&lt;VECSXP&gt; x, int ng, IntegerVector) {
  stop(&quot;Not supported SEXP type!&quot;);
}

template &lt;&gt;
LogicalVector ganyNACppImpl(Vector&lt;RAWSXP&gt; x, int ng, IntegerVector) {
  stop(&quot;Not supported SEXP type!&quot;);
}

template &lt;&gt; 
LogicalVector ganyNACppImpl(Vector&lt;EXPRSXP&gt; x, int ng, IntegerVector) {
  stop(&quot;Not supported SEXP type!&quot;);
}

// [[Rcpp::export]]
LogicalVector ganyNACpp(const SEXP&amp; x, int ng = 0, const IntegerVector&amp; g = 0){
  RCPP_RETURN_VECTOR(ganyNACppImpl, x, ng, g);
}
</code></pre>
<p>On the R side things are then pretty simple:</p>
<pre class="r"><code>library(Rcpp)    
sourceCpp(&quot;ganyNA.cpp&quot;) 
 
ganyNA &lt;- function(x, g, use.g.names = TRUE, ...) { 
  # Option group.sizes = FALSE prevents tabulation of levels if a factor is passed
  g &lt;- GRP(g, return.groups = use.g.names, group.sizes = FALSE, call = FALSE, ...)
  res &lt;- ganyNACpp(x, g[[1L]], g[[2L]]) 
  # GRPnames creates unique group names. For vectors they need not be character typed.
  if(use.g.names) names(res) &lt;- GRPnames(g, force.char = FALSE)
  res
}</code></pre>
<p>Regarding the grouping step, there are actually different options to set this up: <code>GRP()</code> is a S3 generic function with a default method applying to atomic vectors and lists / data frames, but also a ‘factor’ method converting factors to ‘GRP’ objects. Above I have used the generic <code>GRP</code> function with the option <code>group.sizes = FALSE</code>, so factors are efficiently converted without tabulating the levels. This provides more efficiency if a factor is passed to <code>g</code>. Since collapse 1.4.0, the factor method additionally supports efficient dropping of unused factor levels through setting an argument <code>drop = TRUE</code>, which internally calls a C++ function function <code>fdroplevels</code> on the factor. By default <code>drop = FALSE</code> and this default is kept here. The cost of dropping levels on a large factor can be seen in the benchmark below.</p>
<p>While the generic version of <code>GRP()</code> offers a lot of flexibility and faster methods for grouping different objects (for example you can also apply <code>GRP()</code> on a grouped tibble to get at ‘GRP’ object from the attached ‘groups’ attribute), a viable alternative is to just use the default method <code>g &lt;- GRP.default(g, return.groups = use.g.names, call = FALSE)</code>. This will get rid of unused factor levels, but using factors for grouping is just as efficient as any other vector.</p>
<pre class="r"><code>GGDC10S %$% ganyNA(SUM, list(Country, Variable)) %&gt;% head
## ARG.EMP  ARG.VA BOL.EMP  BOL.VA BRA.EMP  BRA.VA 
##   FALSE   FALSE   FALSE    TRUE   FALSE    TRUE
  
# 10 million obs and 1 million groups, 1% of data missing  
x &lt;- na_insert(rnorm(1e7), prop = 0.01)
g &lt;- sample.int(1e6, 1e7, TRUE)
system.time(ganyNA(x, g)) 
##    user  system elapsed 
##    0.82    0.04    0.84
system.time(ganyNA(x, g, use.g.names = FALSE))
##    user  system elapsed 
##    0.57    0.06    0.64

# Using a factor grouping variable: more efficient 
f &lt;- qF(g, na.exclude = FALSE) # Efficiently creating a factor (qF is faster as.factor)
system.time(ganyNA(x, f))
##    user  system elapsed 
##    0.03    0.01    0.04
system.time(ganyNA(x, f, drop = TRUE)) # This also checks for unused levels and drops them (if found)
##    user  system elapsed 
##    0.13    0.03    0.15
# Note: use.g.names = FALSE does not give a performance improvement with factors because unique factor levels are already computed

# We can also efficiently pass a &#39;GRP&#39; object: both GRP.GRP and GRP.default simply return it.
g &lt;- GRP(g)
system.time(ganyNA(x, g))
##    user  system elapsed 
##    0.03    0.00    0.03
system.time(ganyNA(x, g, use.g.names = FALSE))
##    user  system elapsed 
##    0.04    0.00    0.05</code></pre>
<p>We could additionally add a <code>TRA</code> argument and then internally call the <code>TRA()</code> function to allow for replacing and sweeping out statistics, but this does not make much sense here.</p>
</div>
</div>
<div class="footnotes">
<hr />
<ol>
<li id="fn1"><p>By default (<code>sort = TRUE</code>) the grouping is ordered, which is equivalent to <em>data.table</em> grouping with <code>keyby</code>.<a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p><code>add_vars</code> is a faster alternative to <code>cbind</code> and <code>get_vars</code> is a faster alternative to <code>[.data.frame</code> for subsetting columns.<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
<li id="fn3"><p>A random effects estimator could easily be added, see the example <a href="https://sebkrantz.github.io/collapse/reference/fbetween_fwithin.html">here</a>.<a href="#fnref3" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
    <item>
      <title>Introducing collapse: Advanced and Fast Data Transformation in R</title>
      <link>https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/</link>
      <pubDate>Mon, 31 Aug 2020 00:00:00 +0000</pubDate>
      
      <guid>https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/</guid>
      <description><![CDATA[ 
<link href="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.css" rel="stylesheet" />
<script src="/Rblog/rmarkdown-libs/anchor-sections/anchor-sections.js"></script>


<p><img src='collapse_logo_small.png' width="150px" align="right" /></p>
<div id="introduction" class="section level1">
<h1>Introduction</h1>
<p><a href="https://sebkrantz.github.io/collapse/"><em>collapse</em></a> is a C/C++ based package for data transformation and statistical computing in R. It was first released on CRAN end of March 2020. The current version 1.3.1 is a mature piece of statistical software tested with &gt; 7700 unit tests. <em>collapse</em> has 2 main aims:</p>
<ol style="list-style-type: decimal">
<li><p>To facilitate complex data transformation, exploration and computing tasks in R.</p>
<p>
</p>
<p><em>(In particular grouped and weighted statistical computations, advanced aggregation of multi-type data, advanced transformations of time series and panel data, and the manipulation of lists)</em></p></li>
<li><p>To help make R code fast, flexible, parsimonious and programmer friendly.</p>
<p>
</p>
<p><em>(Provide order of magnitude performance improvements via C/C++ and highly optimized R code, broad object orientation and attribute preservation, and a flexible programming infrastructure in standard and non-standard evaluation)</em></p></li>
</ol>
<p>It is made compatible with <em>dplyr</em>, <em>data.table</em> and the <em>plm</em> approach to panel data. It can be installed in R using:</p>
<pre class="r"><code>install.packages(&#39;collapse&#39;)

# See Documentation
help(&#39;collapse-documentation&#39;)</code></pre>
<p>With this post I want to formally and briefly introduce <em>collapse</em>, provide a basic demonstration of important features, and end with a small benchmark comparing <em>collapse</em> to <em>dplyr</em> and <em>data.table</em>. I hope to convince that <em>collapse</em> provides a superior architecture for data manipulation and statistical computing in R, particularly in terms of flexibility, functionality, performance, and programmability.</p>
<p><em>Note:</em> Please read this article <a href="https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/">here</a> for better code appearance.</p>
<!-- My motivation, to but it briefly, for creating this package and expending that -->
<!-- The key features and functions of the package are summarized in the figure below.  -->
<!-- and share some of the motivation and history of it -->
<!-- ![*collapse* Core Functions](collapse header.png) -->
<!-- I start with the motivation (you can skip this if you like). -->
</div>
<div id="demonstration" class="section level1">
<h1>Demonstration</h1>
<p>I start by briefly demonstrating the <em>Fast Statistical Functions</em>, which are a central feature of <em>collapse</em>. Currently there are 14 of them (<code>fmean</code>, <code>fmedian</code>, <code>fmode</code>, <code>fsum</code>, <code>fprod</code>, <code>fsd</code>, <code>fvar</code>, <code>fmin</code>, <code>fmax</code>, <code>fnth</code>, <code>ffirst</code>, <code>flast</code>, <code>fNobs</code> and <code>fNdistinct</code>), they are all S3 generic and support fast grouped and weighted computations on vectors, matrices, data frames, lists and grouped tibbles (class <em>grouped_df</em>). Calling these functions on different objects yields column-wise statistical computations:</p>
<pre class="r"><code>library(collapse)
data(&quot;iris&quot;)            # iris dataset in base R
v &lt;- iris$Sepal.Length  # Vector
d &lt;- num_vars(iris)     # Saving numeric variables 
g &lt;- iris$Species       # Grouping variable (could also be a list of variables)

# Simple statistics
fmean(v)              # Vector
## [1] 5.843333
fsd(qM(d))            # Matrix (qM is a faster as.matrix)
## Sepal.Length  Sepal.Width Petal.Length  Petal.Width 
##    0.8280661    0.4358663    1.7652982    0.7622377
fmode(d)              # Data frame
## Sepal.Length  Sepal.Width Petal.Length  Petal.Width 
##          5.0          3.0          1.5          0.2

# Preserving data structure
fmean(qM(d), drop = FALSE)     # Still a matrix
##      Sepal.Length Sepal.Width Petal.Length Petal.Width
## [1,]     5.843333    3.057333        3.758    1.199333
fmax(d, drop = FALSE)          # Still a data.frame
##   Sepal.Length Sepal.Width Petal.Length Petal.Width
## 1          7.9         4.4          6.9         2.5</code></pre>
<p>The functions <code>fmean</code>, <code>fmedian</code>, <code>fmode</code>, <code>fnth</code>, <code>fsum</code>, <code>fprod</code>, <code>fvar</code> and <code>fsd</code> additionally support weights<a href="#fn1" class="footnote-ref" id="fnref1"><sup>1</sup></a>.</p>
<pre class="r"><code># Weighted statistics, similarly for vectors and matrices ...
wt &lt;- abs(rnorm(fnrow(iris)))
fmedian(d, w = wt)     
## Sepal.Length  Sepal.Width Petal.Length  Petal.Width 
##          5.7          3.0          4.1          1.3</code></pre>
<p>The second argument of these functions is called <code>g</code> and supports vectors or lists of grouping variables for grouped computations. For functions supporting weights, <code>w</code> is the third argument<a href="#fn2" class="footnote-ref" id="fnref2"><sup>2</sup></a>.
<!-- it does not matter anymore on which type of object we are working.   --></p>
<pre class="r"><code># Grouped statistics
fmean(d, g) 
##            Sepal.Length Sepal.Width Petal.Length Petal.Width
## setosa            5.006       3.428        1.462       0.246
## versicolor        5.936       2.770        4.260       1.326
## virginica         6.588       2.974        5.552       2.026

# Groupwise-weighted statistics 
fmean(d, g, wt)
##            Sepal.Length Sepal.Width Petal.Length Petal.Width
## setosa         4.964652    3.389885     1.436666   0.2493647
## versicolor     5.924013    2.814171     4.255227   1.3273743
## virginica      6.630702    2.990253     5.601473   2.0724544

fmode(d, g, wt, ties = &quot;max&quot;)  # Grouped &amp; weighted maximum mode.. 
##            Sepal.Length Sepal.Width Petal.Length Petal.Width
## setosa              5.0           3          1.4         0.2
## versicolor          5.8           3          4.5         1.3
## virginica           6.3           3          5.1         2.3</code></pre>
<p>Grouping becomes more efficient when factors or grouping objects are passed to <code>g</code>. Factors can efficiently be created using the function <code>qF</code>, and grouping objects are efficiently created with the function <code>GRP</code><a href="#fn3" class="footnote-ref" id="fnref3"><sup>3</sup></a>. As a final layer of complexity, all functions support transformations through the <code>TRA</code> argument.</p>
<pre class="r"><code>library(magrittr)  # Pipe operators
# Simple Transformations
fnth(v, 0.9, TRA = &quot;replace&quot;) %&gt;% head   # Replacing values with the 90th percentile
## [1] 6.9 6.9 6.9 6.9 6.9 6.9
fsd(v, TRA = &quot;/&quot;) %&gt;% head               # Dividing by the overall standard-deviation (scaling)
## [1] 6.158928 5.917402 5.675875 5.555112 6.038165 6.521218

# Grouped transformations
fsd(v, g, TRA = &quot;/&quot;) %&gt;% head         # Grouped scaling
## [1] 14.46851 13.90112 13.33372 13.05003 14.18481 15.31960
fmin(v, g, TRA = &quot;-&quot;) %&gt;% head        # Setting the minimum value in each species to 0
## [1] 0.8 0.6 0.4 0.3 0.7 1.1
fsum(v, g, TRA = &quot;%&quot;) %&gt;% head        # Computing percentages
## [1] 2.037555 1.957651 1.877747 1.837795 1.997603 2.157411
ffirst(v, g, TRA = &quot;%%&quot;) %&gt;% head     # Taking modulus of first group-value, etc ...
## [1] 0.0 4.9 4.7 4.6 5.0 0.3

# Grouped and weighted transformations
fmedian(v, g, wt, &quot;-&quot;) %&gt;% head                      # Subtracting weighted group-medians
## [1]  0.1 -0.1 -0.3 -0.4  0.0  0.4
fmode(d, g, wt, &quot;replace&quot;, ties = &quot;min&quot;) %&gt;% head(3) # replace with weighted minimum mode
##   Sepal.Length Sepal.Width Petal.Length Petal.Width
## 1            5           3          1.4         0.2
## 2            5           3          1.4         0.2
## 3            5           3          1.4         0.2</code></pre>
<p>Currently there are 10 different replacing or sweeping operations supported by <code>TRA</code>, see <code>?TRA</code>. <code>TRA</code> can also be called directly as a function which performs simple and grouped replacing and sweeping operations with computed statistics:</p>
<pre class="r"><code># Same as fmedian(v, TRA = &quot;-&quot;)
TRA(v, median(v), &quot;-&quot;) %&gt;% head               
## [1] -0.7 -0.9 -1.1 -1.2 -0.8 -0.4

# Replace values with 5% percentile by species
TRA(d, BY(d, g, quantile, 0.05), &quot;replace&quot;, g) %&gt;% head(3) 
##   Sepal.Length Sepal.Width Petal.Length Petal.Width
## 1          4.4           3          1.2         0.1
## 2          4.4           3          1.2         0.1
## 3          4.4           3          1.2         0.1</code></pre>
<p>The function <code>BY</code> is generic for Split-Apply-Combine computing with user-supplied functions. Another useful function is <code>dapply</code> (data-apply) for efficient column- and row-operations on matrices and data frames.</p>
<!-- I note that simple row-wise operations on data.frames like row-sums are best performed through efficient matrix conversion i.e. `rowSums(qM(d))` is better than `dapply(d, sum, MARGIN = 1)`.    -->
<p>Some common panel data transformations like between- and (quasi-)within-transformations (averaging and centering using the mean) are implemented slightly more memory efficient in the functions <code>fbetween</code> and <code>fwithin</code>. The function <code>fscale</code> also exists for fast (grouped, weighted) scaling and centering (standardizing) and mean-preserving scaling. These functions provide further options for data harmonization, such as centering on the overall data mean or scaling to the within-group standard deviation<a href="#fn4" class="footnote-ref" id="fnref4"><sup>4</sup></a> (as shown below), as well as scaling / centering to arbitrary supplied means and standard deviations.</p>
<pre class="r"><code>oldpar &lt;- par(mfrow = c(1,3))
gv(d, 1:2) %&gt;% {  # gv = shortcut for get_vars is &gt; 2x faster than [.data.frame
plot(., col = g, main = &quot;Raw Data&quot;)                      
plot(fwithin(., g, mean = &quot;overall.mean&quot;), col = g, 
     main = &quot;Centered on Overall Mean&quot;)
plot(fscale(., g, mean = &quot;overall.mean&quot;, sd = &quot;within.sd&quot;), col = g,    
     main = &quot;Harmonized Mean and Variance&quot;)
}</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/figure-html/unnamed-chunk-7-1.png" width="100%" /></p>
<pre class="r"><code>par(oldpar)</code></pre>
<!-- The function `get_vars` is 2x faster than `[.data.frame`, attribute-preserving, and also supports column selection using functions or regular expressions. It's replacement version `get_vars<-` is 6x faster than `[<-.data.frame`. Apart from `fbetween` and `fwithin`, the functions `fHDbetween` and `fHDwithin` can average or center data on multiple groups, and they can also project out continuous variables alongside (i.e. they provide fitted values or residuals from regression problems which may or may not involve one or more factors). -->
<p>For the manipulation of time series and panel series, <em>collapse</em> offers the functions <code>flag</code>, <code>fdiff</code> and <code>fgrowth</code>.</p>
<pre class="r"><code># A sequence of lags and leads
flag(EuStockMarkets, -1:1) %&gt;% head(3)             
##       F1.DAX     DAX  L1.DAX F1.SMI    SMI L1.SMI F1.CAC    CAC L1.CAC F1.FTSE   FTSE L1.FTSE
## [1,] 1613.63 1628.75      NA 1688.5 1678.1     NA 1750.5 1772.8     NA  2460.2 2443.6      NA
## [2,] 1606.51 1613.63 1628.75 1678.6 1688.5 1678.1 1718.0 1750.5 1772.8  2448.2 2460.2  2443.6
## [3,] 1621.04 1606.51 1613.63 1684.1 1678.6 1688.5 1708.1 1718.0 1750.5  2470.4 2448.2  2460.2

# First and second annual difference of SAX and SMI indices (.c is for non-standard concatenation)
EuStockMarkets[, .c(DAX, SMI)] %&gt;% 
  fdiff(0:1 * frequency(.), 1:2) %&gt;% 
  plot(main = c(&quot;DAX and SMI&quot;)) </code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/figure-html/unnamed-chunk-8-1.png" width="100%" /></p>
<!-- I note that all attributes of the time series matrix `EuStockMarkets` were preserved, the use of `head` just suppresses the print method. -->
<!-- At this point I will  -->
<!-- ```{r, eval=FALSE} -->
<!-- library(vars) -->
<!-- library(ggplot2) -->
<!-- library(data.table) # for melt function -->
<!-- frequency(EuStockMarkets) -->
<!-- VARselect(EuStockMarkets, type = "both", season = 260) -->
<!-- varmod <- VAR(EuStockMarkets, p = 7, type = "both", season = 260) -->
<!-- serial.test(varmod) -->
<!-- irf <- irf(varmod) -->
<!-- str(irf) -->
<!-- irfdata <- unlist2d(list_elem(irf), idcols = c("bound", "series"), row.names = "time", -->
<!--                     id.factor = TRUE, DT = TRUE) -->
<!-- head(irfdata) -->
<!-- melt(irfdata, 1:3) %>% ggplot(aes(x = time, y = value, colour = series, shape = bound)) + -->
<!--   geom_line() + facet_wrap("variable") -->
<!-- ``` -->
<p>To facilitate programming and integration with <em>dplyr</em>, all functions introduced so far have a <em>grouped_df</em> method.</p>
<pre class="r"><code>library(dplyr)
iris %&gt;% add_vars(wt) %&gt;%             # Adding weight vector to dataset
  filter(Sepal.Length &lt; fmean(Sepal.Length)) %&gt;% 
  select(Species, Sepal.Width:wt) %&gt;% 
  group_by(Species) %&gt;%               # Frequency-weighted group-variance, default (keep.w = TRUE)  
  fvar(wt) %&gt;% arrange(sum.wt)        # also saves group weights in a column called &#39;sum.wt&#39;
## # A tibble: 3 x 5
##   Species    sum.wt Sepal.Width Petal.Length Petal.Width
##   &lt;fct&gt;       &lt;dbl&gt;       &lt;dbl&gt;        &lt;dbl&gt;       &lt;dbl&gt;
## 1 virginica    3.68      0.0193      0.00993      0.0281
## 2 versicolor  19.2       0.0802      0.181        0.0299
## 3 setosa      43.8       0.142       0.0281       0.0134</code></pre>
<p>Since <em>dplyr</em> operations are rather slow, <em>collapse</em> provides its own set of manipulation verbs yielding significant performance gains.</p>
<pre class="r"><code># Same as above.. executes about 15x faster 
iris %&gt;% add_vars(wt) %&gt;%                    
  fsubset(Sepal.Length &lt; fmean(Sepal.Length), 
          Species, Sepal.Width:wt) %&gt;% 
  fgroup_by(Species) %&gt;%                     
  fvar(wt) %&gt;% roworder(sum.wt)       
## # A tibble: 3 x 5
##   Species    sum.wt Sepal.Width Petal.Length Petal.Width
##   &lt;fct&gt;       &lt;dbl&gt;       &lt;dbl&gt;        &lt;dbl&gt;       &lt;dbl&gt;
## 1 virginica    3.68      0.0193      0.00993      0.0281
## 2 versicolor  19.2       0.0802      0.181        0.0299
## 3 setosa      43.8       0.142       0.0281       0.0134

# Weighted demeaning
iris %&gt;% fgroup_by(Species) %&gt;% num_vars %&gt;% 
  fwithin(wt) %&gt;% head(3)  
## # A tibble: 3 x 4
##   Sepal.Length Sepal.Width Petal.Length Petal.Width
##          &lt;dbl&gt;       &lt;dbl&gt;        &lt;dbl&gt;       &lt;dbl&gt;
## 1       0.135        0.110      -0.0367     -0.0494
## 2      -0.0647      -0.390      -0.0367     -0.0494
## 3      -0.265       -0.190      -0.137      -0.0494

# Generate some additional logical data
settransform(iris, 
  AWMSL = Sepal.Length &gt; fmedian(Sepal.Length, w = wt), 
  AGWMSL = Sepal.Length &gt; fmedian(Sepal.Length, Species, wt, &quot;replace&quot;))

 # Grouped and weighted statistical mode
iris %&gt;% fgroup_by(Species) %&gt;% fmode(wt)
## # A tibble: 3 x 7
##   Species    Sepal.Length Sepal.Width Petal.Length Petal.Width AWMSL AGWMSL
##   &lt;fct&gt;             &lt;dbl&gt;       &lt;dbl&gt;        &lt;dbl&gt;       &lt;dbl&gt; &lt;lgl&gt; &lt;lgl&gt; 
## 1 setosa              5             3          1.4         0.2 FALSE FALSE 
## 2 versicolor          5.8           3          4.5         1.3 TRUE  FALSE 
## 3 virginica           6.3           3          5.1         2.3 TRUE  FALSE</code></pre>
<p>To take things a bit further, let’s consider some multilevel / panel data:</p>
<pre class="r"><code># World Bank World Development Data - supplied with collapse
head(wlddev, 3)
##       country iso3c       date year decade     region     income  OECD PCGDP LIFEEX GINI       ODA
## 1 Afghanistan   AFG 1961-01-01 1960   1960 South Asia Low income FALSE    NA 32.292   NA 114440000
## 2 Afghanistan   AFG 1962-01-01 1961   1960 South Asia Low income FALSE    NA 32.742   NA 233350000
## 3 Afghanistan   AFG 1963-01-01 1962   1960 South Asia Low income FALSE    NA 33.185   NA 114880000</code></pre>
<p>All variables in this data have labels stored in a ‘label’ attribute (the default if you import from STATA / SPSS / SAS with <em>haven</em>). Variable labels can be accessed and set using <code>vlabels</code> and <code>vlabels&lt;-</code>, and viewed together with names and classes using <code>namlab</code>. In general variable labels and other attributes will be preserved in when working with <em>collapse</em>. <em>collapse</em> provides some of the fastest and most advanced summary statistics:</p>
<pre class="r"><code># Fast distinct value count
fNdistinct(wlddev)
## country   iso3c    date    year  decade  region  income    OECD   PCGDP  LIFEEX    GINI     ODA 
##     216     216      59      59       7       7       4       2    8995   10048     363    7564
# Use descr(wlddev) for a detailed description of each variable

# Checking for within-country variation
varying(wlddev, ~ iso3c)
## country    date    year  decade  region  income    OECD   PCGDP  LIFEEX    GINI     ODA 
##   FALSE    TRUE    TRUE    TRUE   FALSE   FALSE   FALSE    TRUE    TRUE    TRUE    TRUE

# Panel data statistics: Summarize GDP and GINI overall, between and within countries
qsu(wlddev, pid = PCGDP + GINI ~ iso3c, 
    vlabels = TRUE, higher = TRUE)
## , , PCGDP: GDP per capita (constant 2010 US$)
## 
##              N/T        Mean          SD          Min         Max    Skew     Kurt
## Overall     8995  11563.6529  18348.4052     131.6464   191586.64  3.1121  16.9585
## Between      203  12488.8577  19628.3668     255.3999  141165.083   3.214  17.2533
## Within   44.3103  11563.6529   6334.9523  -30529.0928   75348.067   0.696  17.0534
## 
## , , GINI: GINI index (World Bank estimate)
## 
##             N/T     Mean      SD      Min      Max    Skew    Kurt
## Overall    1356  39.3976  9.6764     16.2     65.8  0.4613  2.2932
## Between     161  39.5799  8.3679  23.3667  61.7143  0.5169  2.6715
## Within   8.4224  39.3976  3.0406  23.9576  54.7976  0.1421  5.7781

# Panel data ACF: Efficient grouped standardizing and computing covariance with panel-lags
psacf(wlddev, ~ iso3c, ~ year, cols = 9:12)</code></pre>
<p><img src="https://sebkrantz.github.io/Rblog/2020/08/31/welcome-to-collapse/figure-html/unnamed-chunk-12-1.png" width="100%" />
<!--
For fast grouped statistics we can keep programming in standard evaluation as before, or we can use piped expressions. 


```r
head3 <- function(x) head(x, 3L)
head3(fmean(get_vars(wlddev, 9:12), 
            get_vars(wlddev, c("region", "income"))))
##                                             PCGDP   LIFEEX     GINI       ODA
## East Asia & Pacific.High income         26042.280 73.22799 32.80000 177672692
## East Asia & Pacific.Lower middle income  1621.178 58.83796 36.21081 503484782
## East Asia & Pacific.Upper middle income  3432.559 66.41750 42.29524 242080501

`%>%` <- magrittr::`%>%` 
wlddev %>% fgroup_by(region, income) %>% 
  fselect(PCGDP:ODA) %>% fmean %>% head3
## # A tibble: 3 x 6
##   region              income               PCGDP LIFEEX  GINI        ODA
##   <fct>               <fct>                <dbl>  <dbl> <dbl>      <dbl>
## 1 East Asia & Pacific High income         26042.   73.2  32.8 177672692.
## 2 East Asia & Pacific Lower middle income  1621.   58.8  36.2 503484782.
## 3 East Asia & Pacific Upper middle income  3433.   66.4  42.3 242080501.
```

I note that the default is `na.rm = TRUE` for all *collapse* functions^[Missing values are efficiently skipped at C++ level with hardly any computational cost. This also pertains to missing values occurring in the weight vector. If `na.rm = FALSE`, execution will stop when a missing value is encountered, and `NA` is returned. This also speeds up computations compared to base R, particularly if some columns or some groups have missing values and others not. The fast functions also avoid `NaN`'s being created from computations involving `NA` values, and functions like `fsum` are well behaved (i.e. `fsum(NA)` gives `NA`, not `0` like `sum(NA, na.rm = TRUE)`, similarly for `fmin` and `fmax`).]  I also note that you can also use `dplyr::group_by` and `dplyr::select`, but `fgroup_by` and `fselect` are significantly faster (see benchmark). We can do a weighted aggregation using the variable `ODA` as weights using:


```r
# Weighted group mean: Weighted by ODA
wlddev %>% fgroup_by(region, income) %>% 
  fselect(PCGDP:ODA) %>% fmean(ODA) %>% head3
## # A tibble: 3 x 6
##   region              income                   sum.ODA PCGDP LIFEEX  GINI
##   <fct>               <fct>                      <dbl> <dbl>  <dbl> <dbl>
## 1 East Asia & Pacific High income          64672860000 2332.   64.6  NA  
## 2 East Asia & Pacific Lower middle income 346397530000 1411.   62.5  36.2
## 3 East Asia & Pacific Upper middle income 106273340000 1707.   68.8  44.6
```

Note that in this case by default (`keep.w = TRUE`) the sum of the weights is also computed and saved. 
--></p>
<p><em>collapse</em> also has its own very flexible data aggregation command called <code>collap</code>, providing fast and easy multi-data-type, multi-function, weighted, parallelized and fully customized data aggregation.</p>
<pre class="r"><code># Applying the mean to numeric and the mode to categorical data (first 2 arguments are &#39;by&#39; and &#39;FUN&#39;)
collap(wlddev, ~ iso3c + decade, fmean, 
       catFUN = fmode) %&gt;% head(3)
##   country iso3c       date   year decade                     region      income  OECD PCGDP   LIFEEX
## 1   Aruba   ABW 1961-01-01 1962.5   1960 Latin America &amp; Caribbean  High income FALSE    NA 66.58583
## 2   Aruba   ABW 1967-01-01 1970.0   1970 Latin America &amp; Caribbean  High income FALSE    NA 69.14178
## 3   Aruba   ABW 1976-01-01 1980.0   1980 Latin America &amp; Caribbean  High income FALSE    NA 72.17600
##   GINI      ODA
## 1   NA       NA
## 2   NA       NA
## 3   NA 33630000

# Same as a piped call.. 
wlddev %&gt;% fgroup_by(iso3c, decade) %&gt;% 
  collapg(fmean, fmode) %&gt;% head(3)
## # A tibble: 3 x 12
##   iso3c decade country date        year region             income    OECD  PCGDP LIFEEX  GINI     ODA
##   &lt;fct&gt;  &lt;dbl&gt; &lt;chr&gt;   &lt;date&gt;     &lt;dbl&gt; &lt;fct&gt;              &lt;fct&gt;     &lt;lgl&gt; &lt;dbl&gt;  &lt;dbl&gt; &lt;dbl&gt;   &lt;dbl&gt;
## 1 ABW     1960 Aruba   1961-01-01 1962. &quot;Latin America &amp; ~ High inc~ FALSE    NA   66.6    NA NA     
## 2 ABW     1970 Aruba   1967-01-01 1970  &quot;Latin America &amp; ~ High inc~ FALSE    NA   69.1    NA NA     
## 3 ABW     1980 Aruba   1976-01-01 1980  &quot;Latin America &amp; ~ High inc~ FALSE    NA   72.2    NA  3.36e7

# Same thing done manually... without column reordering 
wlddev %&gt;% fgroup_by(iso3c, decade) %&gt;% {
  add_vars(fmode(cat_vars(.)),  # cat_vars selects non-numeric (categorical) columns
           fmean(num_vars(.), keep.group_vars = FALSE)) 
} %&gt;% head(3)
## # A tibble: 3 x 12
##   iso3c decade country date       region             income    OECD   year PCGDP LIFEEX  GINI     ODA
##   &lt;fct&gt;  &lt;dbl&gt; &lt;chr&gt;   &lt;date&gt;     &lt;fct&gt;              &lt;fct&gt;     &lt;lgl&gt; &lt;dbl&gt; &lt;dbl&gt;  &lt;dbl&gt; &lt;dbl&gt;   &lt;dbl&gt;
## 1 ABW     1960 Aruba   1961-01-01 &quot;Latin America &amp; ~ High inc~ FALSE 1962.    NA   66.6    NA NA     
## 2 ABW     1970 Aruba   1967-01-01 &quot;Latin America &amp; ~ High inc~ FALSE 1970     NA   69.1    NA NA     
## 3 ABW     1980 Aruba   1976-01-01 &quot;Latin America &amp; ~ High inc~ FALSE 1980     NA   72.2    NA  3.36e7

# Adding weights: weighted mean and weighted mode (catFUN is 3rd argument) 
wlddev$weights &lt;- abs(rnorm(fnrow(wlddev)))
collap(wlddev, ~ iso3c + decade, fmean, fmode, # weights are also aggregated using sum
       w = ~ weights, wFUN = fsum) %&gt;% head(3)
##   country iso3c       date     year decade                     region      income  OECD PCGDP
## 1   Aruba   ABW 1965-01-01 1963.375   1960 Latin America &amp; Caribbean  High income FALSE    NA
## 2   Aruba   ABW 1967-01-01 1969.179   1970 Latin America &amp; Caribbean  High income FALSE    NA
## 3   Aruba   ABW 1980-01-01 1980.443   1980 Latin America &amp; Caribbean  High income FALSE    NA
##     LIFEEX GINI      ODA  weights
## 1 66.87902   NA       NA 4.527996
## 2 68.85522   NA       NA 7.314234
## 3 72.29649   NA 33630000 6.525710

# Can also apply multiple functions to columns, return in wide or long format or as list of data frames 
collap(wlddev, PCGDP + LIFEEX ~ region + income, 
       list(fmean, fsd, fmin, fmax), return = &quot;long&quot;) %&gt;% head(3)
##   Function              region              income     PCGDP   LIFEEX
## 1    fmean East Asia &amp; Pacific         High income 26042.280 73.22799
## 2    fmean East Asia &amp; Pacific Lower middle income  1621.178 58.83796
## 3    fmean East Asia &amp; Pacific Upper middle income  3432.559 66.41750</code></pre>
<p>The default (<code>keep.col.order = TRUE</code>) ensures that the data remains in the same order, and, when working with <em>Fast Statistical Functions</em>, all column attributes are preserved. When aggregating with multiple functions, you can parallelize over them (internally done with <code>parallel::mclapply</code>).</p>
<!-- It is also possible to provide fully customized calls: -->
<!-- ```{r} -->
<!-- # Fully custom aggregation (also possible with weights) -->
<!-- collapv(wlddev, c("iso3c", "decade"), # collapv allows names or indices of grouping variables -->
<!--         custom = list(fmean = 9:12,  -->
<!--                       fmax = 9:10,  -->
<!--                       flast = cat_vars(wlddev, "indices"), -->
<!--                       fmode = "GINI"), keep.col.order = FALSE) %>% head(3) -->
<!-- ``` -->
<p>Time computations on panel data are also simple and computationally very fast.</p>
<pre class="r"><code># Panel Lag and lead of PCGDP and LIFEEX
L(wlddev, -1:1, PCGDP + LIFEEX ~ iso3c, ~year) %&gt;% head3
##   iso3c year F1.PCGDP PCGDP L1.PCGDP F1.LIFEEX LIFEEX L1.LIFEEX
## 1   AFG 1960       NA    NA       NA    32.742 32.292        NA
## 2   AFG 1961       NA    NA       NA    33.185 32.742    32.292
## 3   AFG 1962       NA    NA       NA    33.624 33.185    32.742

# Equivalent piped call
wlddev %&gt;% fgroup_by(iso3c) %&gt;% 
  fselect(iso3c, year, PCGDP, LIFEEX) %&gt;% 
  flag(-1:1, year) %&gt;% head(3)
## # A tibble: 3 x 8
##   iso3c  year F1.PCGDP PCGDP L1.PCGDP F1.LIFEEX LIFEEX L1.LIFEEX
##   &lt;fct&gt; &lt;int&gt;    &lt;dbl&gt; &lt;dbl&gt;    &lt;dbl&gt;     &lt;dbl&gt;  &lt;dbl&gt;     &lt;dbl&gt;
## 1 AFG    1960       NA    NA       NA      32.7   32.3      NA  
## 2 AFG    1961       NA    NA       NA      33.2   32.7      32.3
## 3 AFG    1962       NA    NA       NA      33.6   33.2      32.7

# Or using plm classes for panel data
pwlddev &lt;- plm::pdata.frame(wlddev, index = .c(iso3c, year))
L(pwlddev, -1:1, cols = .c(PCGDP, LIFEEX)) %&gt;% head(3)
##          iso3c year F1.PCGDP PCGDP L1.PCGDP F1.LIFEEX LIFEEX L1.LIFEEX
## ABW-1960   ABW 1960       NA    NA       NA    66.074 65.662        NA
## ABW-1961   ABW 1961       NA    NA       NA    66.444 66.074    65.662
## ABW-1962   ABW 1962       NA    NA       NA    66.787 66.444    66.074

# Growth rates in percentage terms: 1 and 10-year
G(pwlddev, c(1, 10), cols = 9:12) %&gt;% head(3) # or use Dlog, or G(..., logdiff = TRUE) for percentages
##          iso3c year G1.PCGDP L10G1.PCGDP G1.LIFEEX L10G1.LIFEEX G1.GINI L10G1.GINI G1.ODA L10G1.ODA
## ABW-1960   ABW 1960       NA          NA        NA           NA      NA         NA     NA        NA
## ABW-1961   ABW 1961       NA          NA 0.6274558           NA      NA         NA     NA        NA
## ABW-1962   ABW 1962       NA          NA 0.5599782           NA      NA         NA     NA        NA</code></pre>
<p>Equivalently we can can compute lagged / leaded and suitably iterated (log-) differences, as well as quasi-(log-)differences of the form <span class="math inline">\(x_t - \rho x_{t-1}\)</span>. The operators <code>L</code>, <code>D</code>, <code>Dlog</code> and <code>G</code> are shorthand’s for the functions <code>flag</code>, <code>fdiff</code> and <code>fgrowth</code> allowing formula input. Similar operators exist for <code>fwithin</code>, <code>fscale</code>, etc. which also support <em>plm</em> classes.</p>
<p>This short demonstration illustrated some basic features of <em>collapse</em>. A more complete overview of the package is provided in the <a href="https://sebkrantz.github.io/collapse/reference/index.html">documentation</a> and the <a href="https://sebkrantz.github.io/collapse/articles/index.html">vignettes</a>.</p>
</div>
<div id="benchmark" class="section level1">
<h1>Benchmark</h1>
<p>For benchmarking I use some product-level trade data from the UN Comtrade database, processed by <a href="https://tradestatistics.io/">tadestatistics.io</a>.</p>
<pre class="r"><code>library(tradestatistics)
# US HS4-level trade from 2000 to 2018
us_trade &lt;- ots_create_tidy_data(years = 2000:2018,
                                 reporters = &quot;usa&quot;,
                                 table = &quot;yrpc&quot;)</code></pre>
<p>Downloading US product-level trade (HS4) from 2000 to 2018 gives about 2.6 million observations:</p>
<pre class="r"><code>fdim(us_trade)
## [1] 2569787      16
head(us_trade, 1)
##    year reporter_iso                                                   reporter_fullname_english
## 1: 2017          usa USA, Puerto Rico and US Virgin Islands (excludes Virgin Islands until 1981)
##    partner_iso partner_fullname_english section_code section_color section_shortname_english
## 1:         afg              Afghanistan           01       #74c0e2           Animal Products
##         section_fullname_english group_code group_fullname_english product_code
## 1: Live Animals; Animal Products         01          Animals; live         0101
##    product_shortname_english               product_fullname_english export_value_usd
## 1:                    Horses Horses, asses, mules and hinnies; live             3005
##    import_value_usd
## 1:               NA

# 19 years, 221 trading partners, 1222 products, unbalanced panel with product-time gaps...
fNdistinct(us_trade)
##                      year              reporter_iso reporter_fullname_english 
##                        19                         1                         1 
##               partner_iso  partner_fullname_english              section_code 
##                       221                       221                        22 
##             section_color section_shortname_english  section_fullname_english 
##                        22                        22                        22 
##                group_code    group_fullname_english              product_code 
##                        97                        97                      1222 
## product_shortname_english  product_fullname_english          export_value_usd 
##                      1217                      1222                   1081492 
##          import_value_usd 
##                    684781

# Summarizing data between and within partner-product pairs
qsu(us_trade, pid = export_value_usd + import_value_usd ~ partner_iso + product_code)
## , , export_value_usd
## 
##               N/T         Mean           SD              Min             Max
## Overall  2,450301  11,054800.6   157,295999                1  2.83030606e+10
## Between    205513  7,268011.31   118,709845                1  1.66436161e+10
## Within    11.9229  11,054800.6  68,344396.5  -1.01599067e+10  1.67185229e+10
## 
## , , import_value_usd
## 
##               N/T         Mean          SD              Min             Max
## Overall  1,248201  31,421502.4  505,644905                1  8.51970855e+10
## Between    130114  16,250758.2  328,538895                1  4.36545695e+10
## Within     9.5931  31,421502.4  212,076350  -3.32316111e+10  4.15739375e+10</code></pre>
<p>It would also be interesting to summarize the trade flows for each partner, but that would be too large to print to the console. We can however get the <code>qsu</code> output as a list of matrices:</p>
<pre class="r"><code># Doing all of that by partner - variance of flows between and within traded products for each partner
l &lt;- qsu(us_trade, 
         by = export_value_usd + import_value_usd ~ partner_iso,
         pid = ~ partner_iso + product_code, array = FALSE)
str(l, give.attr = FALSE)
## List of 2
##  $ export_value_usd:List of 3
##   ..$ Overall: &#39;qsu&#39; num [1:221, 1:5] 7250 12427 6692 5941 4017 ...
##   ..$ Between: &#39;qsu&#39; num [1:221, 1:5] 901 1151 872 903 695 ...
##   ..$ Within : &#39;qsu&#39; num [1:221, 1:5] 8.05 10.8 7.67 6.58 5.78 ...
##  $ import_value_usd:List of 3
##   ..$ Overall: &#39;qsu&#39; num [1:221, 1:5] 1157 1547 361 1512 685 ...
##   ..$ Between: &#39;qsu&#39; num [1:221, 1:5] 312 532 167 347 235 ...
##   ..$ Within : &#39;qsu&#39; num [1:221, 1:5] 3.71 2.91 2.16 4.36 2.91 ...</code></pre>
<p>Now with the function <code>unlist2d</code>, we can efficiently turn this into a tidy data frame:</p>
<pre class="r"><code>unlist2d(l, idcols = c(&quot;Variable&quot;, &quot;Trans&quot;),
         row.names = &quot;Partner&quot;, DT = TRUE) %&gt;% head(3)
##            Variable   Trans Partner     N      Mean         SD  Min        Max
## 1: export_value_usd Overall     afg  7250 2170074.0 21176449.3   56 1115125722
## 2: export_value_usd Overall     ago 12427 2188174.6 17158413.8    1  687323408
## 3: export_value_usd Overall     aia  6692  125729.3   586862.2 2503   17698445</code></pre>
<p>If <code>l</code> were some statistical object we could first pull out relevant elements using <code>get_elem</code>, possibly process those elements using <code>rapply2d</code> and then apply <code>unlist2d</code> to get the data frame (or data.table with <code>DT = TRUE</code>). These are the main <em>collapse</em> list-processing functions.</p>
<p>Now on to the benchmark. It is run on a Windows 8.1 laptop with a 2x 2.2 GHZ Intel i5 processor, 8GB DDR3 RAM and a Samsung 850 EVO SSD hard drive.</p>
<pre class="r"><code>library(microbenchmark)
library(dplyr)
library(data.table) # Default for this machine is 2 threads

# Grouping (data.table:::forderv does not compute the unique groups yet)
microbenchmark(collapse = fgroup_by(us_trade, partner_iso, group_code, year),
               data.table = data.table:::forderv(us_trade, c(&quot;partner_iso&quot;, &quot;group_code&quot;, &quot;year&quot;), retGrp = TRUE),
               dplyr = group_by(us_trade, partner_iso, group_code, year), times = 10)
## Unit: milliseconds
##        expr       min        lq      mean    median        uq        max neval cld
##    collapse  97.23371  98.32389  123.7255  107.0449  135.9751   239.6009    10   a
##  data.table 102.88811 107.66742 1267.0891  111.4340  118.1944 11658.7908    10   a
##       dplyr 924.67524 979.54759 1042.8808 1008.5981 1078.1861  1354.4466    10   a

# Sum
microbenchmark(collapse = collap(us_trade, export_value_usd + import_value_usd ~ partner_iso + group_code + year, fsum),
               data.table = us_trade[, list(export_value_usd = sum(export_value_usd, na.rm = TRUE),
                                            import_value_usd = sum(import_value_usd, na.rm = TRUE)),
                                     by = c(&quot;partner_iso&quot;, &quot;group_code&quot;, &quot;year&quot;)],
               dplyr = group_by(us_trade, partner_iso, group_code, year) %&gt;%
                 dplyr::select(export_value_usd, import_value_usd) %&gt;% summarise_all(sum, na.rm = TRUE), times = 10)
## Unit: milliseconds
##        expr       min        lq      mean    median        uq       max neval cld
##    collapse  107.1917  116.5861  128.3973  118.9365  150.3367  161.3135    10  a 
##  data.table  169.3562  182.7400  198.2929  189.3267  221.8608  226.7869    10  a 
##       dplyr 2332.0459 2426.7485 2652.3324 2568.5637 2791.4008 3263.4077    10   b

# Mean
microbenchmark(collapse = collap(us_trade, export_value_usd + import_value_usd ~ partner_iso + group_code + year, fmean),
               data.table = us_trade[, list(export_value_usd = mean(export_value_usd, na.rm = TRUE),
                                            import_value_usd = mean(import_value_usd, na.rm = TRUE)),
                                     by = c(&quot;partner_iso&quot;, &quot;group_code&quot;, &quot;year&quot;)],
               dplyr = group_by(us_trade, partner_iso, group_code, year) %&gt;%
                 dplyr::select(export_value_usd, import_value_usd) %&gt;% summarise_all(mean, na.rm = TRUE), times = 10)
## Unit: milliseconds
##        expr       min        lq      mean    median        uq       max neval cld
##    collapse  121.3204  125.5664  142.8944  132.4904  137.1090  214.3933    10  a 
##  data.table  177.7091  187.3493  210.5166  201.0806  224.2513  269.5401    10  a 
##       dplyr 6303.0662 7037.0073 7270.0693 7242.0813 7872.5290 8066.6510    10   b

# Variance
microbenchmark(collapse = collap(us_trade, export_value_usd + import_value_usd ~ partner_iso + group_code + year, fvar),
               data.table = us_trade[, list(export_value_usd = var(export_value_usd, na.rm = TRUE),
                                            import_value_usd = var(import_value_usd, na.rm = TRUE)),
                                     by = c(&quot;partner_iso&quot;, &quot;group_code&quot;, &quot;year&quot;)],
               dplyr = group_by(us_trade, partner_iso, group_code, year) %&gt;%
                 dplyr::select(export_value_usd, import_value_usd) %&gt;% summarise_all(var, na.rm = TRUE), times = 10)
## Unit: milliseconds
##        expr        min         lq       mean     median         uq        max neval cld
##    collapse   123.8475   126.6842   135.6485   134.5366   142.3431   153.3176    10  a 
##  data.table   269.8578   284.1863   300.0291   287.6637   300.2628   365.7684    10  a 
##       dplyr 10408.3787 10815.5928 11298.6246 11225.9573 11726.4892 12275.4899    10   b

# Mode (forget trying to do this with dplyr or data.table using some mode function created in base R, it runs forever...)
microbenchmark(collapse = fgroup_by(us_trade, partner_iso, group_code, year) %&gt;% 
                 fselect(export_value_usd, import_value_usd) %&gt;% fmode, times = 10)
## Unit: milliseconds
##      expr      min       lq     mean  median       uq     max neval
##  collapse 413.1148 430.7063 451.1648 443.147 459.1362 525.748    10

# Weighted Mean (not easily done with dplyr)
settransform(us_trade, weights = abs(rnorm(length(year))))
microbenchmark(collapse = collap(us_trade, export_value_usd + import_value_usd ~ partner_iso + group_code + year, fmean, w = ~ weights, keep.w = FALSE),
               data.table = us_trade[, list(export_value_usd = weighted.mean(export_value_usd, weights, na.rm = TRUE),
                                            import_value_usd = weighted.mean(import_value_usd, weights, na.rm = TRUE)),
                                     by = c(&quot;partner_iso&quot;, &quot;group_code&quot;, &quot;year&quot;)], times = 10)
## Unit: milliseconds
##        expr       min        lq      mean    median        uq       max neval cld
##    collapse  114.2594  115.2599  118.7735  118.5715  122.2374  124.0246    10  a 
##  data.table 5808.7005 5867.3619 5957.9288 5904.3373 5963.8040 6507.7393    10   b


# Replace values with group-sum
microbenchmark(collapse = fgroup_by(us_trade, partner_iso, group_code, year) %&gt;%
                 fselect(export_value_usd, import_value_usd) %&gt;% fsum(TRA = &quot;replace_fill&quot;),
               data.table = us_trade[, `:=`(export_value_usd2 = sum(export_value_usd, na.rm = TRUE),
                                            import_value_usd2 = sum(import_value_usd, na.rm = TRUE)),
                                     by = c(&quot;partner_iso&quot;, &quot;group_code&quot;, &quot;year&quot;)],
               dplyr = group_by(us_trade, partner_iso, group_code, year) %&gt;%
                 dplyr::select(export_value_usd, import_value_usd) %&gt;% mutate_all(sum, na.rm = TRUE), times = 10)
## Unit: milliseconds
##        expr       min        lq      mean    median        uq       max neval cld
##    collapse  128.8186  139.7133  154.8318  153.3190  167.7827  189.6136    10 a  
##  data.table  823.7694  849.4982  861.1539  853.3544  869.0394  917.6767    10  b 
##       dplyr 2464.5943 2653.9227 2812.6778 2772.3155 2884.5482 3335.0273    10   c

# Centering, partner-product
microbenchmark(collapse = fgroup_by(us_trade, partner_iso, product_code) %&gt;%
                 fselect(export_value_usd, import_value_usd) %&gt;% fwithin,
               data.table = us_trade[, `:=`(export_value_usd2 = export_value_usd - mean(export_value_usd, na.rm = TRUE),
                                            import_value_usd2 = import_value_usd - mean(import_value_usd, na.rm = TRUE)),
                                     by = c(&quot;partner_iso&quot;, &quot;group_code&quot;, &quot;year&quot;)],
               dplyr = group_by(us_trade, partner_iso, group_code, year) %&gt;%
                 dplyr::select(export_value_usd, import_value_usd) %&gt;% mutate_all(function(x) x - mean(x, na.rm = TRUE)), times = 10)
## Unit: milliseconds
##        expr       min         lq       mean     median        uq       max neval cld
##    collapse   80.1893   88.50289   96.74396   96.62168  105.6455  109.0914    10 a  
##  data.table 4537.3741 4578.44447 4788.89139 4816.80366 4876.4886 5071.2853    10  b 
##       dplyr 6822.4752 7190.71493 7388.08024 7372.80011 7706.3514 7902.9720    10   c

# Lag
# Much better to sort data for dplyr
setorder(us_trade, partner_iso, product_code, year)
# We have an additional problem here: There are time-gaps within some partner-product pairs
tryCatch(L(us_trade, 1, export_value_usd + import_value_usd ~ partner_iso + product_code, ~ year),
         error = function(e) e)
## &lt;Rcpp::exception in L.data.frame(us_trade, 1, export_value_usd + import_value_usd ~     partner_iso + product_code, ~year): Gaps in timevar within one or more groups&gt;
# The solution is that we create a unique id for each continuous partner-product sequence
settransform(us_trade, id = seqid(year + unattrib(finteraction(partner_iso, product_code)) * 20L))
# Notes: Normally id = seqid(year) would be enough on sorted data, but here we also have very different start and end dates, with the potential of overlaps...
fNdistinct(us_trade$id)
## [1] 423884
# Another comparison..
microbenchmark(fNdistinct(us_trade$id), n_distinct(us_trade$id))
## Unit: milliseconds
##                     expr      min       lq     mean   median       uq       max neval cld
##  fNdistinct(us_trade$id) 26.46380 26.84445 27.91192 27.25768 28.42417  37.60704   100  a 
##  n_distinct(us_trade$id) 56.70598 64.67573 69.11154 65.50040 68.60963 145.39718   100   b

# Here we go now:
microbenchmark(collapse = L(us_trade, 1, export_value_usd + import_value_usd ~ id),
               collapse_ordered = L(us_trade, 1, export_value_usd + import_value_usd ~ id, ~ year),
               data.table = us_trade[, shift(.SD), keyby = id,
                                     .SDcols = c(&quot;export_value_usd&quot;,&quot;import_value_usd&quot;)],
               data.table_ordered = us_trade[order(year), shift(.SD), keyby = id,
                                             .SDcols = c(&quot;export_value_usd&quot;,&quot;import_value_usd&quot;)],
               dplyr = group_by(us_trade, id) %&gt;% dplyr::select(export_value_usd, import_value_usd) %&gt;%
                 mutate_all(lag), times = 10)
## Unit: milliseconds
##                expr         min          lq        mean      median          uq         max neval
##            collapse    18.57103    29.47686    34.75352    34.34208    39.30635    55.87373    10
##    collapse_ordered    51.26266    57.90416    67.40628    66.86212    73.63482    94.11623    10
##          data.table  7594.63120  7820.26944  7968.29873  7879.71628  8266.03880  8353.21097    10
##  data.table_ordered  7622.66044  7635.12055  8090.12031  7726.25161  8492.04252  9428.44336    10
##               dplyr 32428.73832 32583.82844 33584.48046 32903.50014 34725.72392 36189.46410    10
##  cld
##  a  
##  a  
##   b 
##   b 
##    c

# Note: you can do ordered lags using mutate_all(lag, order_by = &quot;year&quot;) for dplyr, but at computation times in excess of 90 seconds..
</code></pre>
<p>The benchmarks show that <em>collapse</em> is consistently very fast. More extensive benchmarks against <em>dplyr</em> and <em>plm</em> are provided in the corresponding <a href="https://sebkrantz.github.io/collapse/articles/index.html">vignettes</a>.</p>
<!-- But of course *collapse* cannot do a lot of things you can do with *dplyr* or *data.table* and vice-versa. It is and remains an advanced package, but I think it lives up to the high standards set forth by these packages. I am also highly indebted to *data.table* for inspiration and some vital bits of C-code. Feel free to get in touch for any suggestions or comments about *collapse*. I hope you will find it useful. -->
</div>
<div class="footnotes">
<hr />
<ol>
<li id="fn1"><p><code>fvar</code> and <code>fsd</code> compute frequency weights, the most common form of weighted sample variance. <a href="#fnref1" class="footnote-back">↩︎</a></p></li>
<li id="fn2"><p>I note that all further examples generalize to different objects (vectors, matrices, data frames).<a href="#fnref2" class="footnote-back">↩︎</a></p></li>
<li id="fn3"><p>Grouping objects are better for programming and for multiple grouping variables. This is demonstrated in the blog post on programming with <em>collapse</em>.<a href="#fnref3" class="footnote-back">↩︎</a></p></li>
<li id="fn4"><p>The within-group standard deviation is the standard deviation computed on the group-centered data.<a href="#fnref4" class="footnote-back">↩︎</a></p></li>
</ol>
</div>
]]></description>
    </item>
    
  </channel>
</rss>
