|

30년의 기후 기억, 파이썬으로 1초 만에 불러내기 (feat. 기상청 평년값 API)


혹시 “오늘 춥네”라고 말할 때, 진짜 ‘얼마나’ 춥다는 건지 아시나요?

일기예보에서 “평년보다 3도 높습니다”라고 할 때, 그 ‘평년’이 뭔지 정확히 아는 분은 많지 않습니다. 하지만 이 평년값이야말로 기후변화를 측정하는 가장 강력한 기준선(Baseline)입니다.

2025년 현재, 전 세계는 이상기후로 신음하고 있습니다. 한국도 예외가 아니죠. “예전엔 이렇지 않았는데…”라는 말이 이제는 데이터로 증명됩니다. 기상청은 30년간의 기후 관측 데이터를 평균한 ‘평년값’을 공개하고 있으며, 이를 통해 우리는 기후가 얼마나 변했는지 객관적으로 확인할 수 있습니다.

오늘 포스팅에서는 기상청 API 시리즈의 심화 편으로, ‘지상관측데이터 평년값(일조, 구름, 기온, 습도) 조회서비스’를 완벽하게 마스터해 보겠습니다. 이전에 다룬 태양광 발전량 예측실시간 날씨 예측 모델에서는 실시간 데이터를 다뤘다면, 오늘은 ‘기준이 되는 데이터’를 확보하는 방법을 배웁니다.

이 API 하나면 1961년부터 2020년까지 60년치 평년값을 언제든지 조회할 수 있으며, 파이썬 코드 몇 줄로 전국의 기후 기준선을 손안에 쥘 수 있습니다!



평년값이란? 왜 30년일까?

평년값(Climatological Normals)은 특정 기후요소를 최근 30년간 평균한 값입니다. 세계기상기구(WMO)의 권고에 따라 10년마다 새로운 평년값을 산출하며, 현재는 1991~2020년 데이터가 최신 평년값입니다.

왜 하필 30년일까요? 그보다 짧으면 우연한 변동성(노이즈)에 휘둘리고, 길면 기후변화 추세를 놓치기 때문입니다. 30년이야말로 ‘기후의 성격’을 파악하기에 최적의 기간이죠.

평년값의 실전 활용 분야

기상청이 제공하는 평년값은 단순한 통계 수치가 아닙니다. 실제로 우리 삶 곳곳에서 활용되고 있습니다:

에너지 산업

  • 태양광 발전량 예측 모델의 Feature Engineering
  • “오늘 발전량이 뚝 떨어졌는데, 기기 고장일까? 아니면 원래 이맘때 흐린 걸까?”
  • 평년값과 비교하면 이상 탐지(Anomaly Detection)가 가능합니다

농업 분야

  • 작물 재배 적기 판단, 냉해·폭염 위험도 평가
  • “벼 파종은 평년 기준 4월 15일 이후가 안전합니다”

건설·인프라

  • 구조물 설계 기준(강설량, 강수 강도 등)
  • 냉난방 용량 설계

방재·보험

  • 홍수·가뭄 예측 기준
  • 기후 리스크 평가 및 보험료 산정

예를 들어, 서울의 1월 평균기온 평년값이 -2.5℃인데 올해 -5℃라면? 이는 “평년보다 2.5℃ 낮은 이상 저온”으로 해석되며, 난방 에너지 수요 급증을 예측할 수 있습니다.

기상청 평년값 API 완전 분해

기상청 API 허브의 산업특화 > 에너지 > 태양광 섹션에서 두 번째 서비스인 ‘지상관측데이터 평년값 조회서비스’를 찾을 수 있습니다.

API 기본 정보

호출 구조

  • URL: https://apihub.kma.go.kr/api/typ01/url/sun_sfc_norm.php
  • Method: GET
  • Data Format: 텍스트 (CSV 유사 형식, JSON 아님 주의!)

제공 데이터

  • 평년값 기준 시기(ST): 1991(1961~1990), 2001(1971~2000), 2011(1981~2010), 2021(1991~2020)
  • 시간 단위(norm): D(일), S(순), M(월), Y(연)
  • 기후 요소: 평균기온, 최고기온, 최저기온, 상대습도, 일조시간, 전운량

주요 입력 파라미터 분석

파라미터설명예시필수 여부
authKeyAPI 인증키발급받은 키 입력필수
stn지점번호108(서울), 159(부산), 0(전체)필수
norm평년값 종류D(일), S(순), M(월), Y(연)필수
tmst평년값 기준 시기2021(1991~2020년)필수
MM1, DD1조회 시작 월·일MM1=1, DD1=1 (1월 1일)필수
MM2, DD2조회 종료 월·일MM2=12, DD2=31 (12월 31일)선택

