ActiSleep Tutorial

library(ActiSleep)
#> ActiSleep v1.0.0

Introduction

ActiSleep estimates daily sleep duration from wrist or hip accelerometer data. The package implements the Pruned Dynamic Programming (PDP) algorithm described in Baek et al. (2021).

Algorithm overview

PDP solves the penalised segmentation problem: find the K-segment partition of an activity time series that minimises a cost function plus a penalty proportional to the number of breakpoints. The pruning step discards candidate breakpoints that cannot be optimal for any future index, reducing worst-case complexity from O(n²K) to O(nK).

Sleep estimation proceeds in four steps:

  1. Threshold — counts at or below the p-th percentile (threshold_pct) are zeroed.
  2. Segment — the zeroed series is partitioned into K segments using PDP.
  3. Merge — consecutive low-activity segments (≥ no_activity_cutoff zero-count epochs) are merged into candidate sleep windows.
  4. Filter — candidates are validated against an external sleep window (diary or default 10 pm – 8 am) and a minimum duration requirement.

Loading and Inspecting Data

The package includes one subject’s data in AccelData:

data("AccelData")
str(AccelData)
#> spc_tbl_ [1,351 × 11] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
#>  $ date           : chr [1:1351] "7/12/2016 17:00" "7/12/2016 17:01" "7/12/2016 17:02" "7/12/2016 17:03" ...
#>  $ axis1          : num [1:1351] 848 1381 0 1567 2975 ...
#>  $ axis2          : num [1:1351] 889 1197 0 1126 2157 ...
#>  $ axis3          : num [1:1351] 1418 1074 2 1140 3195 ...
#>  $ steps          : num [1:1351] 6 12 0 13 23 23 23 24 39 17 ...
#>  $ lux            : num [1:1351] 0 31 0 0 0 29 71 20 171 315 ...
#>  $ inclineOff     : num [1:1351] 0 0 0 0 0 0 0 0 0 0 ...
#>  $ inclineStanding: num [1:1351] 20 30 0 20 40 60 60 60 60 60 ...
#>  $ inclineSitting : num [1:1351] 0 0 0 0 0 0 0 0 0 0 ...
#>  $ inclineLying   : num [1:1351] 40 30 60 40 20 0 0 0 0 0 ...
#>  $ VM             : num [1:1351] 1876 2120 2 2241 4869 ...
#>  - attr(*, "spec")=List of 3
#>   ..$ cols   :List of 11
#>   .. ..$ date           : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
#>   .. ..$ axis1          : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ axis2          : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ axis3          : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ steps          : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ lux            : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ inclineOff     : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ inclineStanding: list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ inclineSitting : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ inclineLying   : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   .. ..$ VM             : list()
#>   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
#>   ..$ default: list()
#>   .. ..- attr(*, "class")= chr [1:2] "collector_guess" "collector"
#>   ..$ delim  : chr ","
#>   ..- attr(*, "class")= chr "col_spec"
#>  - attr(*, "problems")=<pointer: (nil)>
head(AccelData)
#> # A tibble: 6 × 11
#>   date   axis1 axis2 axis3 steps   lux inclineOff inclineStanding inclineSitting
#>   <chr>  <dbl> <dbl> <dbl> <dbl> <dbl>      <dbl>           <dbl>          <dbl>
#> 1 7/12/…   848   889  1418     6     0          0              20              0
#> 2 7/12/…  1381  1197  1074    12    31          0              30              0
#> 3 7/12/…     0     0     2     0     0          0               0              0
#> 4 7/12/…  1567  1126  1140    13     0          0              20              0
#> 5 7/12/…  2975  2157  3195    23     0          0              40              0
#> 6 7/12/…  3467  2392  2785    23    29          0              60              0
#> # ℹ 2 more variables: inclineLying <dbl>, VM <dbl>

The date column contains minute-level timestamps and VM is the vector magnitude (activity count).


Basic Sleep Estimation

Call estimate_sleep() with the data frame and the name of the activity column. estimate_sleep() automatically detects and parses common date-time formats.

