LINQ 按时间段聚合和分组

Posted

技术标签:

【中文标题】LINQ 按时间段聚合和分组【英文标题】:LINQ aggregate and group by periods of time 【发布时间】:2012-02-09 23:49:24 【问题描述】:

我试图了解如何使用 LINQ 按时间间隔对数据进行分组;然后理想地聚合每个组。

找到大量具有明确日期范围的示例,我尝试按时间段(例如 5 分钟、1 小时、1 天)进行分组。

例如,我有一个用值包装 DateTime 的类:

public class Sample

     public DateTime timestamp;
     public double value;

这些观察结果作为一个系列包含在 List 集合中:

List<Sample> series;

因此,要按每小时的时间段分组并按平均值汇总价值,我正在尝试执行以下操作:

var grouped = from s in series
              group s by new TimeSpan(1, 0, 0) into g
              select new  timestamp = g.Key, value = g.Average(s => s.value ;

这从根本上是有缺陷的,因为它将 TimeSpan 本身分组。我不明白如何在查询中使用 TimeSpan(或任何表示间隔的数据类型)。

【问题讨论】:

您能用示例数据描述您的问题吗? @AliAmiri - 我认为这已经足够清楚了。示例输出可能会有所帮助。 好问题。我相信很多人都在为这个确切的任务而苦苦挣扎。时间序列数据似乎有其困难。 【参考方案1】:

您可以将时间戳四舍五入到下一个边界(即向下到过去最近的 5 分钟边界)并将其用作分组:

var groups = series.GroupBy(x =>

    var stamp = x.timestamp;
    stamp = stamp.AddMinutes(-(stamp.Minute % 5));
    stamp = stamp.AddMilliseconds(-stamp.Millisecond - 1000 * stamp.Second);
    return stamp;
)
.Select(g => new  TimeStamp = g.Key, Value = g.Average(s => s.value) )
.ToList();

Above 通过在分组中使用修改后的时间戳来实现这一点,它将分钟设置为前 5 分钟的边界并删除秒和毫秒。当然,同样的方法也可以用于其他时间段,即小时和天。

编辑:

基于此组成的示例输入:

var series = new List<Sample>();
series.Add(new Sample()  timestamp = DateTime.Now.AddMinutes(3) );
series.Add(new Sample()  timestamp = DateTime.Now.AddMinutes(4) );
series.Add(new Sample()  timestamp = DateTime.Now.AddMinutes(5) );
series.Add(new Sample()  timestamp = DateTime.Now.AddMinutes(6) );
series.Add(new Sample()  timestamp = DateTime.Now.AddMinutes(7) );
series.Add(new Sample()  timestamp = DateTime.Now.AddMinutes(15) );

为我制作了 3 组,一组时间戳为 3:05,一组为 3:10,一组为下午 3:20(您的结果可能因当前时间而异)。

【讨论】:

您的新时间跨度和项目的可用时间跨度有什么区别?你只是改变了偏见。 @AliAmiri:它通过为所有这些项目返回相同的时间戳将属于相同 5 分钟间隔的项目分组到同一组中 - 这不是 OP 的意图吗? 我不这么认为。您只需将它们移到 5 分钟前(我也不知道 OP 想要做您试图展示的事情)。 @AliAmiri:我对此进行了测试,它适用于分组 - 请注意,它使用 %(模数)并且它不仅仅是将样本移动到 5 分钟前。 谢谢 (+1),但发现了一个问题,我发现我的 DateTimes 具有相同的年、月、分、秒和毫秒,但有不同的刻度。注:它们来自 EF 从数据库中提取的 DateTimeOffsets。因此(亚毫秒差异)我建议使用 new DateTime() 来确保您的 DateTimes 确实被认为是相等的。见***.com/a/27234349/661584【参考方案2】:

我玩这个游戏很晚,但是我在寻找其他东西时遇到了这个问题,我认为我有更好的方法。

series.GroupBy (s => s.timestamp.Ticks / TimeSpan.FromHours(1).Ticks)
        .Select (s => new 
            series = s
            ,timestamp = s.First ().timestamp
            ,average = s.Average (x => x.value )
        ).Dump();

这是一个示例 linqpad 程序,您可以验证和测试

void Main()

    List<Sample> series = new List<Sample>();

    Random random = new Random(DateTime.Now.Millisecond);
    for (DateTime i = DateTime.Now.AddDays(-5); i < DateTime.Now; i += TimeSpan.FromMinutes(1))
    
        series.Add(new UserQuery.Sample() timestamp = i, value = random.NextDouble() * 100 );
    
    //series.Dump();
    series.GroupBy (s => s.timestamp.Ticks / TimeSpan.FromHours(1).Ticks)
        .Select (s => new 
            series = s
            ,timestamp = s.First ().timestamp
            ,average = s.Average (x => x.value )
        ).Dump();


// Define other methods and classes here
public class Sample

     public DateTime timestamp;
     public double value;

【讨论】:

【参考方案3】:

要按小时分组,您需要按时间戳的小时部分分组,可以这样做:

var groups = from s in series
  let groupKey = new DateTime(s.timestamp.Year, s.timestamp.Month, s.timestamp.Day, s.timestamp.Hour, 0, 0)
  group s by groupKey into g select new
                                      
                                        TimeStamp = g.Key,
                                        Value = g.Average(a=>a.value)
                                      ;

【讨论】:

【参考方案4】:

我建议使用 new DateTime()避免任何亚毫秒级差异的问题

var versionsGroupedByRoundedTimeAndAuthor = db.Versions.GroupBy(g => 
new

                UserID = g.Author.ID,
                Time = RoundUp(g.Timestamp, TimeSpan.FromMinutes(2))
);

  private DateTime RoundUp(DateTime dt, TimeSpan d)
        
            return new DateTime(((dt.Ticks + d.Ticks - 1) / d.Ticks) * d.Ticks);
        

注意我在这里按 Author.ID 以及四舍五入的时间戳进行分组。

RoundUp 函数取自 @dtb answer here https://***.com/a/7029464/661584

在Why does this unit test fail when testing DateTime equality?Why does this unit test fail when testing DateTime equality? 阅读有关如何精确到毫秒的平等并不总是意味着平等

【讨论】:

【参考方案5】:

我改进了 BrokenGlass 的答案,使其更通用并增加了保护措施。根据他目前的回答,如果您选择 9 的间隔,它不会达到您的预期。对于任何不能被 60 整除的数字也是如此。对于此示例,我使用 9 并从午夜 (0:00) 开始。

如您所料,从 0:00 到 0:08.999 的所有内容都将被放入一组 0:00。它将继续这样做,直到您到达从 0:54 开始的分组。 在 0:54,它只会对 0:54 到 0:59.999 的内容进行分组,而不是向上到 01:03.999。

对我来说,这是一个大问题。

我不确定如何解决这个问题,但您可以添加保护措施。 变化:

    60 % [间隔] 等于 0 的任何分钟都是可接受的间隔。下面的 if 语句保护了这一点。

    小时间隔也可以。

            double minIntervalAsDouble = Convert.ToDouble(minInterval);
            if (minIntervalAsDouble <= 0)
            
                string message = "minInterval must be a positive number, exiting";
                Log.getInstance().Info(message);
                throw new Exception(message);
            
            else if (minIntervalAsDouble < 60.0 && 60.0 % minIntervalAsDouble != 0)
            
                string message = "60 must be divisible by minInterval...exiting";
                Log.getInstance().Info(message);
                throw new Exception(message);
            
            else if (minIntervalAsDouble >= 60.0 && (24.0 % (minIntervalAsDouble / 60.0)) != 0 && (24.0 % (minIntervalAsDouble / 60.0) != 24.0))
            
                //hour part must be divisible...
                string message = "If minInterval is greater than 60, 24 must be divisible by minInterval/60 (hour value)...exiting";
                Log.getInstance().Info(message);
                throw new Exception(message);
            
            var groups = datas.GroupBy(x =>
            
                if (minInterval < 60)
                
                    var stamp = x.Created;
                    stamp = stamp.AddMinutes(-(stamp.Minute % minInterval));
                    stamp = stamp.AddMilliseconds(-stamp.Millisecond);
                    stamp = stamp.AddSeconds(-stamp.Second);
                    return stamp;
                
                else
                
                    var stamp = x.Created;
                    int hourValue = minInterval / 60;
                    stamp = stamp.AddHours(-(stamp.Hour % hourValue));
                    stamp = stamp.AddMilliseconds(-stamp.Millisecond);
                    stamp = stamp.AddSeconds(-stamp.Second);
                    stamp = stamp.AddMinutes(-stamp.Minute);
                    return stamp;
                
            ).Select(o => new
            
                o.Key,
                min = o.Min(f=>f.Created),
                max = o.Max(f=>f.Created),
                o
            ).ToList();
    

在 select 语句中添加您想要的任何内容!我输入了 min/max,因为它更容易测试。

【讨论】:

【参考方案6】:

即使我真的迟到了,这是我的 2 美分:

我想以 5 分钟为间隔将时间值向上和向下取整:

10:31 --> 10:30
10:33 --> 10:35
10:36 --> 10:35

这可以通过转换为 TimeSpan.Tick 并转换回 DateTime 并使用 Math.Round() 来实现:

public DateTime GetShiftedTimeStamp(DateTime timeStamp, int minutes)

    return
        new DateTime(
            Convert.ToInt64(
                Math.Round(timeStamp.Ticks / (decimal)TimeSpan.FromMinutes(minutes).Ticks, 0, MidpointRounding.AwayFromZero)
                    * TimeSpan.FromMinutes(minutes).Ticks));

shiftedTimeStamp 可用于 linq 分组,如上所示。

【讨论】:

【参考方案7】:

我知道这并不能直接回答这个问题,但我在谷歌上搜索了一个非常相似的解决方案,以将股票/加密货币的蜡烛数据从更短的时间段聚合到更高的分钟时间段(5、10、15 , 30)。您不能简单地从当前分钟一次返回 X,因为聚合期间的时间戳将不一致。您还必须注意在列表的开头和结尾有足够的数据来填充较大周期的完整烛台。鉴于此,我想出的解决方案如下。 (它假设较小时期的蜡烛,如 rawPeriod 所示,按时间戳升序排序。)

public class Candle

    public long Id  get; set; 
    public Period Period  get; set; 
    public DateTime Timestamp  get; set; 
    public double High  get; set; 
    public double Low  get; set; 
    public double Open  get; set; 
    public double Close  get; set; 
    public double BuyVolume  get; set; 
    public double SellVolume  get; set; 


public enum Period

    Minute = 1,
    FiveMinutes = 5,
    QuarterOfAnHour = 15,
    HalfAnHour = 30


    private List<Candle> AggregateCandlesIntoRequestedTimePeriod(Period rawPeriod, Period requestedPeriod, List<Candle> candles)
    
        if (rawPeriod != requestedPeriod)
        
            int rawPeriodDivisor = (int) requestedPeriod;
            candles = candles
                        .GroupBy(g => new  TimeBoundary = new DateTime(g.Timestamp.Year, g.Timestamp.Month, g.Timestamp.Day, g.Timestamp.Hour, (g.Timestamp.Minute / rawPeriodDivisor) * rawPeriodDivisor , 0) )
                        .Where(g => g.Count() == rawPeriodDivisor )
                        .Select(s => new Candle
                        
                            Period = requestedPeriod,
                            Timestamp = s.Key.TimeBoundary,
                            High = s.Max(z => z.High),
                            Low = s.Min(z => z.Low),
                            Open = s.First().Open,
                            Close = s.Last().Close,
                            BuyVolume = s.Sum(z => z.BuyVolume),
                            SellVolume = s.Sum(z => z.SellVolume),
                        )
                        .OrderBy(o => o.Timestamp)
                        .ToList();
        

        return candles;
    

【讨论】:

【参考方案8】:

一个通用的解决方案:

    static IEnumerable<IGrouping<DateRange, T>> GroupBy<T>(this IOrderedEnumerable<T> enumerable, TimeSpan timeSpan, Func<T, DateTime> predicate)
    
        Grouping<T> grouping = null;
        foreach (var (a, dt) in from b in enumerable select (b, predicate.Invoke(b)))
        
            if (grouping == null || dt > grouping.Key.End)
                yield return grouping = new Grouping<T>(new DateRange(dt, dt + timeSpan), a);
            else
                grouping.Add(a);
        
    

    class Grouping<T> : IGrouping<DateRange, T>
    

        readonly List<T> elements = new List<T>();

        public DateRange Key  get; 

        public Grouping(DateRange key) => Key = key;

        public Grouping(DateRange key, T element) : this(key) => Add(element);

        public void Add(T element) => elements.Add(element);

        public IEnumerator<T> GetEnumerator()=> this.elements.GetEnumerator();

        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    

    class DateRange
    
    
        public DateRange(DateTime start, DateTime end)
        
            this.Start = start;
            this.End = end;
        

        public DateTime Start  get; set; 
        public DateTime End  get; set; 
    

基于问题的测试(使用 AutoFixture 库)

     void Test()
    
        var many = new Fixture().CreateMany<Sample>(100);

        var groups = many.OrderBy(a => a.timestamp).GroupBy(TimeSpan.FromDays(365), a => a.timestamp).Select(a => a.Average(b => b.value)).ToArray();

    

    public class Sample
    
        public DateTime timestamp;
        public double value;
    

【讨论】:

以上是关于LINQ 按时间段聚合和分组的主要内容,如果未能解决你的问题,请参考以下文章

Linq to SQL:如何在没有分组的情况下进行聚合?

如何使用 Linq 按日期时间和平均结果对字典进行分组

LINQ - 按多个键分组未给出预期结果

在 PromQL/MetricsQL 中按时间分组和聚合

Mysql按聚合排序和限制分组[重复]

LINQ使用中碰到的问题------按时间分组