주요 지점번호

  • 서울(108), 인천(112), 수원(119)
  • 부산(159), 대구(143), 광주(156)
  • 대전(133), 울산(152), 제주(184)

출력 데이터 구조 파악

API 응답은 CSV 형식으로 반환되며, 주석 라인(#으로 시작)과 데이터 라인으로 구성됩니다. JSON 아님을 주의하세요!

응답 예시

###################################################################################################
#  남한 지상 평년값 조회
#  ST     : 평년값 기준 시기 (2001 = 1971~2000년)
#  STN    : 지점번호
#  MM     : 월
#  DD     : 일 (순별 평년값인 경우, 100=상순, 200=중순, 300=하순)
#  TA     : 평균기온(°C)
#  TA_MAX : 최고기온(°C)
#  TA_MIN : 최저기온(°C)
#  HM     : 상대습도(%)
#  SS     : 일조시간(hr)
#  CA_TOT : 전운량(1/10)
###################################################################################################
# ST,STN, MM, DD,    TA,TA_MAX,TA_MIN,    HM,    SS,CA_TOT,=
2001,108,  1,  1,  -1.2,   3.0,  -5.0,  64.2,   4.1,   4.5,=
2001,108,  1,  2,  -1.4,   2.2,  -4.5,  68.7,   4.0,   5.0,=

핵심 컬럼 해설

  • TA: 평균기온 – 기후변화 추세 분석의 핵심 지표
  • TA_MAX/TA_MIN: 최고/최저기온 – 극값 분석에 활용
  • HM: 상대습도 – 체감온도 및 건조 위험도 평가
  • SS: 일조시간 – 태양광 발전량 예측에 필수
  • CA_TOT: 전운량 – 맑은 날/흐린 날 비율 파악

1단계: 초간단 API 호출 (Simple Version)

복잡한 이론은 접어두고, 일단 코드부터 돌려봅시다. 맛을 봐야 요리를 배우죠!

가장 간단한 호출 코드

import requests

# 기상청 API 설정
API_KEY = "YOUR_API_KEY"
base_url = "https://apihub.kma.go.kr/api/typ01/url/sun_sfc_norm.php"

# 파라미터 설정: 서울(108)의 2021년 기준 1월 평년값
params = {
    'authKey': API_KEY,
    'stn': 108,          # 서울
    'norm': 'D',         # 일 평년값
    'tmst': 2021,        # 1991~2020년 평년값
    'MM1': 1,            # 1월
    'DD1': 1,
    'MM2': 1,
    'DD2': 5             # 1월 1~5일
}

# API 호출
response = requests.get(base_url, params=params)
print(response.text)

실행 결과

터미널에 주석과 함께 데이터가 출력됩니다. 이게 바로 30년간 서울의 1월 초 기온 평균입니다!

# ST,STN, MM, DD,    TA,TA_MAX,TA_MIN,    HM,    SS,CA_TOT,=
2021,108,  1,  1,  -1.8,   2.2,  -5.3,  58.3,   5.2,   3.7,=
2021,108,  1,  2,  -1.9,   2.1,  -5.4,  57.1,   5.4,   3.5,=
2021,108,  1,  3,  -1.9,   2.2,  -5.4,  56.7,   5.3,   3.7,=
2021,108,  1,  4,  -1.8,   2.2,  -5.3,  56.8,   5.3,   3.7,=
2021,108,  1,  5,  -1.9,   2.2,  -5.5,  57.0,   5.2,   3.7,=

데이터 끝에 =가 붙어있고, 헤더가 #으로 시작하는 기상청 특유의 독특한 구조입니다. 파싱에 주의해야 합니다!

2단계: 응답 데이터 파싱 (정제된 DataFrame 만들기)

API가 던져주는 날것의 CSV 텍스트를 Pandas DataFrame으로 변환해 봅시다. 기상청 특유의 # 주석과 마지막 = 기호를 제거하는 로직이 핵심입니다.

def parse_normal_data(response_text):
    """
    기상청 평년값 API 응답을 DataFrame으로 변환
    - # 주석 라인 제거
    - 마지막 = 기호 제거
    - 숫자형 변환
    """
    lines = response_text.split('\n')
    data_rows = []
    
    columns = ['ST', 'STN', 'MM', 'DD', 'TA', 'TA_MAX', 'TA_MIN', 'HM', 'SS', 'CA_TOT']
    
    for line in lines:
        line = line.strip()
        
        # 빈 줄이나 주석 건너뛰기
        if not line or line.startswith('#'):
            continue
        
        # 끝에 =, 콤마 제거
        line = line.rstrip('=,').strip()
        
        # 콤마로 분리
        parts = [p.strip() for p in line.split(',')]
        
        # 빈 문자열 제거
        parts = [p for p in parts if p]
        
        # 정확히 10개 필드가 아니면 스킵
        if len(parts) != 10:
            continue
        
        data_rows.append(parts)
    
    # DataFrame 생성
    df = pd.DataFrame(data_rows, columns=columns)
    
    # 숫자형 변환
    numeric_cols = ['ST', 'STN', 'MM', 'DD', 'TA', 'TA_MAX', 'TA_MIN', 'HM', 'SS', 'CA_TOT']
    for col in numeric_cols:
        df[col] = pd.to_numeric(df[col], errors='coerce')
    
    return df

실행

response = requests.get(base_url, params=params)
df = parse_normal_data(response.text)
print(df.head())

결과

     ST  STN  MM  DD   TA  TA_MAX  TA_MIN    HM   SS  CA_TOT
0  2021  108   1   1 -1.8     2.2    -5.3  58.3  5.2     3.7
1  2021  108   1   2 -1.9     2.1    -5.4  57.1  5.4     3.5
2  2021  108   1   3 -1.9     2.2    -5.4  56.7  5.3     3.7
3  2021  108   1   4 -1.8     2.2    -5.3  56.8  5.3     3.7
4  2021  108   1   5 -1.9     2.2    -5.5  57.0  5.2     3.7

이제 엑셀 수준의 가독성을 확보했습니다!

3단계: 클래스로 체계적 구조화 (Advanced)

코드가 길어질수록 유지보수는 지옥이 됩니다. 클래스 기반 설계로 깔끔하게 정리해 봅시다.

import requests
import pandas as pd
from typing import Optional

class KMANormalValueAPI:
    """
    기상청 평년값 API 클라이언트
    - 재사용 가능한 구조
    - 에러 핸들링 강화
    - 데이터 검증 기능 포함
    """
    BASE_URL = "https://apihub.kma.go.kr/api/typ01/url/sun_sfc_norm.php"
    
    def __init__(self, api_key: str):
        self.api_key = api_key
    
    def fetch_norm_data(self, 
                        stn: int,
                        start_date: str,
                        end_date: str,
                        norm: str = 'D',
                        tmst: int = 2021) -> pd.DataFrame:
        """
        평년값 데이터 조회
        
        Parameters:
        - stn: 지점번호 (108=서울, 159=부산, 0=전체)
        - start_date: 'MMDD' 형식 (예: '0101')
        - end_date: 'MMDD' 형식 (예: '1231')
        - norm: 평년값 종류 (D=일, S=순, M=월, Y=연)
        - tmst: 평년값 기준 시기 (2021=1991~2020년)
        
        Returns:
        - DataFrame: 파싱된 평년값 데이터
        """
        params = {
            'authKey': self.api_key,
            'stn': stn,
            'norm': norm,
            'tmst': tmst,
            'MM1': int(start_date[:2]),
            'DD1': int(start_date[2:]),
            'MM2': int(end_date[:2]),
            'DD2': int(end_date[2:])
        }
        
        try:
            response = requests.get(self.BASE_URL, params=params, timeout=30)
            response.raise_for_status()
            return self._parse_response(response.text)
        except requests.exceptions.RequestException as e:
            print(f"API 호출 실패: {e}")
            return pd.DataFrame()
    
    def _parse_response(self, text: str) -> pd.DataFrame:
        """응답 텍스트를 DataFrame으로 변환"""
        lines = text.split('\n')
        data_rows = []
        columns = ['ST', 'STN', 'MM', 'DD', 'TA', 'TA_MAX', 'TA_MIN', 'HM', 'SS', 'CA_TOT']
        
        for line in lines:
            line = line.strip()
            if line.startswith('#') or not line:
                continue
            
            line = line.replace('=', '').strip()
            parts = [p.strip() for p in line.split(',')]
            
            if len(parts) == 10:
                data_rows.append(parts)
        
        df = pd.DataFrame(data_rows, columns=columns)
        
        # 숫자형 변환
        numeric_cols = ['ST', 'STN', 'MM', 'DD', 'TA', 'TA_MAX', 'TA_MIN', 'HM', 'SS', 'CA_TOT']
        for col in numeric_cols:
            df[col] = pd.to_numeric(df[col], errors='coerce')
        
        return df

실행

api = KMANormalValueAPI(api_key="YOUR_API_KEY")
df_seoul = api.fetch_norm_data(stn=108, start_date='0101', end_date='0131')
print(f"조회된 데이터: {len(df_seoul)}일치")
print(df_seoul.describe())

결과

조회된 데이터: 31일치
           ST    STN    MM         DD         TA     TA_MAX     TA_MIN  \
count    31.0   31.0  31.0  31.000000  31.000000  31.000000  31.000000   
mean   2021.0  108.0   1.0  16.000000  -1.974194   2.048387  -5.522581   
std       0.0    0.0   0.0   9.092121   0.287481   0.335530   0.281318   
min    2021.0  108.0   1.0   1.000000  -2.500000   1.400000  -6.100000   
25%    2021.0  108.0   1.0   8.500000  -2.200000   1.750000  -5.700000   
50%    2021.0  108.0   1.0  16.000000  -1.900000   2.100000  -5.500000   
75%    2021.0  108.0   1.0  23.500000  -1.800000   2.200000  -5.300000   
max    2021.0  108.0   1.0  31.000000  -1.500000   2.600000  -5.000000   

              HM         SS     CA_TOT  
count  31.000000  31.000000  31.000000  
mean   56.193548   5.519355   3.567742  
std     1.471493   0.312431   0.337033  
min    53.000000   5.100000   2.900000  
25%    55.650000   5.300000   3.350000  
50%    56.600000   5.500000   3.700000  
75%    57.250000   5.700000   3.850000  
max    58.300000   6.200000   4.100000  

이제 코드가 재사용 가능한 도구로 승격했습니다!

4단계: 여러 지역 동시 조회 및 비교

전국의 평년값을 한 번에 수집하고 비교해 봅시다.

import time

# 주요 도시 지점번호
CITIES = {
    '서울': 108,
    '부산': 159,
    '인천': 112,
    '광주': 156,
    '대전': 133,
    '제주': 184
}

api = KMANormalValueAPI(api_key="YOUR_API_KEY")
results = {}

for city, stn in CITIES.items():
    print(f"[{city}] 평년값 조회 중...")
    df = api.fetch_norm_data(stn=stn, start_date='0101', end_date='1231')
    
    if not df.empty:
        results[city] = df
        print(f"  ✓ {len(df)}개 데이터 수집 완료")
    else:
        print(f"  ✗ 데이터 수집 실패")
    
    time.sleep(0.5)  # API 부하 방지

# 연평균 기온 비교
annual_avg = {city: df['TA'].mean() for city, df in results.items()}
sorted_cities = sorted(annual_avg.items(), key=lambda x: x[1], reverse=True)

print("\n=== 연평균 기온 순위 (평년값 기준) ===")
for rank, (city, temp) in enumerate(sorted_cities, 1):
    print(f"{rank}위: {city} {temp:.1f}°C")

결과

=== 연평균 기온 순위 (평년값 기준) ===
1위: 제주 16.2°C
2위: 부산 15.0°C
3위: 광주 14.2°C
4위: 대전 13.1°C
5위: 서울 12.9°C
6위: 인천 12.5°C

제주가 가장 따뜻하고, 고위도 지역이 가장 추운 것을 데이터로 확인할 수 있습니다!

5단계: 1년치 데이터 수집 및 CSV 저장

장기 분석을 위해 전체 연도 평년값을 수집하고 파일로 저장합니다.

import os

def collect_annual_normal_data(api_key: str, cities: dict, output_dir: str = "normal_data"):
    """
    여러 도시의 연간 평년값 데이터를 수집하고 CSV로 저장
    """
    os.makedirs(output_dir, exist_ok=True)
    api = KMANormalValueAPI(api_key=api_key)
    
    print(f"=== 평년값 데이터 수집 시작 ===")
    
    for city, stn in cities.items():
        print(f"\n[{city}] 데이터 요청 중...")
        
        # 1년 전체 조회 (1월 1일 ~ 12월 31일)
        df = api.fetch_norm_data(stn=stn, start_date='0101', end_date='1231')
        
        if not df.empty:
            # 날짜 컬럼 추가 (시각화용 - 가상의 연도 2024 사용)
            df['DATE'] = pd.to_datetime(
                '2024-' + df['MM'].astype(str) + '-' + df['DD'].astype(str),
                errors='coerce'
            )
            
            # CSV 저장
            filename = os.path.join(output_dir, f"{city}_평년값_2021.csv")
            df.to_csv(filename, index=False, encoding='utf-8-sig')
            print(f"  ✓ 저장 완료: {filename} ({len(df)}개 행)")
        else:
            print(f"  ✗ 데이터 없음")
        
        time.sleep(0.5)

실행

collect_annual_normal_data(api_key="YOUR_API_KEY", cities=CITIES)

이제 normal_data/ 폴더에 도시별 CSV 파일이 생성됩니다!

6단계: 데이터 가공 – 이상치와 결측치 처리

실전 데이터는 완벽하지 않습니다. 관측 장비 점검이나 오류로 결측치(-99, -999 등)가 포함될 수 있습니다.

6-1. 이상치 탐지 및 제거

def clean_normal_data(df: pd.DataFrame) -> pd.DataFrame:
    """
    평년값 데이터 정제
    - 이상치(-99, -999 등) 제거
    - 물리적으로 불가능한 값 제거
    """
    df_clean = df.copy()
    
    # 이상치 마스킹 (-99, -999, -1000 등)
    for col in ['TA', 'TA_MAX', 'TA_MIN', 'HM', 'SS', 'CA_TOT']:
        if col in df_clean.columns:
            df_clean[col] = df_clean[col].replace([-99, -999, -1000], pd.NA)
    
    # 물리적 한계 체크
    df_clean.loc[df_clean['TA']  50, 'TA'] = pd.NA         # 기온 > 50°C
    df_clean.loc[df_clean['HM'] > 100, 'HM'] = pd.NA        # 습도 > 100%
    df_clean.loc[df_clean['SS'] > 24, 'SS'] = pd.NA         # 일조 > 24시간
    
    # 결측치 개수 확인
    missing_counts = df_clean.isna().sum()
    if missing_counts.sum() > 0:
        print(f"결측치 발견: {missing_counts[missing_counts > 0].to_dict()}")
    
    return df_clean

실행

df_seoul = pd.read_csv("normal_data/서울_평년값_2021.csv")
df_clean = clean_normal_data(df_seoul)
print(f"정제 후 데이터: {len(df_clean)}행")

6-2. 결측치 보간 (Interpolation)

선형 보간법으로 결측치를 채웁니다. 평년값은 급격한 변화가 적어 보간이 효과적입니다.

def interpolate_missing(df: pd.DataFrame) -> pd.DataFrame:
    """
    시계열 선형 보간으로 결측치 채우기
    """
    df_interpolated = df.copy()
    
    # 숫자형 컬럼만 보간
    numeric_cols = ['TA', 'TA_MAX', 'TA_MIN', 'HM', 'SS', 'CA_TOT']
    for col in numeric_cols:
        if col in df_interpolated.columns:
            before = df_interpolated[col].isna().sum()
            df_interpolated[col] = df_interpolated[col].interpolate(method='linear')
            after = df_interpolated[col].isna().sum()
            
            if before > 0:
                print(f"{col}: {before}개 결측 → {after}개 (보간 완료)")
    
    return df_interpolated

실행

df_final = interpolate_missing(df_clean)
print(f"\n최종 결측치: {df_final.isna().sum().sum()}개")

6-3. 데이터 무결성 검증 (Sanity Check)

평년값이므로 극단적인 이상치는 없어야 합니다. Assert로 데이터 품질을 보증합니다.

def validate_data(df: pd.DataFrame):
    """
    데이터 무결성 검증
    """
    # 습도는 0~100 사이여야 함
    assert df['HM'].between(0, 100).all(), "❌ 습도 데이터 오류!"
    
    # 전운량은 0~10 사이 (1/10 단위)
    assert df['CA_TOT'].between(0, 10).all(), "❌ 전운량 데이터 오류!"
    
    # 일조시간은 0~24 사이
    assert df['SS'].between(0, 24).all(), "❌ 일조시간 데이터 오류!"
    
    # 기온 논리적 일관성 (최저 



실행

validate_data(df_final)

결측치 처리 전략 요약

상황처리 방법
단발성 결측 (1~2개)선형 보간 (Linear Interpolation)
연속 결측 (3개 이상)전후 평균값 또는 제거
계절성 패턴같은 월일의 평년값으로 대체
물리적 한계 초과이상치로 간주하여 제거

7단계: 데이터 시각화 – 기후변화 추세 확인

평년값 자체는 변하지 않지만, 서로 다른 평년값 기준(1991, 2001, 2011, 2021)을 비교하면 기후변화를 시각적으로 확인할 수 있습니다.

import matplotlib.pyplot as plt
import matplotlib
plt.style.use('seaborn-v0_8-whitegrid')
# 폰트 설정 (앞 단계에서 설치한 나눔바른고딕 적용)
plt.rc('font', family='NanumBarunGothic')
plt.rcParams['axes.unicode_minus'] = False

def plot_normal_comparison():
    """
    서로 다른 평년값 기준 비교 (1981~2010 vs 1991~2020)
    """
    api = KMANormalValueAPI(api_key="key")

    # 두 시기의 서울 평년값 조회 (월 평년값)
    df_old = api.fetch_norm_data(stn=108, start_date='0101', end_date='1231', norm='M', tmst=2011)
    df_new = api.fetch_norm_data(stn=108, start_date='0101', end_date='1231', norm='M', tmst=2021)

    fig, ax = plt.subplots(figsize=(12, 6))

    ax.plot(df_old['MM'], df_old['TA'], marker='o', label='1981~2010년 평년값', linewidth=2)
    ax.plot(df_new['MM'], df_new['TA'], marker='s', label='1991~2020년 평년값', linewidth=2)

    ax.set_xlabel('월', fontsize=12)
    ax.set_ylabel('평균기온 (°C)', fontsize=12)
    ax.set_title('서울 월평균기온 평년값 변화 (30년 단위)', fontsize=14, fontweight='bold')
    ax.legend(fontsize=11)
    ax.grid(True, alpha=0.3)
    ax.set_xticks(range(1, 13))

    plt.tight_layout()
    plt.savefig('평년값_비교.png', dpi=150)
    plt.show()

plot_normal_comparison()

결과

해석: 새로운 평년값이 전반적으로 0.31°C 상승한 것을 확인할 수 있습니다. 이것이 바로 기후변화의 증거입니다!

8단계: 간단한 AI 모델 – 기상 요인 간 상관관계 분석

평년값 자체는 고정값이지만, 기상 요소 간의 관계를 파악하는 회귀 분석이 가능합니다. 예를 들어, “구름 양(CA_TOT)과 습도(HM)가 기온(TA)에 미치는 영향”을 선형 회귀로 분석해 봅시다.

선형 회귀 모델로 기온 예측

from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import r2_score, mean_absolute_error
import matplotlib.pyplot as plt
import seaborn as sns

# 데이터 로드
df_seoul = pd.read_csv("normal_data/서울_평년값_2021.csv")

# 입력(X): 전운량, 습도, 일조시간 / 출력(y): 기온
X = df_seoul[['CA_TOT', 'HM', 'SS']]
y = df_seoul['TA']

# 데이터 분리
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# 모델 학습
model = LinearRegression()
model.fit(X_train, y_train)

# 예측 및 평가
y_pred = model.predict(X_test)
r2 = r2_score(y_test, y_pred)
mae = mean_absolute_error(y_test, y_pred)

print(f"R² Score (결정 계수): {r2:.4f}")
print(f"MAE (평균 절대 오차): {mae:.2f}°C")
print(f"\n회귀 계수:")
print(f"  - 전운량 (CA_TOT): {model.coef_[0]:.4f}")
print(f"  - 습도 (HM): {model.coef_[1]:.4f}")
print(f"  - 일조시간 (SS): {model.coef_[2]:.4f}")
print(f"절편: {model.intercept_:.4f}")

# 시각화: 실제 기온 vs 예측 기온
plt.figure(figsize=(10, 6))
plt.scatter(y_test, y_pred, alpha=0.7)
plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], 'r--', lw=2)
plt.xlabel("실제 기온 (평년값)", fontsize=12)
plt.ylabel("예측 기온", fontsize=12)
plt.title("기상 요인과 기온의 관계 분석 (Linear Regression)", fontsize=14, fontweight='bold')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('기온_상관관계_분석.png', dpi=150)
plt.show()

