※ 예제 데이터 및 이전 자료 참고
예제 데이터 출처
[SQL 코호트 분석] 2. 리텐션 분석 실습 (1)
코호트 분석에서 시계열 데이터를 다룰 때, 다음을 확인해야 한다.
입법가 데이터셋에서 의원의 임기 시작 날짜는 있지만, 2년 또는 6년과 같이 의원직을 수행하는 "기간"에 대한 데이터가 손실된 상태이다.
실제로 "재임한 기간"에 대한 리텐션 분석의 정확도를 높인다.
12월 31일에 재임 중이라면 "해당 연도에 재임 중인 의원"으로 간주한다.
임기 시작을 의미하는 term_start 필드는 대부분 1월에 몰려 있지만, 다른 날짜들도 있다. 따라서, 각기 다른 임기 시작 날짜를 정규화하기 위해 12월 31일을 기준으로 판단할 것이다.
먼저, 날짜 차원 테이블을 생성한다. 이는 다음과 같은 장점을 갖는다.
-- 날짜 차원(date dimension) 테이블 생성
drop table if exists public.date_dim;
create table public.date_dim
as
select date::date
, to_char(date,'yyyymmdd')::int as date_key
, date_part('day',date)::int as day_of_month
, date_part('doy',date)::int as day_of_year
, date_part('dow',date)::int as day_of_week
, trim(to_char(date, 'day')) as day_name
, trim(to_char(date, 'dy')) as day_short_name
, date_part('week',date)::int as week_number
, to_char(date,'w')::int as week_of_month
, date_trunc('week',date)::date as week
, date_part('month',date)::int as month_number
, trim(to_char(date, 'month')) as month_name
, trim(to_char(date, 'mon')) as month_short_name
, date_trunc('month',date)::date as first_day_of_month
, (date_trunc('month',date) + interval '1 month' - interval '1 day')::date as last_day_of_month
, date_part('quarter',date)::int as quarter_number
, trim('q' || date_part('quarter',date)::int) as quarter_name
, date_trunc('quarter',date)::date as first_day_of_quarter
, (date_trunc('quarter',date) + interval '3 months' - interval '1 day')::date as last_day_of_quarter
, date_part('year',date)::int as year
, date_part('decade',date)::int * 10 as decade
, date_part('century',date)::int as century
from generate_series('1770-01-01'::date, '2020-12-31'::date, '1 day') as date
의원별 연차(period)를 계산하는 SQL문은 다음과 같다.
-- 시계열 데이터 결측 보간을 통한 리텐션 정확도 향상
select a.id_bioguide, a.first_term,
b.term_start, b.term_end,
c.date,
date_part('year', age(c.date, a.first_term)) as period
from ( -- 의원별 첫 임기 시작일 추출
select id_bioguide, min(term_start) as first_term
from legislators_terms
group by 1
) a
join legislators_terms b on a.id_bioguide = b.id_bioguide
left join date_dim c on c.date between b.term_start and b.term_end -- c.date가 해당 의원의 임기 기간에 속하는지
and c.month_name = 'December' and c.day_of_month = 31
✅ 결과 해석
B000944 의원을 보자.
- first_term은 1993-01-05으로 이는 첫 임기 시작일이다.
- term_start ~ term_end = 1993-01-05 ~ 1995-01-03으로 해당 기간 의원으로 근무했다.
- date가 1993-12-31일 때, 해당 의원은 근무중이었지만 1993-12-31 기준으로 대략 11개월차이므로 period는 0이다.
- date가 1994-12-31일 때, 해당 의원은 근무중이었고 1994-12-31 기준으로 대략 1년 11개월차이므로 period는 1이다.
⭐ term_start와 term_end는 고정되지만, period가 달라지면 행이 달라진다.
이렇게 만들어진 데이터는 의원 활동을 연도별로 세분화하고 시계열 분석에 활용할 수 있다.
select id_bioguide, min(term_start) as first_term
from legislators_terms
group by 1
join legislators_terms b on a.id_bioguide = b.id_bioguide
의원별 첫 임기 정보(a)와 모든 임기 정보(b)를 연결한다.
left join date_dim c on c.date between b.term_start and b.term_end and c.month_name = 'December' and c.day_of_month = 31
select a.id_bioguide, a.first_term,
b.term_start, b.term_end,
c.date,
date_part('year', age(c.date, a.first_term)) as period
1-2 에서 12월 31일을 기준으로 하는 의원별 재임 연도(연차)를 date 필드에 출력했다.
date 필드의 연차를 구간으로 삼아 id_bioguide의 개수를 세어 리텐션을 계산한다.
-- 재임 연도(연차)를 의미하는 date를 기준 구간으로 한 코호트 크기 계산
select
coalesce(date_part('year', age(c.date, a.first_term)), 0) as period
,count(distinct a.id_bioguide) as cohort_retained
from
(
select id_bioguide, min(term_start) as first_term
from legislators_terms
group by 1
) a
join legislators_terms b on a.id_bioguide = b.id_bioguide
left join date_dim c on date between b.term_start and b.term_end
and c.month_name = 'December' and c.day_of_month = 31
group by 1
select
period
,first_value(cohort_retained) over (order by period) as cochort_size
,cohort_retained
,cohort_retained * 1.0 / first_value(cohort_retained) over (order by period) as pct_retained
from
(
select
coalesce(date_part('year', age(c.date, a.first_term)), 0) as period
,count(distinct a.id_bioguide) as cohort_retained
from
(
select id_bioguide, min(term_start) as first_term
from legislators_terms
group by 1
) a
join legislators_terms b on a.id_bioguide = b.id_bioguide
left join date_dim c on date between b.term_start and b.term_end
and c.month_name = 'December' and c.day_of_month = 31
group by 1
) aa
※ 코호트 크기, 리텐션 비율, 윈도우 함수 등에 대한 자세한 내용은 앞선 글을 참고해주세요.
📌 시행착오 기록
select
coalesce(date_part('year', age(c.date, a.first_term)), 0) as period
,count(distinct a.id_bioguide) as cohort_retained
,count(distinct a.id_bioguide) * 1.0 / first_value(count(distinct a.id_bioguide)) over (order by coalesce(date_part('year', age(c.date, a.first_term)), 0)) as pct_retained
from
(
select id_bioguide, min(term_start) as first_term
from legislators_terms
group by 1
) a
join legislators_terms b on a.id_bioguide = b.id_bioguide
left join date_dim c on date between b.term_start and b.term_end
and c.month_name = 'December' and c.day_of_month = 31
group by 1
select절에서 period, cohort_retained를 정의해도 같은 select절 내에서 별칭으로 사용할 수 없기 때문에 쿼리의 가독성이 떨어진다.
이를 해결하기 위해서는 period, cohort_retained를 정의해주는 서브쿼리를 from절에 작성해야 한다.
✅ 결과 해석
period가 1에서 2로 될 때, 리텐션 비율이 급락한다.
미국 국회의원의 임기가 2년 또는 6년이므로 대부분 선출되고 다음 해인 1번 구간까지 재임한다.
이후 재선에 실패하거나 도전하지 않는 국회의원이 많기 때문에 period가 급락한다.
➡️ 리텐션 분석 결과는 전보다 더 정확해졌다.
의원의 임기, 서비스 구독 기간처럼 이벤트의 유지 기간을 이미 알고 있는 경우, 시작 날짜에 고정된 인터벌을 추가한다.
종료 날짜 = 시작 날짜 + 고정된 인터벌
select
a.id_bioguide, a.first_term
,b.term_start
,case when b.term_type = 'rep' then b.term_start + interval '2 years'
when b.term_type = 'sen' then b.term_start + interval '6 years'
end as term_end
from
( -- 의원별 첫 임기 시작일
select id_bioguide, min(term_start) as first_term
from legislators_terms
group by 1
) a
join legislators_terms b on a.id_bioguide = b.id_bioguide
case
when b.term_type = 'rep' then b.term_start + interval '2 years'
when b.term_type = 'sen' then b.term_start + interval '6 years'
end as term_end
임기 기간은 하원 의원(rep) 2년, 상원 의원(sen) 6년이다.
interval의 용법
- interval은 날짜차, 시간차를 나타내는 SQL 데이터 타입이다.
- interval은 숫자와 기간(예: years, months, days, hours)을 조합하여 기간을 표현할 수 있다.
- 날짜와 interval 타입 간의 계산은 SQL이 내부적으로 처리하므로 사용자는 간단히 +, - 연산자로 기간 계산을 수행할 수 있다.
2 years
- '2 years'는 interval 타입으로 변환 가능한 리터럴 값(literal)이다.
- SQL 동작으로 '2 years'처럼 작성된 문자열 리터럴이 interval 타입으로 자동 변환된다.
시작 날짜에 고정된 인터벌을 추가해 종료 날짜로 활용하는 해당 방법은 "의원이 정해진 임기를 다 채운다고 가정한다."
따라서, 임기를 다 채우지 못하는 경우를 고려하지 못한다는 단점이 있다.
select
a.id_bioguide, a.first_term
,b.term_start
,lead(b.term_start) over (partition by a.id_bioguide order by b.term_start) - interval '1 day' as term_end
from
(
select id_bioguide, min(term_start) as first_term
from legislators_terms
group by 1
) a
join legislators_terms b on a.id_bioguide = b.id_bioguide
order by 1, 3
lead(b.term_start) over (partition by a.id_bioguide order by b.term_start) - interval '1 day' as term_end
lead 함수를 사용할 경우, 의원직을 한 번만 수행해서 다음 임기가 없는 경우 null을 반환하므로 term_end에 null이 저장된다는 단점이 있다.
의원 임기가 항상 연속적으로 이어진다고 가정한다는 단점이 있다.
이어서, 코호트 그룹을 만드는 방법과 그룹별 리텐션 분석 방법을 알아보자.
💡 질문과 피드백 사항은 댓글에 편하게 남겨주시기 바랍니다.
❤️ 도움이 되셨다면 공감 부탁드립니다.