--- title: "Constructing Time Series Networks" author: - name: "Mohammed Saqr" affiliation: "University of Eastern Finland" - name: "Sonsoles López-Pernas" affiliation: "University of Eastern Finland" - name: "Manuel J. Gómez" affiliation: "University of Murcia" output: rmarkdown::html_vignette: toc: true number_sections: true vignette: > %\VignetteIndexEntry{Constructing Time Series Networks} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.align = "center", fig.width = 7, fig.height = 4.5, dpi = 110, out.width = "100%" ) library(tsn) has_cograph <- requireNamespace("cograph", quietly = TRUE) && requireNamespace("igraph", quietly = TRUE) has_nestimate <- requireNamespace("Nestimate", quietly = TRUE) ``` # Introduction Time series analysis traditionally represents temporal data as observations indexed by time, with attention directed to quantities such as level, variation, dependence, and change. A network representation offers a complementary view: it replaces, or augments, the explicit time index with a relational structure in which nodes stand for observations, temporal states, or local windows, and edges encode a defined relationship between them. That relationship may be geometric visibility, numerical similarity, a state transition, a co-occurrence, or a weighted dependence. A time series therefore does not have a single canonical network. The network that results is determined jointly by the chosen representation and by the structural question that representation is designed to answer. The **tsn** package provides a set of these representations while keeping the modelling choices explicit at every step. `discretize()` maps continuous observations onto a finite set of ordered states and so yields a symbolic version of the series. `trend()` characterizes local direction and separates short-range increase, decrease, stability, and turbulence. `vg()` constructs a visibility graph, in which observations are joined when the geometry of the series makes them mutually visible. `tsn()` is the general distance-network constructor over complete series, local windows, or individual observations; this vignette applies it to overlapping windows joined by their similarity. Once a series has been reduced to a sequence of states, its dynamics can be examined through transition network analysis. `ts_tna()` builds a transition network whose edges carry conditional transition probabilities. `ts_ftna()` builds the corresponding frequency network, `ts_cna()` a co-occurrence network, and `ts_atna()` an attention-weighted transition network. These four constructions summarize different properties of the same symbolic sequence and are complementary rather than interchangeable. Finally, `series_networks()` extracts and indexes the network model attached to each source series, retaining the model type, the shared state alphabet, and the provenance needed to trace a network back to its data. Every representation in this vignette is developed on one fixed segment of one measured variable, so that the constructions can be compared without any change in the underlying data. Network figures are produced by the package's `plot()` methods through **cograph**, and the four transition-network constructors require the **Nestimate** package; both are optional and used only where noted. | Scientific question | Function | Result | What it captures | |---|---|---|---| | **How can a continuous series be represented as a sequence of discrete states?** | `discretize()` | Tidy state table | Maps continuous observations onto a finite, ordered state alphabet, making recurring levels and ranges explicit. | | **How does the series change locally over time?** | `trend()` | Tidy trend table | Labels each observation by its local direction: increase, decrease, stability, or turbulence. | | **Which observations are connected by geometric visibility?** | `vg()` | Visibility network | Connects observations that can "see" each other over the intervening values. | | **Which time windows are similar in value or shape?** | `tsn()` | Distance network | Relates local windows by their proximity, exposing repeated or structurally similar segments. | | **How likely is one state to be followed by another?** | `ts_tna()` | Transition network (TNA) | Weights directed edges by the conditional probability of each successive transition. | | **How often does the series move from one state to another?** | `ts_ftna()` | Frequency network (FTNA) | Weights directed edges by the observed count of each successive transition. | | **Which states co-occur within the sequence?** | `ts_cna()` | Co-occurrence network (CNA) | Uses undirected edges to represent state co-occurrence, ignoring direction. | | **Which transitions receive the greatest attention weight?** | `ts_atna()` | Attention-weighted network (ATNA) | Weights directed edges by model-assigned attention rather than raw counts. | | **How is each network associated with its source series?** | `series_networks()` | Indexed model collection | Organizes extracted models by source series while preserving their correspondence and provenance. | In short, `discretize()` defines the **states**, `trend()` describes **local direction**, `vg()` describes **geometric visibility**, and `tsn()` describes **similarity between temporal windows**. The `ts_tna()` family then builds transition networks from the state sequence, differing only in the quantity assigned to their edges, and `series_networks()` indexes the results by source series. # Data The `esm_srl` data set bundled with the package is an example of experience-sampling, or intensive longitudinal, data: 41 students were prompted repeatedly, usually more than once a day, and asked to rate their momentary self-regulation, motivation, and anxiety on 0-100 scales. Each of its 2,820 rows is one such momentary report. The data are fully anonymized — the participant identifiers are fictional names and the calendar dates have been shifted by a constant offset, so within-person spacing is preserved while no real dates or identities remain. The nine indicators span self-regulation (`planning`, `monitoring`, `effort`, `regulation`), motivation (`efficacy`, `value`, `motivated`, `enjoyment`), and `anxiety`; a derived `day_type` column marks weekday and weekend reports. This vignette follows a single one of these signals for a single student: the momentary `anxiety` ratings of the participant recorded as Jamal, who reported 79 occasions in total. To keep every network small enough to read in full, the analysis uses only his first 60 reports, which each verb selects by name from the data frame. Passing the data frame rather than a bare vector preserves the recorded order of the observations, and that order is what every method below treats as the flow of time. ```{r data} data("esm_srl", package = "tsn") anxiety <- head(subset(esm_srl, name == "Jamal"), 60) dim(anxiety) ``` These 60 reports span 31 anonymized calendar days. The anxiety ratings occupy the full observed range 0 to 100, with a median of 49, a mean of 47.0, and a standard deviation of about 27, so they spread across a wide band rather than clustering near a single value. The segment carries no systematic trend: the first twenty reports average 44.9 and the last twenty average 46.3, essentially unchanged. The representations that follow should therefore be read as descriptions of short-run fluctuation around a roughly constant level, not of a rising or falling series. The distinction matters for interpretation, because several of the constructions below, in particular the visibility graph and the trend labels, respond to the local rises and falls of the series rather than to its overall direction. # Transition Network Analysis Transition Network Analysis starts by turning the observed series into a finite sequence of states. The aim is not to discard the measured values but to obtain a symbolic representation in which dynamics can be studied as movement among a fixed set of states. `discretize()` performs this step, assigning each observation to one state and returning one tidy row per observation. Here the default quantile discretization splits the values into three empirical groups, labelled `Low`, `Middle`, and `High`. The resulting sequence keeps the temporal order of the observations and replaces their continuous values with ordered categorical states. ```{r discretize} states <- discretize( data = anxiety, series = "anxiety", labels = c("Low", "Middle", "High") ) states summary(states) ``` Each row records the source identifier, the time index, the observed value, the assigned state, and an assignment probability. The `probability` column reports the confidence of the assignment: it equals 1 for a hard partition such as the quantile rule used here, and takes values in the unit interval for probabilistic discretizers such as `gaussian` or `kde`. Because the state labels follow the ordering of the values, `Low`, `Middle`, and `High` retain a strict ordinal meaning. Quantile boundaries need not divide tied observations into exactly equal groups; in this segment they happen to do so, with 20 observations in each state and state means of 17.0, 45.7, and 78.4. The state sequence is the intermediate object from which every transition network below is built. Its three states become the network nodes, and each adjacent pair of observations defines one possible state-to-state transition. The four constructors that follow all read this same sequence; what differs between them is the quantity placed on the edges. ```{r discretize-plot, fig.alt="Ribbon plot of the anxiety series with a strip of Low, Middle, and High states drawn beneath the measured values along observation order."} plot(states, type = "ribbon") ``` The ribbon view places the discrete states beneath the measured series, so the correspondence between an observation and its assigned state stays visible without a full-panel overlay obscuring the values themselves. ## Transition probabilities with `ts_tna()` `ts_tna()` builds a directed network in which an edge runs from the current state to the next, weighted by the conditional probability of that transition. Each row of the weight matrix is a probability distribution over the possible next states, so the outgoing weights of a state sum to one. ```{r ts-tna, eval=has_nestimate} probabilities <- ts_tna(states) probabilities ``` The diagonal entries measure persistence, the probability that the next observation stays in the current state: 0.316 for `Low`, 0.200 for `Middle`, and 0.400 for `High`. Comparing these with the marginal state proportions (each exactly one third here) separates persistence from mere prevalence. `High` persists above its marginal share, so a high-anxiety report is more likely to be followed by another one than random ordering would imply. `Low` sits near its share, while `Middle` falls well below it: after a mid-range report the series usually leaves, most often upward (`Middle` to `High` is 0.450). The single largest transition probability is `Low` to `Middle` at 0.526 — low anxiety resolves toward the middle of the scale rather than holding. ```{r ts-tna-plot, eval=has_nestimate && has_cograph, fig.width=9, fig.alt="Directed transition-probability network over the Low, Middle, and High states, with arrows labelled by conditional probabilities."} plot(probabilities) ``` Row normalization is what makes this representation interpretable. Each row is the conditional distribution of the next state given the current one, so the outgoing patterns of different states remain comparable even though the states may have different marginal frequencies. A sequence of 60 observations is thereby reduced to three nodes and the directed relationships among them, with the temporal order retained as the basis for the estimates. ## Transition frequencies with `ts_ftna()` `ts_ftna()` weights each directed edge by the observed count of the corresponding transition, without the row normalization applied by `ts_tna()`. The network therefore preserves the absolute number of times each transition occurred. ```{r ts-ftna, eval=has_nestimate} counts <- ts_ftna(states) counts ``` The 60 observations form 59 adjacent pairs, so the count matrix sums to 59. The most frequent single transition is `Low` to `Middle`, which occurs 10 times; the largest self-transition, `High` to `High`, occurs 8. No row is dominated by its own diagonal — `Middle` follows itself only 4 times against 16 departures — so persistence, although present for `High`, does not dominate the raw counts here. The frequency and probability networks describe the same transitions but answer different questions: the frequency network reports **how often** each transition occurred, whereas the probability network reports **how likely** it is given the current state. ```{r ts-ftna-plot, eval=has_nestimate && has_cograph, fig.width=9, fig.alt="Directed frequency transition network over the three states, with arrows labelled by observed transition counts."} plot(counts) ``` ## State co-occurrence with `ts_cna()` `ts_cna()` builds an undirected, symmetric network whose edges measure how strongly two states occur together, without regard to the order in which they appear. It answers a different question from the directed transition networks. ```{r ts-cna, eval=has_nestimate} cooccurrence <- ts_cna(states) cooccurrence ``` For a single sequence, these co-occurrence counts follow directly from the marginal state frequencies. An off-diagonal entry is the product of the two state counts, so with 20 observations per state every between-state weight is `20 * 20 = 400`; a diagonal entry counts the unordered pairs within a state, so every self-weight is `20 * 19 / 2 = 190`. The network therefore reflects the composition of the state alphabet rather than the temporal direction of transitions — with exactly equal state counts it is completely uniform. A strong edge means the two states are both common, not that one tends to follow the other. ```{r ts-cna-plot, eval=has_nestimate && has_cograph, fig.width=9, fig.alt="Undirected co-occurrence network over the three states, with symmetric edges weighted by state co-occurrence."} plot(cooccurrence) ``` The symmetric drawing makes the distinction explicit: an edge carries no source-to-target direction, in contrast to the transition networks above. ## Attention-weighted transitions with `ts_atna()` `ts_atna()` assigns each transition an attention weight rather than a count or a probability. The edge weights are accumulated attention values and are not on the scale of either the frequency or the probability network. ```{r ts-atna, eval=has_nestimate} attention <- ts_atna(states) attention ``` The weights range from 2.462 to 5.000. Unlike the frequency and probability networks, the largest weights here are off-diagonal: the strongest attention-weighted transitions are `Middle` to `High` (5.000) and `Low` to `Middle` (4.793), and the persistence diagonals are not the dominant entries in their rows. The attention mechanism consequently redistributes emphasis away from self-transitions, and the weights should be read on the model's own attention scale rather than compared directly with counts or probabilities. ```{r ts-atna-plot, eval=has_nestimate && has_cograph, fig.width=9, fig.alt="Directed attention-weighted transition network over the three states, with edges weighted by accumulated attention values."} plot(attention) ``` ## Indexing the model with `series_networks()` `series_networks()` returns an indexed collection of the network models attached to the source series. It is designed for analyses that extract networks across many series at once, where the index preserves the link between each series and its model. With a single input series it returns one entry, corresponding to `anxiety`. ```{r series-networks, eval=has_nestimate} individual <- series_networks(probabilities) individual ``` The index records the source series, the network type, and the number of observations, states, and edges. For the transition-probability network the three states generate nine directed relationships, the six between distinct states plus the three self-transitions. ```{r series-networks-plot, eval=has_nestimate && has_cograph, fig.width=7, fig.alt="The single indexed transition-probability network for the anxiety series, drawn from the series_networks collection."} plot(individual) ``` The collection thus provides a higher-level handle on the extracted networks while keeping their provenance. Given several source series, the same structure organizes and compares their models systematically. # Local direction with `trend()` `trend()` estimates a rolling local slope and labels each observation as `Ascending`, `Descending`, `Flat`, `Turbulent`, `Missing Data`, or `Initial`. The window is adaptive and defaults to one tenth of the series length, which is 6 observations here, and the default Theil-Sen slope limits the influence of isolated extreme ratings. ```{r trend} directions <- trend( data = anxiety, series = "anxiety" ) directions summary(directions) ``` `Ascending` is the most common label with 24 observations, followed by `Descending` with 21, `Turbulent` with 10, and `Initial` with 5. The near balance between ascending and descending positions is what a segment without drift should produce: local direction keeps reversing rather than accumulating in one direction. No observation earns the `Flat` label at all — in this segment the local slope is never both small and steady for a full window, which is itself a description of how restless the momentary ratings are. `Initial` marks the leading positions that lack a complete centered window, and `Missing Data` would mark absent values, of which this segment has none. ```{r trend-plot, fig.alt="The anxiety series with each observation coloured by its trend label: Ascending, Descending, Turbulent, or Initial."} plot(directions) ``` The default plot colours each observation by its trend label. ```{r trend-panels, fig.alt="Two stacked panels: the anxiety series on top and its rolling slope metric below, with a shaded flat band marking near-zero slope."} plot(directions, type = "panels") ``` The panel view places the rolling metric below the measurements, so the points at which the metric crosses the flat band can be checked against the assigned direction. # Visibility with `vg()` `vg()` treats each observation as a node. Under natural visibility, two points are joined when the straight line between their values stays above every intervening point. Under horizontal visibility, they are joined only when every intervening value lies below both endpoints, a strictly stronger condition. ```{r vg-natural} natural <- vg( data = anxiety, series = "anxiety" ) summary(natural) ``` The natural visibility graph has 60 nodes and 144 edges. At this length the edges remain distinguishable, so it can be drawn directly. Node size encodes degree, and the peaks accumulate the most connections because they stay visible over the smaller values around them. ```{r vg-network, eval=has_cograph, fig.alt="Natural visibility graph of sixty anxiety observations, drawn with a Fruchterman-Reingold layout and nodes sized by degree; the high-degree hubs correspond to peaks that see far along the series, spread apart so individual nodes stay distinguishable."} plot(natural, layout = "fr", node_size_range = c(1.4, 4.5)) ``` The horizontal rule keeps the same 60 nodes but fewer edges. ```{r vg-horizontal} horizontal <- vg( data = anxiety, type = "horizontal", series = "anxiety" ) summary(horizontal) ``` It retains 106 of the 144 connections, removing the 38 that a natural line of sight can clear but a horizontal one cannot. The result keeps the temporal chain of consecutive observations while dropping the longer sightlines that only the natural view permits. In both graphs the edge weights are binary, since visibility either holds or it does not. ```{r vg-network-horizontal, eval=has_cograph, fig.alt="Horizontal visibility graph of the same sixty observations, drawn with the same Fruchterman-Reingold layout and degree-scaled nodes; it is sparser than the natural graph, with the temporal chain visible alongside a smaller number of longer edges."} plot(horizontal, layout = "fr", node_size_range = c(1.4, 4.5)) ``` Every network also retains the series it was built from, which the source-series view recovers. ```{r vg-series, fig.alt="The sixty anxiety observations behind the visibility graph, plotted as a line over observation order."} plot(horizontal, type = "series") ``` This view makes the short-run fluctuation that the visibility rule reads directly apparent. # Window similarity with `tsn()` `tsn()` is the general distance-network constructor. With `method = "distance"` a single measured series defaults to sliding-window nodes. The window width defaults to one tenth of the series length, which is 6 here, and a step of 2 yields 28 overlapping windows. ```{r tsn-distance} windows <- tsn( data = anxiety, method = "distance", series = "anxiety", step = 2 ) summary(windows) ``` The default full connection rule evaluates and keeps all 378 window pairs. Each edge weight is `1 / (1 + distance)`, so larger weights mark more similar windows. ```{r tsn-nearest} sparse <- tsn( data = anxiety, method = "distance", series = "anxiety", step = 2, connect = "nearest", neighbors = 2 ) summary(sparse) ``` Nearest-neighbour sparsification keeps 41 edges and lowers the density from 1 to 0.108, retaining the strongest local similarities instead of drawing every pair. ```{r tsn-network, eval=has_cograph, fig.alt="Nearest-neighbour distance network over twenty-eight sliding windows of the anxiety series, with edges joining the most similar windows."} plot(sparse) ``` Only two of the retained edges join immediately adjacent windows; the others span anywhere from two to nineteen positions. Overlap in time therefore does not by itself determine similarity in value: windows far apart in the sequence can still be each other's nearest neighbours. The distance measure is itself a modelling choice. Dynamic time warping aligns two windows by their shape, tolerating the shift or stretch in time that Euclidean distance penalizes. Repeating the sparsification under `distance = "dtw"` redraws the neighbour graph on the same 28 windows. ```{r tsn-dtw} warped <- tsn( data = anxiety, method = "distance", series = "anxiety", step = 2, distance = "dtw", connect = "nearest", neighbors = 2 ) summary(warped) ``` The warped network keeps 37 edges against the Euclidean version's 41, yet only 12 edges appear in both: the two rules connect largely different pairs of windows. Whether two windows count as similar therefore depends as much on the chosen distance as on the series itself. ```{r tsn-dtw-network, eval=has_cograph, fig.alt="Nearest-neighbour distance network over the same twenty-eight windows using dynamic time warping, connecting a different set of window pairs than the Euclidean network."} plot(warped) ```