Skip to content
On this page

复杂数据处理 · 使用序列


第 6 节 复杂数据处理 · 使用序列

我们常使用数组作为存储一系列数据的方式,而在 JavaScript 中数组是非常强调顺序的一种数据结构。但我们在日常使用的时候,并不是所有的数据都是完全遵守等间距的。可能每两个数据之间从特定的维度上观察是呈现的间断性,而非连续性。

其中最为典型的就是时间序列,从物理传感器传来的数据、智能穿戴设备的记录数据等等都是经常会出现不连续的现象,这些数据从时间维度上观测会发现基本上是无规律间隙性的。那么我们就不能简单地使用数组进行记录。

为了更为准确地记录和表达这些数据,我们需要使用到第 4 节中我们所提到的“更强”的数组。将用于表达顺序的标签从 JavaScript 数组中的下标,改变为对象元素中的某一个标签。

6.1 时间序列

时间序列一般用于表达一组建立在时间轴上的数据,比如工业生产设备中传感器所定时记录的数据,但由于传感器设备同样存在误差,而且由于实际应用中也有可能出现不工作的情况,所以真正所记录下来的数据很有可能是断断续续的。

时间序列

在这图中你会看到并不是每一秒钟都会记录有数据,这种数据集我们可以将其称为稀疏序列,虽然我们可以使用插值、拟合等方式将这些空缺的数据补上,但是这涉及更为复杂的数学运算所以不会在本小册的讨论范围之内,感兴趣的同学可以查询“数值计算”相关的文献和书籍进行学习。

回到正题上,我们需要使用“更强”的数组来进行对时间序列的存储,那么自然就意味着数组元素中必定包含用于存储每一个数据点所对应的时间戳(Timestamp)。在 JavaScript 中,一般使用毫秒级 Unix 时间戳作为时间的基本表达方式。毫秒级 Unix 时间戳即以格林尼治时间 1970 年 1 月 1 日 0 点 0 分开始,每经过一毫秒(rac{1}{1000} 秒)即记数 1。如 2018 年 1 月 1 日 0 时 0 分整点使用毫秒级 Unix 时间戳则为 1514736000000。

在 JavaScript 中我们可以使用 Date.now() 方法来获取当前设备中所记录的当前毫秒级 Unix 时间戳。同样的也可以使用 new Date(timestamp) 的方式将以数值为表达方式的时间戳转换为以 JavaScript 中用于表达时间(包括日期和时间)的 Date 类型。

假设我们有以下记账数据,并以时间序列的形式存储。

时间类型价格
Thu Mar 01 2018 08:31:32餐饮6.00
Thu Mar 01 2018 11:27:52餐饮12.00
Thu Mar 01 2018 18:24:09餐饮52.50
Fri Mar 02 2018 09:14:09餐饮4.50
Fri Mar 02 2018 11:58:22餐饮13.50
Fri Mar 02 2018 22:10:49餐饮104.25

这是某人两天的一日三餐记账记录,假设我们使用 JavaScript 的形式进行存储的话就可以使用以带有时间戳 timestamp 属性的对象作为数组的元素。

let transactions = [
  {
    timestamp: 1519864292535,
    category: '餐饮',
    price: 6.00
  },
  {
    timestamp: 1519874872261,
    category: '餐饮',
    price: 12.00
  },
  {
    timestamp: 1519899849526,
    category: '餐饮',
    price: 52.50
  },
  {
    timestamp: 1519953249020,
    category: '餐饮',
    price: 4.50
  },
  {
    timestamp: 1519963102270,
    category: '餐饮',
    price: 13.50
  },
  {
    timestamp: 1519999849526,
    category: '餐饮',
    price: 104.25
  }
]

如果我们将这些数据放到时间轴上,就可以发现这些记录值间歇性地分布在时间轴上,中间的间隔也并非一定。

time-series-demo

6.1.1 处理时间

在时间序列中,顾名思义其最重要的参数便是序列元素中的时间戳。但由于在实际研究和应用中,我们大多并不需要将统计分析的精度精确到毫秒级或是秒级,更多的情况是以每天、每周、每月和每年的方式进行统计。

所以我们在处理时间序列的时候,首先需要做的是如何将时间序列中的高精确度记录数据进行整合,首先聚合出一定时间范围内的平均、总体记录结果。

在 JavaScript 中处理时间,我们可以首先使用 Date 类型将以整型为存储介质的时间戳转换为 Date 类型。

transactions = transactions.map(function(data) {
  data.timestamp = new Date(data.timestamp)
  return data
})

