头图

大家好,我是“蒋点数分”,多年以来一直从事数据分析工作。从今天开始,与大家持续分享关于数据分析的学习内容。

本文是第 6 篇,也是【SQL 周周练】系列的第 5 篇。该系列是挑选或自创具有一些难度的 SQL 题目,一周至少更新一篇。后续创作的内容,初步规划的方向包括:

后续内容规划

1.利用 Streamlit 实现 Hive 元数据展示SQL 编辑器、 结合Docker 沙箱实现数据分析 Agent
2.时间序列异常识别、异动归因算法
3.留存率拟合、预测、建模
4.学习 AB 实验、复杂实验设计等
5.自动化机器学习、自动化特征工程
6.因果推断学习
7.……

欢迎关注,一起学习。

第 5 期题目

题目来源:改编题目,在某 Excel 论坛看到的;求助希望使用 VBA 处理(求助者自称手工处理,上千条数据就需要做一天)。我参照他的数据格式改编为 SQL 题目(我的数据完全由自己模拟生成,并没有使用求助者的数据)

一、题目介绍

有一张记录了电表缴费数据的表,缴费的时间间隔是不固定的。领导希望将这张表重新拆解按照月份来汇总,分析每月的日均使用电量等等。求助者的原始数据还有站点编码、电表号、供电类型等等,我这里将问题简化,省略这些信息,就假设只有一个电表。

注:只有缴费区间的总电量,没有每日电量;利用平均值拆分到每天再根据不同区间汇总到自然月。针对最开始的日期和结束日期,如果不满整月,就按照实际存在的日期区间来计算(时间维度按日来处理,不考虑小时等更精细的级别 | 求助者给的数据也是精确到日期而不是小时)

列名数据类型注释
period_startstring缴费区间开始日期
period_endstring缴费区间结束日期
electricity_usageint这段区间使用的电量
daily_electricity_usageint这段区间的日平均电量
(本应该设置为浮点类型,但是我用整数生成的)
这字段本来也没必要存在,只不过省得在 SQL 再算一遍
periodint这段日期有几天(没啥用,我生成数据后二次校验用的)

部分样例数据(完整生成逻辑参见第三节)

period_startperiod_endelectricity_usagedaily_electricity_usageperiod
2023-12-012023-12-301470049030
2023-12-312024-01-06420607
2024-01-072024-01-072302301
2024-01-082024-02-041316047028
...............
2025-02-112025-02-24336024014
2025-02-252025-03-10602043014

二、题目思路

想要答题的同学,可以先思考答案🤔。
.……

.……

.……

我来谈谈我的思路,这道题目要解决两个问题点:

1.根据整个电费缴纳涵盖的区间,生成自然月的“维度表”。暴力的方法,那就是取最大最小日期,然后构造一个递增序列求出每一天,再分组/去重汇总成月,但是太低效了。直接的方法是获取最大最小日期之间有多少个月,然后 add_months 加上去,除了最大最小日期的端点,取出每一个月的 1 号和当月最后一天

是不是一定要做这一步?实际工作中可以使用固定日期范围或者数仓里现成的维度表 dim_date 等等。但是动态日期维度表生成一方面更符合这个场景的逻辑;另一方面也是笔试的考点,只不过动态生成每一天日期更常见。

2.根据上一步获取的自然月区间,来与缴费区间关联,关联的条件是存在交集。这又涉及到两点,第一是如何判断有交集(包括哪怕只有端点一天相交),第二是如何取交集区间的数据来进行后续的计算