result <- estimate_sleep(AccelData, activity_col = "VM")
result
#> ActiSleep Sleep Estimation
#> ----------------------------------------
#>   Day of week   : Tue 
#>   Cost model    : poisson 
#>   Threshold pct : 0.4 
#>   Seg/hour      : 3 
#>   Valid accel   : yes 
#> ----------------------------------------
#>   Sleep segments found: 3
#>     [1] 2016-07-13 00:51:00 -> 2016-07-13 01:28:00  (37 min, 79% zero)
#>     [2] 2016-07-13 01:31:00 -> 2016-07-13 03:53:00  (142 min, 97% zero)
#>     [3] 2016-07-13 03:54:00 -> 2016-07-13 09:08:00  (314 min, 90% zero)
#> ----------------------------------------

Use the three S3 methods to inspect the result:

# Formatted per-segment summary
print(result)
#> ActiSleep Sleep Estimation
#> ----------------------------------------
#>   Day of week   : Tue 
#>   Cost model    : poisson 
#>   Threshold pct : 0.4 
#>   Seg/hour      : 3 
#>   Valid accel   : yes 
#> ----------------------------------------
#>   Sleep segments found: 3
#>     [1] 2016-07-13 00:51:00 -> 2016-07-13 01:28:00  (37 min, 79% zero)
#>     [2] 2016-07-13 01:31:00 -> 2016-07-13 03:53:00  (142 min, 97% zero)
#>     [3] 2016-07-13 03:54:00 -> 2016-07-13 09:08:00  (314 min, 90% zero)
#> ----------------------------------------

# Aggregate statistics
summary(result)
#> ActiSleep Result Summary
#> ----------------------------------------
#>   Sleep segments     : 3
#>   Total sleep time   : 493 min (8.2 hr)
#>   Mean % zero epochs : 88.5%
#> ----------------------------------------

# Full data frame
df <- as.data.frame(result)
df
#>            sleep_onset        sleep_offset duration_min pct_zero_activity
#> 6  2016-07-13 00:51:00 2016-07-13 01:28:00      37 mins         0.7894737
#> 8  2016-07-13 01:31:00 2016-07-13 03:53:00     142 mins         0.9650350
#> 10 2016-07-13 03:54:00 2016-07-13 09:08:00     314 mins         0.9015873
#>    no_activity_cutoff is_sleep is_no_activity diary_overlap       diary_bedtime
#> 6                 0.7        1              1             1 2016-07-12 22:00:00
#> 8                 0.7        1              1             1 2016-07-12 22:00:00
#> 10                0.7        1              1             1 2016-07-12 22:00:00
#>         diary_waketime n_sleep_segments
#> 6  2016-07-13 08:00:00                3
#> 8  2016-07-13 08:00:00                3
#> 10 2016-07-13 08:00:00                3

Understanding the output

Each row in the segments data frame represents one candidate sleep episode:

  • sleep_onset / sleep_offset — start and end timestamps
  • duration_min — duration in minutes
  • pct_zero_activity — proportion of zero-count epochs (higher = more inactive)
  • is_sleep — 1 if the segment meets all criteria for sleep
  • diary_overlap — 1 if the segment overlaps the diary (or default) window

Non-Wear Detection

Set detect_nonwear = TRUE to apply the accelerometry non-wear algorithm before segmentation. Days with insufficient wear time are flagged with valid_accel = 0 and return NA segments.

result_nw <- estimate_sleep(
  AccelData,
  activity_col     = "VM",
  detect_nonwear   = TRUE,
  min_wear_minutes = 120
)
print(result_nw)
#> ActiSleep Sleep Estimation
#> ----------------------------------------
#>   Day of week   : Tue 
#>   Cost model    : poisson 
#>   Threshold pct : 0.4 
#>   Seg/hour      : 3 
#>   Valid accel   : yes 
#> ----------------------------------------
#>   Sleep segments found: 3
#>     [1] 2016-07-13 00:51:00 -> 2016-07-13 01:28:00  (37 min, 79% zero)
#>     [2] 2016-07-13 01:31:00 -> 2016-07-13 03:53:00  (142 min, 97% zero)
#>     [3] 2016-07-13 03:54:00 -> 2016-07-13 09:08:00  (314 min, 90% zero)
#> ----------------------------------------