这样我们就已经将现有数据中的时间戳变成了可以用于进行详细操作的 Date 类型对象,接下来就可以将其进行时间范围的分组操作。

但是跟 JavaScript 中的数组一样,JavaScript 中的 Date 对象虽然本身也已经提供了非常多很实用的方法,但是这远远不足以满足我们的实际需求。所以我们这里再次引入一个第三方工具库 Moment.js。

引入 Moment.js

Moment.js 是一个专门用于处理 JavaScript 中 Date 类型数据的工具库,它除了提供用于设置和提取时间对象中的各种参数(时、分、秒、日期等)外,还可以根据不同的表达格式进行字符串渲染,得到需要的时间格式。

将以下 HTML 标签直接加入到 head 头部中,或者在 CodePen 的设置中加入 Moment.js 的链接即可。

<script type="application/javascript" src="https://cdn.staticfile.org/moment.js/2.21.0/moment.min.js"></script>

在使用 Moment.js 之前,我们同样需要将时间戳转换为 Moment 类的对象,支持直接将以整型的时间戳或 Date 类型转换为 Moment 对象。

transactions = transactions.map(function(data) {
  data.moment = moment(data.timestamp)
  return data
})

按天分组

对于记账数据来说,一般来说我们需要进行最小颗粒统计便是以天为单位的计算。那么我们首先就需要对记录数据中的时间戳进行处理,得到对应的日期。

使用 Moment.js 进行日期提取非常简单,Moment.js 允许对时间对象进行格式化。比如我们若需要将时间转换为以 年-月-日 为格式的字符串,就可以使用 moment.format('YYYY-MM-DD') 进行格式化。

结合前面我们学习过的 LoDash 工具库,我们可以使用 _.groupBy 函数进行分组。

const transactionsGroupedByDate = _.groupBy(transactions, function(transaction) {
  return transaction.moment.format('YYYY-MM-DD')
})

console.log(transactionsGroupedByDate)
// => {
//  "2018-03-01": [{...}, {...}],
//  "2018-03-02": [{...}, {...}]
// }

按周分组

除了按天计算以外,我们对于我们的记账数据往往对每周的开销更为看重。然而实际上如何把时间按周分组确实是一个“技术活”,因为我们往往不能保证每年 1 月 1 日和每个月的第一天都是周日(每周的第一天)。

一般情况下一年有 365 天(闰年有 366 天),以一周 7 天为标准,所以一年就有 rac{365}{7}=52rac{1}{7} 周,也就是一年有 52 个星期多一到两天。

如果严格使用周日为一周的第一天原则,就需要精确到天来确定某一天处在于某一年的第几个星期。当然我们不需要太过于纠结于这个,因为 Moment.js 已经帮我们封装好这样的转换工具了。

在调用 moment.format(pattern) 方法时使用 "WW" 可以获取两位数的周数(01 ~ 53),为了根据周分组我们可以按 "YYYY-WW" 作为分组标签。

const transactionsGroupedByWeek = _.groupBy(transactions, function(transaction) {
  return transaction.moment.format('YYYY-WW')
})

console.log(transactionsGroupedByWeek)
// => {
//   "2018-09": [{…}, {…}, {…}, {…}, {…}, {…}]
// }

按月、年分组

除了按星期作为分组以外,我们也需要看看我们一个月内我们究竟花了多少钱,而且对于个体户来说一个月的收支更是重要。按月分组跟按周分组非常相似,只是在调用 moment.format(pattern) 时,将 "WW" 改成 "MM" 即可。

const transactionsGroupedByMonth = _.groupBy(transactions, function(transaction) {
  return transaction.moment.format('YYYY-MM')
})

console.log(transactionsGroupedByMonth)
// => {
//   "2018-03": [{…}, {…}, {…}, {…}, {…}, {…}]
// }

按照年来分组则同理,对格式化方式进行更改就可以了。

const transactionsGroupedByYear = _.groupBy(transactions, function(transaction) {
  return transaction.moment.format('YYYY')
})

console.log(transactionsGroupedByYear)
// => {
//   "2018": [{…}, {…}, {…}, {…}, {…}, {…}]
// }

分组整合

是否觉得我们一个一个时间单位地分别进行分组太麻烦了?不用担心,回想一下我们在进行数组操作的时候,我们曾为数组的操作封装过一个工具,那么我们也同样可以为时间序列封装一个工具来方便我们使用时间序列。