(目前【SQL 周周练】系列默认讨论 Hive,但很多思路可以移植到其他 SQL 方言;我了解到诸如 OraclePostgre 是有非常多函数的,可能相对于 Hive 会有更直接更好用的方法 | SparkSQL 一般都能兼容 Hive,后期我会逐步拓展到 MySQLDuckDBDoris/Starrocks

下面,我用 NumPyScipy 生成模拟的数据集:

三、用 Python 生成模拟数据

只关心 SQL 代码的同学,可以跳转到第四节(我在工作中使用 Hive 较多,因此采用 Hive 的语法)

模拟代码如下:

  1. 构造日期间隔,并将顺序随机打乱,作为“缴费”日期区间:
import numpy as np
import pandas as pd
from datetime import datetime, timedelta

# 确定随机数种子
np.random.seed(2025)

# 构造日期间隔
date_interval_list = [1, 3, 7, 14, 15, 28, 30, 45, 90] * 2

# 打乱顺序,注意它是直接修改没有返回
np.random.shuffle(date_interval_list)  
print(date_interval_list)
  1. 随机抽样生成“缴费”区间的日耗电量,构造“缴费”区间的起始日期、结束日期。将前面生成的数据转为 pd.DataFrame,并输出为 csv 文件:
power_consumption = range(50, 510, 10)

# 随机抽取数值,作为缴费区间的平均日消耗电量
daily_electricity_usage = np.random.choice(
    power_consumption, size=len(date_interval_list), replace=False
)

df = pd.DataFrame(
    {
        "period_start": [
            datetime(2023, 12, 1) + timedelta(days=sum(date_interval_list[:i]) )
            for i in range(len(date_interval_list))
        ],
        "period_end": [
            datetime(2023, 12, 1) + timedelta(days=sum(date_interval_list[:i])-1)
            for i in range(1, len(date_interval_list) + 1)
        ],
        "electricity_usage": date_interval_list * daily_electricity_usage,
        "daily_electricity_usage": daily_electricity_usage,
        "period": date_interval_list,
    }
)
# 在 Jupyter 环境中显示 dataframe,其他环境执行可能报错
display(df)

out_csv_path = "dwd_electricity_usage_records.csv"
df.to_csv(out_csv_path, index=False, header=False, encoding="utf-8-sig")
  1. 如果表存在则删除,创建新的 Hive 表,并将数据 load 到表中:
from pyhive import hive

# 配置连接参数
host_ip = "127.0.0.1"
port = 10000
username = "Jiang"


with hive.Connection(host=host_ip, port=port) as conn:
    cursor = conn.cursor()

    hive_table_name = 'data_exercise.dwd_electricity_usage_records'

    drop_table_sql = f"""
    drop table if exists {hive_table_name}
    """

    print('删除表语句:\n', drop_table_sql)

    cursor.execute(drop_table_sql)

    create_table_sql = f"""
    create table if not exists `{hive_table_name}` (
        period_start string comment '费用期始',
        period_end string comment '费用期终',
        electricity_usage int comment '计费期间总用电量',
        daily_electricity_usage int comment '计费期间日均电量(用来核对结果)',
        period int comment '期间天数'
    )
    comment "不定期的电表计费数据 | author: 蒋点数分 | 文章编号:04d08f61"
    row format delimited fields terminated by ','
    stored as textfile
    """

    print("创建表语句:\n", create_table_sql)
    cursor.execute(create_table_sql)

    import os

    load_data_sql = f"""
    load data local inpath "{os.path.abspath(out_csv_path)}"
    overwrite into table {hive_table_name}
    """

    print("覆盖写入数据语句:\n", load_data_sql)
    cursor.execute(load_data_sql)

    cursor.close()
我通过使用 PyHive 包实现 Python 操作 Hive。我个人电脑部署了 HadoopHive,但是没有开启认证,企业里一般常用 Kerberos 来进行大数据集群的认证。

四、SQL 解答

我使用 CTE 的语法,这样将步骤串行展示,逻辑比较清晰,下面分成几部分解释 SQL 语句:

1.这部分代码的逻辑是,先求出所有缴费区间最大最小的日期;然后利用求出来两个日期之间的有多少自然月(包含两个端点所在的自然月);有的同学会问,Hive 中是存在 months_between 的,为什么你不用,而要自己“年-年”、“月-月”的这么算?当然可以用,但是要额外处理一下,此代码块后面,我解释一下,如何额外处理:

with get_date_interval as (
    -- 获取总体的开始、结束日期:这里只是一个电表
    -- 多个电表注意后续的逻辑该带分组的分组
    select
      min(period_start) as all_start
    , max(period_end) as all_end
    from data_exercise.dwd_electricity_usage_records
)
, get_inner_months as (
    -- 获取两个日期之间有多少个月;“年-年”*12“+ 月-月”
    -- 请参看文章的解释;如果按照“数数”的规则,注意结果加 1
    -- 如果要用 months_between 函数,该怎么处理?
    -- 根据官方文档,如果 两个日期的 day 部分不一样或者不是当月的最后一天
    -- 则是按照 31 天的月份来计算小数部分 `fractional portion`
    -- 注意 months_between 返回的小数,要取整
    select
      all_start, all_end
    , (year(all_end) - year(all_start))*12 + month(all_end) - month(all_start) + 1 as month_cnt
    -- , int(months_between(last_day(all_start), last_day(all_start)))+1 as month_cnt
    from get_date_interval
)
……

关于 months_between 函数,我查看了 Hive 的官方文档,人家是这么说的 —— “If date1 and date2 are either the same days of the month or both last days of months, then the result is always an integer. Otherwise the UDF calculates the fractional portion of the result based on a 31-day month and considers the difference in time components date1 and date2.” 翻译后是 “如果 date1 和 date2 在当月的日期是相同的,或者都是各自月份的最后一天 ,那么结果始终是一个整数。否则,该函数会基于一个31天的月份来计算结果的小数部分,并考虑 date1 和 date2 的时间部分差异。”

我没有看源代码,但是这里面说的是 fractional portion 小数部分按照 31 天的月来处理,什么意思呢?我稍微实验了一下,小数部分应该就是 (day(...)-day(...))/31(负数就向前借位),它的结果是四舍五入保留 8 位数。我们想避开这个小数,就想让它返回整数月份怎么办呢(因为情况比较多,单独用向上取整、向下取整或者四舍五入取整都有问题,要综合判断,直接返回整数月份最好)

我这里的最大最小日期,大概率 day 部分是不一样的,也不太可能都是每个月份的最后一天。这样会涉及小数,那么咱手工处理一下 —— 比如将最大最小日期改成当月的最后一天,这个很简单,使用 last_day 函数即可;还可以改成当月的第一天,使用 trunc(...,'MM'),也满足使用条件。甚至咱追求个性,将日期的年月部分取出来,日部分都写个 5 号、13 号也可以,只要不超过 28 号,就不用考虑日期合法性问题。

图片里 m1m4 的写法都是可以的,只不过后面要转换为整数。

2.使用 repeatsplit 加上 posexplode 构造一个指定长度的递增序列,这也是 Hive 的常见技巧。explode 更常见,posexplode 就是多返回一个“位置”列。(我个人电脑部署的 Hive 版本是 3.1.3,我验证过没有 sequence 函数)。如果根据日期取某个自然月的 1 号和最后一天,上一小节我已经解释过;注意最早最晚日期根据我题目的定义,要按照实际日期来计算:

……
, get_every_month_start as (
    select
     (case
        -- 对于非开始日期的那个月,取当月的 1 号
        when idx > 0 then add_months(trunc(g.all_start, 'MM'), idx)
        -- 如果是开始日期,直接用开始日期,不管它是不是当月的 1 号
        when idx = 0 then g.all_start 
     end) as month_start
    , all_end
    from get_inner_months g
    lateral view posexplode(split(repeat(',', month_cnt-1), ',')) month_table as idx, m
)
, get_every_month_end as (
    select
      month_start
      -- 如果是结束日期的那个月,结束日期就取截止日期
      -- 否则取每个月的最后一天,即 last_day
    , if(date_format(month_start, 'yyyy-MM') = date_format(all_end, 'yyyy-MM'), all_end,
    last_day(month_start)) as month_end
    from get_every_month_start
)
……

3.这一步是将前面构造的自然月维度表 get_every_month_end 与电费数据集,进行关联。关联的条件是判断有交集,为什么这两个条件可以保证有交集,我在代码下面用图片结合文字论述。另外就是当两者有交集的情况下,取交集的逻辑是 —— 起点选择最晚 greatest 的那个,终点选择最早 least 的那个。

Hive 非常早期的版本是不支持不等值连接的。这个时候要小心处理 on 后面的条件,如果是 inner join 一般将 on 后面的条件移到 where 中,而 outer join 如果 on 不匹配,只是代表关联的表没有匹配的数据,可以理解为给你一个 null;比如拿 left outer join 举例,左表的数据是不会被 on 条件过滤掉的,如果 on 能导致一条数据匹配多条,左表的部分数据甚至全部数据还会翻倍上涨。这个时候,不能将 on 的条件直接移到 where 中,而是需要保持 on 里面的最粗糙的匹配逻辑(这个场景不行,因为两个条件都是不等值,需要人工造一个无用的关联),在 select 中对于右表的列,在使用时进行 on 条件的完整判断 if,如果满足,返回右表列的值;否则返回 null。这种方法缺点是每一个用到右表的列,都需要这么处理。

……
, get_interval_power_usage as (
    select
      e.month_start as month_start
    , e.month_end as month_end
    -- 注意取两个日期区间的交集
    -- 非常重要,别忘了取区间的交集
    -- 起点取最后面的那个,终点取最早的那个
    , greatest(r.period_start,e.month_start) as period_start
    , least(r.period_end,e.month_end) as period_end
    , nvl(r.daily_electricity_usage,0) as daily_electricity_usage
    from get_every_month_end e
    left outer join data_exercise.dwd_electricity_usage_records r
        on r.period_end >= e.month_start
        and r.period_start <= e.month_end
)
……

4.这是最后求结果的 SQL 部分,注意前面取交集后,会划分称成若干“子”区间,每个区间计算时用原来的日均值乘以区间长度(注意 datediff 结果加 1)计算该区间的总电量消耗。group by 加和整月的电量,整月的长度可以用子区间长度叠加 days_cnt 列,也可以直接求 days_cnt2 列,注意我这里的 month_startmonth_end 在最开始与最晚日期是根据实际日期定义的,因此可以用 days_cnt2 的逻辑。

另外,这里只靠 month_start 就足以标记统计的自然月,没必要将 month_end 也作为 group by 的分组标识,用 max/min 当成维度值处理,这也是最基本的技巧。(我已经验证Hive 3.1.3 也没有 any_value)在数据量很大时或者分组 key 比较多时,有助于提高计算速度、减少资源消耗:

……
select
  month_start
, max(month_end) as month_end
  
, sum(daily_electricity_usage * (datediff(period_end, period_start)+1)) 
    / sum(datediff(period_end, period_start)+1) as new_daliy_usage
-- 用哪个做分母都行    
-- , sum(datediff(period_end, period_start)+1) as days_cnt
--, max(datediff(month_end, month_start)+1) as days_cnt2
from get_interval_power_usage
group by month_start 

5.完整的 SQL 语句:

with get_date_interval as (
    -- 获取总体的开始、结束日期:这里只是一个电表
    -- 多个电表注意后续的逻辑该带分组的分组
    select
      min(period_start) as all_start
    , max(period_end) as all_end
    from data_exercise.dwd_electricity_usage_records
)
, get_inner_months as (
    -- 获取两个日期之间有多少个月;为什么不用 months_between
    -- 请参看文章的解释;如果按照“数数”的规则,注意结果加 1
    select
      all_start, all_end
    , (year(all_end) - year(all_start))*12 + month(all_end) - month(all_start) + 1 as month_cnt
    from get_date_interval
)
, get_every_month_start as (
    select
     (case
        -- 对于非开始日期的那个月,取当月的 1 号
        when idx > 0 then add_months(trunc(g.all_start, 'MM'), idx)
        -- 如果是开始日期,直接用开始日期,不管它是不是当月的 1 号
        when idx = 0 then g.all_start 
     end) as month_start
    , all_end
    from get_inner_months g
    lateral view posexplode(split(repeat(',', month_cnt-1), ',')) month_table as idx, m
)
, get_every_month_end as (
    select
      month_start
      -- 如果是结束日期的那个月,结束日期就取截止日期
      -- 否则取每个月的最后一天,即 last_day
    , if(date_format(month_start, 'yyyy-MM') = date_format(all_end, 'yyyy-MM'), all_end,
    last_day(month_start)) as month_end
    from get_every_month_start
)
, get_interval_power_usage as (
    select
      e.month_start as month_start
    , e.month_end as month_end
    -- 注意取两个日期区间的交集
    -- 非常重要,别忘了取区间的交集
    -- 起点取最后面的那个,终点取最早的那个
    , greatest(r.period_start,e.month_start) as period_start
    , least(r.period_end,e.month_end) as period_end
    , r.daily_electricity_usage as daily_electricity_usage
    from get_every_month_end e
    left outer join data_exercise.dwd_electricity_usage_records r
        on r.period_end >= e.month_start
        and r.period_start <= e.month_end
)

select
  month_start
, max(month_end) as month_end
  
, sum(daily_electricity_usage * (datediff(period_end, period_start)+1)) 
    / sum(datediff(period_end, period_start)+1) as new_daliy_usage
-- 用哪个做分母都行    
-- , sum(datediff(period_end, period_start)+1) as days_cnt
--, max(datediff(month_end, month_start)+1) as days_cnt2
from get_interval_power_usage
group by month_start


😁😁😁
我现在正在求职数据类工作(主要是数据分析或数据科学);如果您有合适的机会,即时到岗,不限城市。


蒋点数分
1 声望0 粉丝

有多年多份数据分析工作经历,当前失业中。如果您觉得我的文章还不错,求推荐工作