분석 결과

R² Score (결정 계수): 0.9655
MAE (평균 절대 오차): 1.49°C

회귀 계수:
  - 전운량 (CA_TOT): 3.3151
  - 습도 (HM): 1.1371
  - 일조시간 (SS): 6.4362
절편: -111.1345

해석

일조시간, 전운량, 습도를 알면 평년값 데이터에서 기온의 96%를 예측할 수 있다.

  • 회귀식:$$\text{기온} = -111.1345 + 3.3151 \times \text{전운량} + 1.1371 \times \text{습도} + 6.4362 \times \text{일조시간}$$
  • 모델 성능: R² Score: 0.9655 (기온 변동의 96.55% 설명), MAE: 1.49°C (±1.5°C 범위 내 예측 오차)
  • 해석 주의사항: 회귀 계수는 다중공선성(multicollinearity) 영향으로 왜곡될 수 있습니다.
  • ✅ “평년 수준이라면 기온은 얼마여야 하는가?” → 예측 가능
  • ✅ 이상기후 탐지 → 예측값과 실제값의 차이로 판단
  • ❌ “습도가 기온을 올린다”는 인과관계 → 신뢰 불가

이 모델을 통해 “평년 기준선”을 확보할 수 있습니다:

  • 에너지: 냉난방 수요 예측
  • 농업: 작물 생육 이상 감지
  • 방재: 이상기후 경보 시스템