function createTimeSeries(timeSeriesArray) {
  const timeSeriesObj = {
    array: timeSeriesArray.map(function(data) {
      data.moment = moment(data.timestamp)

      return data
    }),

    groupByFormat(formatPattern) {
      return _.groupBy(timeSeriesObj.array, function(data) {
        return data.moment.format(formatPattern)
      })
    },

    groupByDate() {
      return timeSeriesObj.groupByFormat('YYYY-MM-DD')
    },

    groupByWeek() {
      return timeSeriesObj.groupByFormat('YYYY-WW')
    },

    groupByMonth() {
      return timeSeriesObj.groupByFormat('YYYY-MM')
    },

    groupByYear() {
      return timeSeriesObj.groupByFormat('YYYY')
    }

    // ...

  }

  return timeSeriesObj
}

const timeSeries = createTimeSeries(transactions)
console.log(timeSeries.groupByMonth())

6.1.2 时间序列统计计算

我们已经将账单数据按照时间进行了分组,但是当我们打开一个记账软件的时候难道只会看某一天我花了哪些钱吗?我自然希望能够知道这一天我花了多少钱、一周内花了多少钱、一个月内花了多少钱、一般是周几的时候花钱最多、一周平均每天花多少钱等等计算结果。

而我们前面已经将数据按周、月进行分组,但是我们同样需要在按周、月分组之后再进行按天分组,因为我们需要看到一个星期、一个月内每天的开销统计。

计算每天开销情况

要计算每天的开销情况,不一定是需要先将数据分组好以后再进行处理,而我们在进行分组的时候就可以直接完成我们需要的统计计算。

首先我们第一步就是需要从知道每天花了哪些钱,变成知道每天花了多少钱,那么我们就需要进行求和计算。在第 4 节中我们介绍过 LoDash 工具库中的 _.sum 函数,而 LoDash 工具库同时还提供了一个 _.sumBy 函数以用于处理我们较为复杂的多维数组。

我们前面定义了一个 timeSeriesObj.groupByFormat 方法,该方法返回的结果是一个以 { [date]: array } 为格式的对象(或叫映射集)。为了避免数据产生的大量冗余(重复、不必要的数据),我们可以再定义一个结果对象,将前面的日期集对象以 map 属性值存储,并且定义 dates() 以返回日期字符串集以便我们后面的使用。

再回到正题上,我们需要得到当前日期集中每一天的开销总和。但是让我们再次思考一个问题,是否一定要让每一天的统计值以实体数据(即内存变量)的方式存储呢?其实不必,我们可以以一种虚拟映射的方式表达这样的数据,即定义一个计算函数 sum(date),只有当传入某一日期的时候才会返回该日期的统计结果,以节省内存空间。这些日期字符串我们就可以通过调用 dates 取得。

function createTimeSeries(timeSeriesArray) {
  const timeSeriesObj = {
  
    // ...

    groupByDate() {
      const groupedResult = {
        map: timeSeriesObj.groupByFormat('YYYY-MM-DD'),

        dates() {
          return _.keys(groupedResult.map)
        },

        sum(date) {
          return _.sumBy(groupedResult.map[date], 'price')
        }
      }

      return groupedResult
    },

    // ...

  }

  return timeSeriesObj
}

const timeSeries = createTimeSeries(transactions)
const groupedByDateSeries = timeSeries.groupByDate()

console.log(groupedByDateSeries.dates())
//=> ["2018-03-01", "2018-03-02"]

const firstDate = groupedByDateSeries.dates()[0]

console.log(groupedByDateSeries.sum(firstDate))
//=> 70.5

如果我们需要一次性打出所有日期的统计结果,我们可以简单地灵活使用 Array.map 方法即可。

groupedByDateSeries.dates().map(function(date) {
  return {
    date: date,
    sum: groupedByDateSeries.sum(date)
  }
})
//=> [
//   { date: "2018-03-01", sum: 70.5 },
//   { date: "2018-03-02", sum: 122.25 }
// ]

计算每周开销情况

完成了对每天开销情况的统计以后,我们就可以对更大时间范围的数据进行进一步的统计了,比如我们需要知道一周内的使用情况。那就可以在前面按天计算的前提下完成这个需求。

事实上 Moment.js 库非常的“聪明”,它可以自动检测我们传入的时间参数的格式(整数时间戳、时间字符串、日期字符串等等),并转化为标准的 Moment 时间对象。那么这就意味着可以直接传入前面使用 groupByDate.dates() 方法所得到的日期集合来进行聚合。

date-to-week