Sleep Diary Integration

When self-reported bed and wake times are available, pass them as a data.frame with columns bed and wake.

data("SleepDiary1Day")

diary <- data.frame(
  bed  = SleepDiary1Day$bed,
  wake = SleepDiary1Day$wake
)

result_diary <- estimate_sleep(
  AccelData,
  activity_col       = "VM",
  cost_model         = "normal",
  threshold_pct      = 0,
  detect_nonwear     = TRUE,
  segments_per_hour  = 2,
  no_activity_cutoff = 0.45,
  min_sleep_minutes  = 5,
  use_diary          = TRUE,
  diary              = diary
)
print(result_diary)
#> ActiSleep Sleep Estimation
#> ----------------------------------------
#>   Day of week   : Tue 
#>   Cost model    : normal 
#>   Threshold pct : 0 
#>   Seg/hour      : 2 
#>   Valid accel   : yes 
#> ----------------------------------------
#>   Sleep segments found: 1
#>     [1] 2016-07-13 00:17:00 -> 2016-07-13 10:21:00  (604 min, 86% zero)
#> ----------------------------------------

Diary columns may be POSIXct objects or character strings; estimate_sleep() parses them automatically using common formats ("YYYY-MM-DD HH:MM", "MM/DD/YYYY HH:MM", etc.).


Batch Processing Multiple Subjects

Wrap estimate_sleep() in lapply() to process a list of subjects:

# subject_list: list of named lists with $accel (data.frame) and $id (string)
results <- lapply(subject_list, function(subj) {
  estimate_sleep(
    data         = subj$accel,
    subject_id   = subj$id,
    activity_col = "VM"
  )
})

# Combine all segments into one data frame
all_segments <- do.call(rbind, lapply(results, as.data.frame))

Cost Model Selection

ActiSleep supports five PDP cost functions:

cost_model Distribution When to use
"poisson" (default) Poisson Non-negative integer counts — typical for raw actigraphy
"normal" Normal Continuous or approximately symmetric data
"negative_binomial" Negative binomial Overdispersed integer counts
"variance" Variance change Constant-mean series with changing variance
"exponential" Exponential Positive continuous inter-event times

Integer codes 1–5 are also accepted.


Parameter Tuning

threshold_pct

Controls how aggressively low-activity epochs are zeroed before segmentation.

  • Higher value (e.g. 0.6): more epochs zeroed → sharper sleep/wake contrast → recommended when many wake epochs have low-but-non-zero activity.
  • Lower value (e.g. 0.2): fewer epochs zeroed → use when the dataset has a bimodal activity distribution or when false negatives are a concern.
  • 0: no thresholding at all.

segments_per_hour

Controls the temporal resolution of the segmentation.

  • Higher value (e.g. 5): finer resolution; useful for detecting short naps or fragmented sleep.
  • Lower value (e.g. 1–2): coarser resolution; faster and more stable on noisy data.

no_activity_cutoff

Minimum proportion of zero-count epochs to label a segment as “inactive”.

  • Higher value (e.g. 0.9): stricter; only segments with almost all zeros are considered inactive.
  • Lower value (e.g. 0.5): more lenient; useful for restless sleepers.

min_sleep_minutes

Segments shorter than this are not classified as sleep. Raise this value if short spurious segments are being detected (e.g. during long inactive rest periods while awake).


Reading AGD Files

ActiGraph devices produce .agd files (SQLite databases). Use read_agd() to extract the device settings and raw accelerometer data:

agd <- read_agd("subject01.agd", tz = "America/New_York")

head(agd$raw.data)   # date, axis1, axis2, axis3, steps, lux, ...
agd$settings         # device metadata

The returned raw.data data frame can be passed directly to estimate_sleep() after selecting the appropriate activity column.


References

Baek, J., Banker, M., Jansen, E. C., She, X., Peterson, K. E., Pitchford, E. A., & Song, P. X. K. (2021). An efficient segmentation algorithm to estimate sleep duration from actigraphy data. Statistics in Biosciences. https://doi.org/10.1007/s12561-021-09309-3