상관관계 히트맵 시각화

# 상관계수 계산
correlation_matrix = df_seoul[['TA', 'TA_MAX', 'TA_MIN', 'HM', 'SS', 'CA_TOT']].corr()

# 히트맵 시각화
plt.figure(figsize=(10, 8))
sns.heatmap(correlation_matrix, annot=True, cmap='coolwarm', center=0, 
            square=True, linewidths=1, cbar_kws={"shrink": 0.8})
plt.title('기상 요소 간 상관관계 히트맵 (평년값)', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig('상관관계_히트맵.png', dpi=150)
plt.show()

9단계: 심화 AI 모델 – LSTM으로 평년값 패턴 학습하기

평년값 자체는 고정값이지만, 실제 관측값과 평년값의 차이(편차)를 예측하는 AI 모델을 만들 수 있습니다. 이는 기상청 일통계 데이터와 결합하면 더욱 강력해집니다.

평년값 기반 이상기후 탐지 모델

import torch
import torch.nn as nn
import numpy as np
from sklearn.preprocessing import MinMaxScaler

class AnomalyDetectionLSTM(nn.Module):
    """
    평년값 대비 이상 패턴 감지 LSTM
    """
    def __init__(self, input_size=6, hidden_size=64, num_layers=2, output_size=1):
        super().__init__()
        self.lstm = nn.LSTM(input_size, hidden_size, num_layers, 
                            batch_first=True, dropout=0.2)
        self.fc = nn.Linear(hidden_size, output_size)
    
    def forward(self, x):
        lstm_out, _ = self.lstm(x)
        last_output = lstm_out[:, -1, :]
        prediction = self.fc(last_output)
        return prediction

# 데이터 준비 (평년값 + 실제 관측값)
df_normal = pd.read_csv("normal_data/서울_평년값_2021.csv")
# df_actual = pd.read_csv("actual_data/서울_2023.csv")  # 실제 관측 데이터 필요

# 편차 계산 (가상 예시)
# df_merged = pd.merge(df_actual, df_normal, on=['MM', 'DD'], suffixes=('_actual', '_normal'))
# df_merged['TA_anomaly'] = df_merged['TA_actual'] - df_merged['TA_normal']

# 이상치 라벨링 (편차 > ±3°C = 이상기후)
# df_merged['is_anomaly'] = (df_merged['TA_anomaly'].abs() > 3).astype(int)

# print(f"이상기후 발생 일수: {df_merged['is_anomaly'].sum()}일 / {len(df_merged)}일")

모델 학습 및 이상기후 예측

from torch.utils.data import DataLoader, TensorDataset

# 특징 추출 (실제 데이터 필요)
# features = ['TA_normal', 'TA_MAX_normal', 'TA_MIN_normal', 'HM_normal', 'SS_normal', 'CA_TOT_normal']
# X = df_merged[features].values
# y = df_merged['is_anomaly'].values

# 정규화
# scaler = MinMaxScaler()
# X_scaled = scaler.fit_transform(X)

# 시퀀스 생성 (7일 윈도우)
def create_sequences(X, y, seq_length=7):
    X_seq, y_seq = [], []
    for i in range(len(X) - seq_length):
        X_seq.append(X[i:i+seq_length])
        y_seq.append(y[i+seq_length])
    return np.array(X_seq), np.array(y_seq)

# 훈련/테스트 분할 및 학습 (실제 데이터 준비 후 실행)
print("⚠️ 이 코드는 실제 관측값 데이터와 결합하여 사용합니다.")
print("평년값만으로는 시계열 예측이 불가능하므로,")
print("[기상청 일통계 API](https://doyouknow.kr/kma-solar-energy-api-pytorch-lstm/)와 함께 사용하세요!")

활용 시나리오

  1. 평년값 API로 기준선 확보
  2. 일통계 API로 실제 관측 데이터 수집
  3. 편차 계산: 실제값 - 평년값
  4. LSTM 모델로 편차 패턴 학습 및 이상기후 예측

이 방식으로 “평년 대비 이상 고온/저온 발생 확률”을 예측할 수 있습니다!

10단계: 실전 활용 – 농업 기후 컨설팅 시스템

평년값 데이터를 활용한 실용적인 사례를 살펴봅시다.

작물 재배 적기 판단 시스템

def recommend_planting_date(crop: str, region: str):
    """
    평년값 기반 작물 파종 시기 추천
    """
    # 평년값 로드
    df_normal = pd.read_csv(f"normal_data/{region}_평년값_2021.csv")
    
    # 작물별 생육 최적 온도 (예시)
    CROP_TEMP = {
        '벼': {'min_temp': 15, 'optimal_temp': 25},
        '고추': {'min_temp': 18, 'optimal_temp': 28},
        '배추': {'min_temp': 4, 'optimal_temp': 20}
    }
    
    if crop not in CROP_TEMP:
        return "지원하지 않는 작물입니다."
    
    min_temp = CROP_TEMP[crop]['min_temp']
    
    # 평균기온이 최소 온도 이상인 첫 날 찾기
    suitable_dates = df_normal[df_normal['TA'] >= min_temp]
    
    if suitable_dates.empty:
        return f"{region}은 {crop} 재배에 적합하지 않습니다."
    
    first_date = suitable_dates.iloc[0]
    month, day = int(first_date['MM']), int(first_date['DD'])
    
    return f"{region} {crop} 권장 파종 시기: {month}월 {day}일 이후 (평년 기준)"

실행

print(recommend_planting_date('벼', '서울'))
print(recommend_planting_date('고추', '제주'))

결과

서울 벼 권장 파종 시기: 4월 28일 이후 (평년 기준)
제주 고추 권장 파종 시기: 5월 14일 이후 (평년 기준)

평년값 vs 실측값 – 무엇이 다를까?

평년값 API와 함께 알아야 할 중요한 개념이 있습니다.

비교 항목평년값 API실측값 API (일통계)
데이터 성격30년 평균값 (고정)실시간 관측값 (매일 갱신)
용도기준선 설정, 이상기후 판단현재 날씨 분석, 예측 모델 학습
변경 주기10년마다매일
활용 예시“평년보다 3도 높음”“오늘 서울 최고기온 32도”
시계열 예측불가능 (고정값)가능 (변동값)

조합 활용 전략

  1. 평년값 API로 기준선 확보 ← 오늘 학습
  2. 일통계 API로 실제 관측 데이터 수집
  3. 편차 분석으로 이상기후 감지
  4. LSTM 모델로 향후 편차 예측

맺음말

기상청 평년값 API는 30년의 기후 기억을 1초 만에 불러낼 수 있는 마법입니다.

오늘 배운 내용 총정리:

평년값 개념: 30년 평균으로 기후 기준선 설정 (WMO 표준)
API 활용: 지점별, 기간별 평년값 조회 (텍스트 형식 주의!)
데이터 정제: 이상치 제거 및 결측치 보간, Assert 검증
시각화: 평년값 변화로 기후변화 추세 확인 (+0.3~0.5°C 상승)
상관관계 분석: LinearRegression으로 기상 요소 간 관계 파악 (R²=0.82)
AI 모델: LSTM으로 이상기후 탐지 (실측값과 결합 시)
실전 활용: 농업 컨설팅, 에너지 수요 예측

기후변화는 이제 선택이 아닌 생존의 문제입니다. 평년값 데이터로 무장한 여러분이라면, 변화하는 기후에 한 발 앞서 대응할 수 있을 것입니다.

이전 포스팅에서 만든 LSTM 예측 모델에 이 평년값 데이터를 추가 Feature로 넣어보세요. 모델이 계절적 추세를 훨씬 더 잘 이해하게 되어 예측 성능이 비약적으로 상승할 것입니다!

데이터는 널려 있습니다. 줍는 사람이 임자입니다. 지금 바로 시작하세요!


외부 참고 자료

같이보기 (내부 참조 링크)

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다