相比于 groupedByDate 直接建立从日期到数据集的直接映射,出于避免数据过度冗余的原则,在进行对星期聚合的时候我们选择从星期到日期的映射,再使用前面 groupedByDate 所建立的虚拟映射来完成新的虚拟映射需求。

const timeSeriesObj = {

  // ...

  groupByWeek() {
    const groupedByDate = timeSeriesObj.groupByDate()

    const groupedResult = {
      map: _.groupBy(groupedByDate.dates(), function(date) {
        return moment(date).format('YYYY-WW')
      }),

      weeks() {
        return _.keys(groupedResult.map)
      },

      sum(week) {
        const dates = groupedResult.map[week]

       return _.sumBy(dates, function(date) {
          return groupedByDate.sum(date)
        })
      },

      average(week) {
        const dates = groupedResult.map[week]
        const sum = groupedResult.sum(week)

        return sum / dates.length
      }
    }

    return groupedResult
  },

  // ...

}

相比前面的 groupByDategroupByWeek 还多了一个 average 虚拟映射以得到某一星期内每天开销的平均值。学会对星期进行分组聚合以后,对月和对年的实现就由你们来完成哦😃。

更为简单的组合接口

是否觉得目前对于获取每一个范围内的开销总和以及平均日开销太过于复杂?不用担心,经过了前面几节的学习之后,我们已经知道了如何使用函数和对象进行一些逻辑的封装,那么我们自然可以再继续将封装的程度往上堆叠,将 sumaverage 从聚合结果中抽出。

function createTimeSeries(timeSeriesArray) {
  const timeSeriesObj = {

    // ...

    dates() {
      return timeSeriesObj.groupByDate().dates()
    },

    weeks() {
      return timeSeriesObj.groupByWeek().weeks()
    },

    months() {
      return timeSeriesObj.groupByMonth().months()
    },

    years() {
      return timeSeriesObj.groupByYear().years()
    },

    sum(unit, point) {
      switch (unit) {
        case 'date':
          return timeSeriesObj.groupByDate().sum(point)

        case 'week':
          return timeSeriesObj.groupByWeek().sum(point)

        case 'month':
          return timeSeriesObj.groupByMonth().sum(point)

        case 'year':
          return timeSeriesObj.groupByYear().sum(point)
      }
    },

    average(unit, point) {
      switch (unit) {
        case 'week':
          return timeSeriesObj.groupByWeek().average(point)

        case 'month':
          return timeSeriesObj.groupByMonth().average(point)

        case 'year':
          return timeSeriesObj.groupByYear().average(point)
      }
    }
  }

  return timeSeriesObj
}

const timeSeries = createTimeSeries(transactions)
console.log(timeSeries.sum('month', '2018-03')) //=> 192.75
console.log(timeSeries.average('month', '2018-03')) //=> 96.375

题外话:聚合缓存

经过了三层的封装之后,各种的聚合、提取的调用次数变得比较多。而为了能够让程序运行更加顺畅,内存调度更为节约,可以使用懒加载的方式缓存一些经常使用的聚合结果。

一般来说,缓存的存储位置是在封装层级较底层的位置,来进行存储和读取。而我们这里最为底层的封装位置则是最开始的 groupByFormat 函数。

我们可以在 createTimeSeries 函数中、创建 timeSeries 对象之前定义一个 caches 对象。然后在 groupByFormat 中首先检查 caches 对象中是否存在当前 formatPattern 的结果缓存,若存在则将其作为当前结果返回;在完成计算后就将其存储到 caches 对象中。

function createTimeSeries(timeSeriesArray) {
  const caches = {}
  
  const timeSeriesObj = {
    
    // ...
    
    groupByFormat(formatPattern) {
      if (caches[formatPattern]) {
        return caches[formatPattern]
      }

      const result = _.groupBy(timeSeriesObj.array, function(data) {
        return data.moment.format(formatPattern)
      })

      caches[formatPattern] = result

      return result
    },
    
    // ...
    
  }
  
  return timeSeriesObj
}

小结

在现代社会中,几乎所有的大小单位团体都已经离不开账本系统,无论大至国家公司,还是小至家庭个人,都需要账本来记录和分析收支情况。而你已经在本节中学会了如何使用数组类型来存储这些收支数据,非常棒。

习题

  1. 请根据已有代码,完成以月聚合和以年聚合的处理方法。
  2. 在实际情况中,我们同样需要根据不同的支出分类(category)进行分组计算,请完成按分类计算的同时,支持按天、周、月、年进行分拣范围的统计。