2024년 10월 4일 금요일

BIM IFC 파일을 Cesium 디지털트윈 플랫폼에 3D tiles로 가시화하는 방법과 구조

이 글은 BIM(Building Information Modeling) 포맷 중 하나인 IFC 파일을 Cesium 플랫폼에 3D tiles로 가시화하는 방법을 간략히 설명한다. 아울러, 마지막 부분에 3D tiles 개념과 구조를 간략히 나눔한다. Cesium에서 개발된 3D tiles는 3D 고속 렌더링을 위한 모델 구조와 렌더링 메커니즘을 제공한다. 이 기술은 현재 공간정보산업표준을 담당하는 OGC(Open Geospatial Consortium)와 유기적 협력을 통해 발전하고 있다.
Cesium 3D tiles 가시화 모습 예시

참고로, 최근 릴리즈된 IFC 직접 로딩하는 기술은 향후에 사용기를 정리하여 공유할 것이다( 아직, IFC를 직접 Cesium으로 임포트하는 기능은 완전하지 않다). 관심있는 분들은 다음 링크를 참고하길 바란다.

개요
Cesium은 구글 어스와 유사한 지구 스케일의 디지털트윈 플랫폼이다. 이를 이용하면, 도시 차원에서 분석하고, 실내 건물을 탐색하는 등의 유스케이스를 개발할 수 있다. 국내 대부분의 3차원 도시 플랫폼 기반 서비스에서 Cesium이 사용되고 있다. Cesium은 디지털트윈 모델을 다루기 위한 저작도구도 함께 제공한다. 개발자는 서비스에 필요한 메뉴 기능, 데쉬보드에 표출한 데이터 처리에만 신경을 쓰면 된다. 
Cesium 저작도구 예시

공간정보 기술을 연구하다 보면, 가끔 BIM 파일 포맷 중 하나 인 IFC(Industry Foundation Classes)를 Cesium위에 가시화해야 하는 경우가 종종 발생할 때가 있다. 하지만, Cesium은 IFC를 직접적으로 지원하지 않는다. 
IFC 추가 에러 발생 모습

Cesium은 IFC를 포함한 모든 3D 모델파일을 3D tiles로 변환해 업로드하도록 하고 있다. 이는 무거운 3D 모델의 가시화 성능을 고려한 것이다. 

3D tiles은 웹에서 가시화하기에 무거운 3D 파일을 공간인덱싱 기법을 이용해 Octree형식으로 표현하고, 각 노트에 분할된 3D 모델의 부분을 담아둔다. 메쉬 간략화 기법을 이용해, 카메라가 모델을 비추는 거리에 따라 적절한 LoD(Level of Detail)의 메쉬를 보여준다. 이는 게임에서 FPS 성능을 올리기 위해 개발된 기법과 매우 유사하다. glTF 20은 3D 타일의 기본 형식이다. 
glTF 2.0 기능(3차원 점군, 텍스쳐, 모델 지원 예시)

Cesium은 다양한 샘플 코드를 sandcastle이란 플랫폼으로 제공하여, 편리한 개발을 지원하고 있다.

3D tile 모델 변환 및 업로드
먼저, 다음 링크를 방문해 Cesium ion 에 가입한다.
이후, 세슘의 API, 어셋(asset)을 관리하는 클라우드, Javascript 기반 예제 등을 무료로 사용할 수 있다. 여기서 어셋이란 플랫폼에서 사용하는 GIS, BIM 등 모든 파일 및 데이터셋을 의미한다. 가입 후, 아래를 클릭해 어셋을 추가해 보자. 
Cesium ion 메뉴 화면

Cesium은 3D 모델을 3차원 타일 형식으로 내부 표현한다. 이 형식을 지원하는 파일 포맷은 다음과 같다. 
IFC를 fbx와 같은 형식으로 변환한 후, My Assets 메뉴(My Assets | Cesium ion)를 이용해 데이터를 추가한다. 추가된 데이터는 고유의 Asset ID가 부여된다. 다음 그림에서 첫 행의 어셋인 2716386은 어셋 ID를 보여준다. 현재 세슘은 5GB 무료 어셋 저장소를 제공한다.

참고로, IFC파일을 다른 형식으로 변환하기 위해서는 Revit 등 상용 모델러를 사용하거나, Blender와 같은 오픈소스 도구에 IFC import 애드인을 설치하고, fbx 포맷 등으로 저장하면 된다. 
Blender에서 IFC to FBX 변환 모습

3차원 모델은 좌표 원점에 표시되므로, 모델의 원점과 각도를 재조정해야 한다. 

다음과 같이 MyAssets 메뉴에 Adjust Tileset Location 메뉴를 클릭한다.

참고로, 만약 모델 원점이 0.0.0이 아니면, 다음 그림과 같이 재조정하기 어렵다. 모델링 할 때 원점을 맞추고 진행한다.

3D Tile Location Editor에서 모델의 위치, 방향을 수정한 후 저장버튼을 클릭한다. 참고로, Click position 버튼을 클릭하면, 현재 커서 위치의 지표면에 맞춰 타일 모델 위치를 자동 입력한다.

앱 서비스 개발
특정 서비스에 3D tile을 사용하고 싶다면, 단순히 primities.add 함수를 사용하여 3D tile을 추가할 수 있다. API를 사용해야 하므로, API 토큰을 생성 한다. 
API 키 토큰 생성 예시

다음 링크를 참고해, API 키 토큰을 생성하고, 이 문자열을 코드의 Token에 할당한다.
HTML를 만들어, 다음 같이 자바스크립트 파일을 입력한다. 어셋 ID를 이용해 fromAssetID에서 어셋을 가져온다.

         Cesium.Ion.defaultAccessToken = '';
         var viewer = new Cesium.Viewer('cesiumContainer', {
            animation: false,
            homeButton: true,
            navigationHelpButton: true
         });

         const tileset = viewer.scene.primitives.add(
            new Cesium.Cesium3DTileset({
               url: Cesium.IonResource.fromAssetId(1378646),
            })
         );

3D tile 코드 사용 예시

이와 관련해 구현된 상세 코드 예시는 다음 github 링크를 참고하길 바란다.
웹 서비스에서 코드 호출한 결과는 다음과 같다. 3D tile이 잘 가시화되는 것을 확인할 수 있다. 

이런 방식으로 디지털트윈 플랫폼의 도시 건물 정보 가시화하는 기능을 구현할 수 있다.

3D tile 구조
Cesium의 3D Tiles 데이터 구조는 대규모 3D 지리 데이터를 효율적으로 스트리밍하고 시각화하기 위해 설계되었다. 3D Tiles 구조는 오픈소스로 다음 깃허브 링크에 공개되어 있다. 

타일 데이터셋은 다음과 같다.
  • Tileset
Tileset.json 파일이 3D 타일의 상위 구성 파일 역할을 하며, 타일 계층 구조와 데이터 위치 정보를 담고 있다. 이 파일은 전체 타일셋의 메타데이터와 루트 타일에 대한 참조를 제공한다. 
 {
  "transform": [
     4.843178171884396,   1.2424271388626869, 0,                  0,
    -0.7993325488216595,  3.1159251367235608, 3.8278032889280675, 0,
     0.9511533376784163, -3.7077466670407433, 3.2168186118075526, 0,
     1215001.7612985559, -4736269.697480114,  4081650.708604793,  1
  ],
  "boundingVolume": {
    "box": [
      0,     0,    6.701,
      3.738, 0,    0,
      0,     3.72, 0,
      0,     0,    13.402
    ]
  },
  "geometricError": 32,
  "content": {
    "uri": "building.b3dm"
  },
  "extensions": {
    "VENDOR_collision_volume": {
      "box": [
        0,     0,    6.8,
        3.8,   0,    0,
        0,     3.8,  0,
        0,     0,    13.5
      ]
    }
  }
}
  • 타일 계층 구조
타일 계층은 루트 타일에서 시작해 더 작은 타일로 분할되며, 쿼드트리나 옥트리 구조를 사용한다. 타일들은 자신보다 작은 자식 타일들을 가지며, 각각의 해상도가 다르다.
루트 타일은 대략적인 모델 데이터를 제공하고, 자식 타일은 줌 인할 때 더 높은 해상도를 제공한다.
  • 타일 구성 요소
Bounding Volume (경계 볼륨)은 각 타일이 포함하는 공간 영역을 정의한다. 이 경계는 카메라의 위치에 따라 타일을 로드할지 결정하는 데 사용된다.
Geometric Error (기하학적 오차)는 타일의 해상도와 관련된 값으로, 클라이언트가 어떤 타일을 로드할지 선택하는 데 도움을 준다.
  • 타일 콘텐츠
타일은 다양한 형태의 3D 데이터를 포함할 수 있다.
Batched 3D Model (B3DM)은 여러 개의 3D 객체를 포함한 배치된 모델이다.
Instanced 3D Model (I3DM)은 동일한 3D 모델을 여러 위치에 인스턴스화하여 사용한다.
Point Cloud (PNTS)는 점군 데이터를 포함한다.
Composite (CMPT)는 다양한 콘텐츠를 하나의 타일에 혼합할 수 있다.
  • LOD (Level of Detail)
3D Tiles는 LOD(세부 수준)에 따라 계층 구조를 가지며, 줌 수준에 따라 더 높은 해상도의 타일을 로드하거나, 멀리 있는 객체는 낮은 해상도를 유지함으로써 성능을 최적화한다.
  • 압축 및 최적화
Cesium의 3D Tiles는 데이터를 압축하여 전송 속도와 메모리 효율성을 높인다.
Draco 압축을 사용하여 기하학 데이터를 압축하고 파일 크기를 줄인다.
이처럼 Cesium의 3D Tiles 구조는 대규모 지리 데이터를 효율적으로 처리하고 동적으로 로드하여 성능을 극대화한다.

다음은 앞의 구조를 표현한 그림이다.
이 그림에서 보면, 솔리드 모델, IFC와 같이 3D 모델 형상 정보는 메쉬 형태로 Vertices, Texels 로 변환된다. 이는 동일한 인스턴스를 스케일, 위치, 방향만 다르게 해서 렌더링하는 객체를 GPU 인스턴스로 등록하고(가속 렌더링을 위해), 나머진 형상(FEATURE)로 등록한다. 이 피쳐는 타일 컨텐츠로 등록된다. 

각 타일 컨텐츠는 타일 트리(일종의 공간 인덱싱을 통해 카메라의 거리, 방향에 따라 타일에 포함된 인스턴스의 상세 렌더링 수준을 사전 계산, 결정. 이를 통해, 타일이 보여질 때 가시화 속도를 높이는 역할)에 등록된다. 이때 각 타일은 LoD(Level of detail)을 계산하는 데, 보통, Mesh Simplication 이란 게임엔진에서 사용된 방법을 이용한다. 

각 LoD처리된 타일은 속성정보를 포함하고 있어, 카메라 각도, 거리에 따라 적절한 LoD의 타일 형상과 정보가 보여질 수 있다. 이를 모아 Tileset이라 한다.

마무리
오늘은 BIM IFC 파일을 Cesium 디지털트윈 플랫폼에 3D tiles로 가시화하는 방법과 구조를 간략히 알아보았다. Cesium은 디지털트윈 뿐 아니라 도시 시뮬레이션 가시화 등 많은 곳에서 사용된다. 

개인적으로는 Cesium 오픈 플랫폼의 개발자이자 개발 최고 책임자인 패트릭 코지(Patrick Cozzi)가 30대 초부터 꾸준히 이 기술을 발전시키고 공유하는 모습이 참 인상적이다. 그는 펜실베니아 주립대를 졸업한 후, 석사를 한 UPen에서 9년간이나 컴퓨터 그래픽스 기술을 가르치며, Cesium 을 개발해 공유했다. 그의 석사 논문은 Cesium의 기본 개념이 담겨 있다. 
Patrick Cozzi 석사 논문 일부(2008, UPen. Cesium 3D Tiles 기술 컨셉이 설명됨)

논문을 살펴보면 그가 OpenGL과 컴퓨터 그래픽스에 얼마나 깊은 이해를 하고 있는 지 알수 있다. 일종의 이 분야 오타구로 보여지는 데, 그는 여기서 그치지 않고, 본인이 도시와 같은 대용량 모델을 실시간으로 가시화할 수 있는 기술을 개발해 사람들이 편리하게 사용할 수 있도록 하는 데까지 나아가려 했다. 

그는 2010년대 부터 컴퓨터 그래픽스에 대한 퍼듀 자문, 크로노스 협회 glTF 표준화 기여, 메타버스 협회 창립 이사를 거친다. 2024년에는 그가 개발을 시작해 창립한 Cesium이 벤틀리에 합병되어 플랫폼 개발 최고 책임자가 된다. 
그의 발자취를 살펴보면, 막대한 국가 R&D를 통해 살림하고 있는 국내 그 수많은 SW 정보 기술 관련 협회, 조직에서 제대로 된 오픈소스 하나 산업계에 꾸준히 공유 발전시킨 적이 있는 지, 그 조직에 이런 전문가들이 있기나 한 것인지 생각해 보지 않을 수 없다. 국내 기술 산업이 아직 선진기술 패키징하는 상황에서 이런 문제가 산업 생태계와 국가 시스템의 문제인지, 아니면, 개인의 문제인지 다시 한번 생각하게 된다. 


부록: glTF 구조도
다음 그림은 3D 타일 공식 포맷인 glTF 2.0의 구조와 개념을 상세히 보여준다. 
레퍼런스

무료 CityGML 3D 도시모델 뷰어 FZK Viewer 와 도시 시뮬레이션 SimStadt 소개

공간정보와 관련된 기술 개발을 하다 보면, CityGML 파일을 확인해야 할 때가 발생한다. 이때 CityGML파일 무료 뷰어를 막상 발견하기 어려운 경우가 종종 있다. 이 글은 라이센스 무료인 FZK Viewer 뷰어를 소개하고 설치 및 사용법을 간략히 소개한다. 아울러, 이 프로그램의 상위 R&D 프로젝트인 도시 시뮬레이션 SimStadt 프로그램도 간략히 나눔한다.
CityGML LoD example

FZK Viewer
슈트르가르트 기술대학에서 개발한 뷰어로, 설치형이다. 설치를 위해 다음 링크를 클릭한다.
다음 화면에서 적색 밑줄 단어를 선택하여 다운로드 후, 압축파일을 푼다. 
다음 exe 파일을 실행한다.

예제 파일(예제 파일 다운로드)을 로딩하고, 텍스쳐를 적용하면 다음과 같이 출력된다. 

CityGML Web Viewer
같은 곳에서 개발한 웹 뷰어가 있다. 사용법은 아래 링크를 선택한 후, CityGML파일을 선택하면 된다. 
실행모습

도시 시뮬레이션 SimStadt R&D
FZK viewer는 SimStadt 프로젝트 중 일부 프로그램이다. SimStadt는 HFT(Hochschule für technik. 기술대학) Stuttgart에서 연구 중인 도시 시뮬레이션 환경 프로젝트로, 2015년에 완료된 프로젝트(SimStadt)를 계속 발전해 온 것이다. 
SimStadt는 건물, 도시 구역, 전체 도시 및 지역의 에너지 분석을 위해 실제 도시 계획 상황 또는 계획 상태의 데이터를 사용할 수 있다. 유스케이스 시나리오는 건물 난방 요구 사항, 태양광 발전 시뮬레이션, 건물 레노베이션, 재생 에너지 공급 시나리오 시뮬레이션에 이르기까지 다양하다. 
시나리오 흐름 분석 시뮬레이션을 지원하는 SimStadt 실행 화면

SimStadt는 건축가, 엔지니어링 사무소, 도시 계획가가 사용할 목적으로 개발되고 있다. 향후, 오픈소스로 공개할 계획이 있다. 

일부 소스코드는 다음 링크에서 발견할 수 있다.

이 R&D 프로젝트는 Dr. Volker Coors, Prof. Bastian Schröter에 의해 주도되고 있다.

자세한 내용은 다음 링크를 참고한다.

2024년 9월 3일 화요일

CuPy 사용해 CUDA 프로그래밍하기

이 글은 AI 딥러닝에 핵심적으로 사용되는 CUDA를 손쉽게 사용하기 위해 CuPy 와 사용법을 간략히 알아본다. 이 라이브러리는 NumPy와 유사한 방식으로 CUDA를 사용할 수 있다. CuPy는 NVIDIA의 RAPIDS 데이터 분석 파이프라인에서도 사용된다. 
설치 방법
미리 파이썬 개발환경이 준비되어 있다는 가정하에, 다음 명령을 명렁창에 입력한다.
pip install cupy-cuda11x
pip install nvcc4jupyter


개발하기
다음 코드를 입력해 실행한다.
import cupy as cp

def elementwise_multiply(vector1, vector2):
    # GPU Device 0에서 실행
    with cp.cuda.Device(0):  
        # CuPy 배열로 변환
        vec1_gpu = cp.array(vector1)
        vec2_gpu = cp.array(vector2)
        
        # 요소별 곱을 CuPy를 이용해 병렬로 수행
        result_gpu = vec1_gpu * vec2_gpu
        
        # 결과를 다시 CPU로 가져옴 (필요한 경우)
        result = cp.asnumpy(result_gpu)
    
    return result

# 예시
vector1 = [1, 2, 3, 4]
vector2 = [5, 6, 7, 8]

result = elementwise_multiply(vector1, vector2)
print(result)  # 출력: [ 5 12 21 32 ]

이 코드는 CUDA를 이용해 GPU DEVICE 0번에 벡터값을 전송하고, 벡터곱을 계산한 후, CPU 메모리로 그 값을 전송한다. 

C 언어로 개발하는 CUDA 방식에 비해 매우 간략히 코딩할 수 있다는 것을 알 수 있다.

레퍼런스

2024년 9월 2일 월요일

Web 기반 Ollama 서비스 구동 방법

이 글은 Web 기반 Ollama (올라마) 서비스 구동 방법을 간략히 정리한다. 이를 이용하면, Ollama 를 손쉽게 사용할 수 있는 웹 어플리케이션 형태로, 오픈 LLM 모델들을 로컬 컴퓨터에서 사용할 수 있다.

다음 링크에서 올라마를 설치한다. 
웹에서 실행하기 위해 다음 링크 참고해 도커를 설치한다. 그리고 재부팅한다. 
다음 명령을 터미널 명령창에서 실행한다.
docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main

로컬주소(localhost:3000)를 웹에서 띄우면 다음 화면을 볼 수 있다.

이제 웹에서 제공되는 메뉴를 통해 오픈된 LLM 모델을 ChatGPT처럼 사용할 수 있다.

로컬 멀티모달 LLM 기반 간단한 RAG Enhanced Visual Question Answering

이 글은 로컬 멀티모달 LLM 기반 간단한 RAG Enhanced Visual Question Answering 에이전트 기술을 간략히 정리한다.
멀티모달 문제 예시(Phi-3)

제너레이티브 AI 분야에서 최근 많은 발전은 기존의 트랜스포머 아키텍처를 확장하여 다양한 입력과 출력을 처리하는 멀티모달 모델을 만드는 데 집중하고 있다. 예를 들어, 텍스트뿐만 아니라 이미지, 비디오, 음성 등 여러 형태의 데이터를 동시에 처리하는 능력을 갖춘 모델들이 등장하고 있다. 이러한 멀티모달 모델은 이미 오픈 소스와 클로즈드 소스 환경에서 뛰어난 성능을 입증하고 있다.

멀티모달 모델 중 하나인 VLM(Vision Language Models)은 텍스트와 이미지를 동시에 이해하고 처리하는 능력을 가진 모델이다. 이 모델들은 LLaVA, Idefics, Phi-vision과 같은 다양한 변형으로 제공되며, 이러한 작은 모델들이 오픈 소스 커뮤니티에 중요한 기여를 하고 있다. LLaVA 같은 모델을 사용하면 Vision Language Chat Assistant 같은 애플리케이션을 쉽게 구축할 수 있다.

하지만 멀티모달 모델을 위한 RAG(Retrieval-Augmented Generation) 시스템을 설계하는 것은 단순히 텍스트만을 사용하는 경우보다 훨씬 복잡하다. LLM(Large Language Models)을 위한 RAG 시스템의 설계는 이미 확립되어 있으며, 주로 정확성과 신뢰성, 그리고 확장성을 개선하는 방향으로 발전해왔다. 그러나 멀티모달 모델에서는 다양한 데이터 형식을 사용하여 정보를 검색할 수 있는 여러 방법이 존재하며, 그에 따라 여러 가지 아키텍처 옵션이 주어진다.

예를 들어, 하나의 공통된 벡터 공간을 생성하여 여러 데이터 형식을 함께 임베딩할 수도 있고, 각 형식에 대해 별도의 공간을 유지하면서 필요한 경우만 통합할 수도 있다. 이러한 선택은 성능과 처리 효율성에 영향을 미치며, 각각의 접근법이 고유한 장점과 단점을 가지고 있다.

최근 Phi 3.5, LLaMA 멀티모달 버전이 오픈소스화 되면서, 이런 기술을 쉽게 구현할 수 있게 되었다. 더불어, 멀티에이전트를 지원하는 RAG 기술 중 하나인 LangGraph 등이 공개되면서, VLM은 좀 더 쉽게 서비스 개발할 수 있게 되었다. 
VLM 예시 

레퍼런스

2024년 9월 1일 일요일

UCM 기반 시계열 데이터 신호 분해 및 모델 개발 방법

이 글은 UCM 모델을 이용한 시계열 데이터 신호 분해 처리 방법을 정리한다. 이 글은 시계열 데이터에 대한 딥러닝 모델 학습 데이터 처리에 통찰력을 준다.

머리말
시계열 해석을 위해 UCM(Unobserved Component Model)과 같은 방법이 개발되었다. 이는 상태공간 모델이라 불리고, 시계열 데이터를 개별 수준, 추세, 주기, 계절 구성 요소로 분해하고, 이 요소를 처리해 모델링하여, 미래값을 예측한다. 

UCM은 통계 모델로 관찰할 수 없는(Unobserved) 요인들에 관찰된 데이터에 어떻게 영향을 미치는 지 설명하는 모델이다. UCM은 계량 경제학 등 분야에서 사용되고 있다. 

UCM 모델에서 시계열 데이터는 트랜드(trend), 계절성(seasonality), 사이클(cycle), 불규칙성(irregular component) 등 구성요소로 분해될 수 있다.
  • 트렌드(Trend) 컴포넌트: 장기적인 상승 또는 하강 경향을 나타내는 요소이다. 예를 들어, 경제 성장으로 인한 판매 증가와 같은 장기적인 패턴을 설명할 수 있다.
  • 계절성(Seasonality) 컴포넌트: 주기적으로 반복되는 패턴을 설명하는 요소이다. 예를 들어, 계절에 따른 기후 변화로 인해 매년 여름에 증가하는 에어컨 판매량이 이에 해당한다.
  • 사이클(Cycle) 컴포넌트: 계절성보다 더 길고 불규칙한 주기를 나타내는 요소이다. 주로 경제적 사이클(경기 순환)과 같이 장기적인 변동성을 설명할 때 사용된다.
  • 불규칙성(Irregular) 컴포넌트: 예측할 수 없는 무작위적인 변동 요소로, 모델로 설명할 수 없는 데이터를 설명한다.
UCM은 다양한 분야 시계열 데이터 분석 및 예측에 사용된다. 

이제 UCM 모델을 사용한 시계열 데이터 처리 프로세스를 확인해 보자. 

UCM 학습 데이터 처리 프로세스
데이터 준비
이 글에서 사용될 데이터셋은 Kaggle의 '시간당 에너지 소비량'이다. 이 소스는 미국 전역 서비스 지역에서 매시간 보고된 에너지값(Mega watts)이 포함되어 있다(예. AEP. American Electric Power 데이터). 
데이터셋을 확인하기 위해 다음 코드를 실행한다. 
import math, scipy as sp, numpy as np, pandas as pd, datetime, warnings
import matplotlib as mpl, matplotlib.pyplot as plt, seaborn as sns, statsmodels.api as sm

from statsmodels.graphics.tsaplots import plot_acf
from statsmodels.graphics.tsaplots import plot_pacf
sns.set()

from sklearn.preprocessing import StandardScaler
from statsmodels.tsa.stattools import kpss
from sklearn.metrics import mean_absolute_error
from sklearn.metrics import mean_squared_error

df_aep = pd.read_csv("AEP_hourly.csv", index_col=0)
print(df_aep)

df_aep.sort_index(inplace = True)
print(df_aep)

데이터 프레임은 케글에서 제공하는 CSV 형식에서 로딩된다. 이를 소팅하면, 다음과 같이 출력된다. 

데이터 시각화 및 관찰
시각화를 하면, 데이터셋트의 복잡성을 확인할 수 있다.
f, ax = plt.subplots(figsize=(18,6),dpi=200);
plt.suptitle('American Electric Power (AEP) estimated energy consumption in MegaWatts (MW)', fontsize=24);
df_aep.plot(ax=ax,rot=90,ylabel='MW');
plt.show()


스케일이 너무 크므로, 일부만 확대해 보도록 하자.
f, ax = plt.subplots(figsize=(18,6),dpi=200);
plt.suptitle('American Electric Power estimated energy consumption in MegaWatts (MW)', fontsize=36);
df_aep.iloc[-3*8766:,:].plot(ax=ax,rot=90,fontsize=12);

데이터 무결성 확인 - 누락 및 중복 제거
누락 데이터가 있는 지 확인을 위해, 시간 단위로 데이터셋을 생성한다. 그리고, 인덱스가 서로 맞는지 체크한다.
datelist = pd.date_range(datetime.datetime(2004,10,1,1,0,0), datetime.datetime(2018,8,3,0,0,0), freq='H').tolist()
idx_list = df_aep.index.to_list()
print(idx_list == datelist)

결과가 False이므로, 데이터에는 중복이나 누락이 있다는 것을 알 수 있다. 체크해 보면, 서로 값이 다르다.
print(len(datelist), len(idx_list), len(set(idx_list)))

다음과 같이 데이터 변환 후, 중복, 누락 데이터셋을 확인한다. 
dt_idc = pd.to_datetime(df_aep.index, format='%Y-%m-%d %H:%M:%S')
print('Index,   current datetime,   current value,   last datetime,   last value,   timedelta,   value delta')

idc = []
for idx in range(1,len(dt_idc)):
    if dt_idc[idx] - dt_idc[idx-1] != datetime.timedelta(hours=1):
        idc.append([idx,dt_idc[idx] - dt_idc[idx-1]])
        
        print('{},   {},   {},   {},   {},   {},   {}'.format(idx, dt_idc[idx], df_aep.iloc[idx,0], dt_idc[idx-1], df_aep.iloc[idx-1,0], dt_idc[idx]-dt_idc[idx-1], df_aep.iloc[idx,0]-df_aep.iloc[idx-1,0]))

중복은 평균값을 사용하고, 누락은 평균값으로 채운다. 
df_aep.set_index(dt_idc, inplace=True)
print(df_aep.index)

for idx in reversed(idc):
    if idx[1] == datetime.timedelta(hours=2):
        idx_old = df_aep.iloc[idx[0]].name
        idx_new = idx_old-datetime.timedelta(hours=1)
        df_aep.loc[idx_new] = np.mean(df_aep.iloc[idx[0]-1:idx[0]+1].values)

    elif idx[1] == datetime.timedelta(hours=0):
        idx_old = df_aep.iloc[idx[0]].name
        value = np.mean(df_aep.iloc[idx[0]-1:idx[0]+1].values)
        df_aep.drop(df_aep.iloc[idx[0]-1:idx[0]+1].index, inplace=True)
        df_aep.loc[idx_old] = value

이제, 다시 동등성 체크하여 올바른 값을 확인한다.
idx_list = df_aep.index.to_list()
print(idx_list == datelist)

5개의 무작위 타임 윈도우를 4가지 다른 스케일에 따라 출력한다.
idx_list = df_aep.index.to_list()
idx_list == datelist

sample = sorted([x for x in np.random.choice(range(len(df_aep)), 5, replace=False)])
periods = [9000,3000,720,240]

f, axes = plt.subplots(len(sample),4,dpi=100,figsize=(8,4))
plt.suptitle('{} random time window plotted at {} different scales'.format(len(sample),len(periods)), fontsize=6, x=0.5, y=0.95)
f.tight_layout(pad=3.0)

for si,s in enumerate(sample):
    #p for period length
    for pi,p in enumerate(periods):
        df_aep.iloc[s:(s+p+1),:].plot(ax=axes[si][pi], legend=False, rot=90)
        #annotating datetime start
        axes[si][pi].annotate("Start at: " + df_aep.iloc[s:s+1,:].index[0].strftime("%d-%b-%Y %H:%M"), (0,1), xycoords='axes fraction')
plt.show()

결과는 다음과 같다.

시계열 데이터 패턴 확인 및 요소 분해
통계모델을 사용해, 트랜드 요소, 일별 계절 요소, 주별 계절 요소, 연별 계절 요소, 잔차 요소로 구분해 표현해 본다. 이를 위해, 학습할 데이터와 테스트 데이터를 분할하고, 이동평균법을 사용해 24시간(하루), 168시간(주간), 8766시간(연간) 요소를 계산한다. 주간 요소 분해는 일별 요소를 제외해야 하며, 연간 요소 분해는 다른 주간, 일간 요소를 제외해 적용해야 한다.
# Decomposition process
y_train = df_aep.iloc[:-8766,:]
y_test = df_aep.iloc[-8766:,:]

sd_24 = sm.tsa.seasonal_decompose(y_train, period=24) #extracting daily seasonality
sd_168 = sm.tsa.seasonal_decompose(y_train - np.array(sd_24.seasonal).reshape(-1,1), period=168) #extracting weekly
sd_8766 = sm.tsa.seasonal_decompose(y_train - np.array(sd_168.seasonal).reshape(-1,1), period=8766) #extracting yearly

f, axes = plt.subplots(5,1,figsize=(8,4),dpi=100)
plt.suptitle('Summary of seasonal decomposition', y=0.92, fontsize=12)
axes[0].plot(sd_8766.trend)
axes[0].set_title('Trend component', fontdict={'fontsize': 18})
axes[0].vlines(datetime.datetime(2008,1,1), axes[0].get_ylim()[0], axes[0].get_ylim()[1], colors='black', linestyles='dashed')
axes[0].vlines(datetime.datetime(2011,1,1), axes[0].get_ylim()[0], axes[0].get_ylim()[1], colors='black', linestyles='dashed')
axes[0].text(datetime.datetime(2006,6,1), 15000, 'Increasing trend',
             ha='center', va='center', bbox=dict(fc='white', ec='b', boxstyle='round'))
axes[0].text(datetime.datetime(2009,8,1), 14750, 'Global Financial Crisis \n (GFC) and recovery',
             ha='center', va='center', bbox=dict(fc='white', ec='b', boxstyle='round'))
axes[0].text(datetime.datetime(2015,1,1), 16000, 'Decreasing trend',
             ha='center', va='center', bbox=dict(fc='white', ec='b', boxstyle='round'))

axes[1].plot(sd_24.seasonal[:1000])
axes[1].set_title('Daily seasonal component', fontdict={'fontsize': 18})
axes[1].annotate('Higher \n daytime values', xy=(0.54, 0.50), xycoords='axes fraction', va='center', ha='center', xytext=(0.9, 0.9), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='w', ec='b'))
axes[1].annotate('Lower \n nighttime values', xy=(0.54, 0.50), xycoords='axes fraction', va='center', ha='center', xytext=(0.9, 0.1), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='w', ec='b'))
axes[2].plot(sd_168.seasonal[5000:6000])
axes[2].set_title('Weekly seasonal component', fontdict={'fontsize': 18})
axes[2].annotate('Leaked daily \n seasonal effects', xy=(0.50, 0.75), xycoords='axes fraction', va='center', ha='center', xytext=(0.50, 0.25), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='w', ec='b'), arrowprops=dict(color='black', arrowstyle='->', connectionstyle='arc3'))
axes[2].annotate('Weekdays', xy=(0.20, 0.75), xycoords='axes fraction', va='center', ha='center', xytext=(0.20, 0.40), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='w', ec='b'), arrowprops=dict(color='black', arrowstyle='-[', mutation_scale=45, connectionstyle='arc3'))
axes[2].annotate('Weekends', xy=(0.28, 0.55), xycoords='axes fraction', va='center', ha='center', xytext=(0.28, 0.90), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='w', ec='b'), arrowprops=dict(color='black', arrowstyle='-[', mutation_scale=17, connectionstyle='arc3'))
axes[3].plot(sd_8766.seasonal[-30000:])
axes[3].set_title('Yearly seasonal component', fontdict={'fontsize': 18})
axes[3].annotate('Calendar effect', xy=(0.54, 0.50), xycoords='axes fraction', va='center', ha='center', xytext=(0.67, 0.9), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='w', ec='b'), arrowprops=dict(color='black', arrowstyle='->', connectionstyle='arc3'))
axes[3].annotate('Leaked daily and \n weekly seasonal effects', xy=(0.34, 0.49), xycoords='axes fraction', va='center', ha='center', xytext=(0.40, 0.90), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='w', ec='b'), arrowprops=dict(color='black', arrowstyle='->', connectionstyle='arc3'))
axes[3].annotate('Summer', xy=(0.54, 0.50), xycoords='axes fraction', va='center', ha='center', xytext=(0.68, 0.05), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='#f5f88f', ec='b'))
axes[3].annotate('Autumn', xy=(0.54, 0.50), xycoords='axes fraction', va='center', ha='center', xytext=(0.74, 0.74), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='#f5f88f', ec='b'))
axes[3].annotate('Winter', xy=(0.54, 0.50), xycoords='axes fraction', va='center', ha='center', xytext=(0.81, 0.05), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='#f5f88f', ec='b'))
axes[3].annotate('Spring', xy=(0.54, 0.50), xycoords='axes fraction', va='center', ha='center', xytext=(0.88, 0.74), textcoords='axes fraction', bbox=dict(boxstyle='round', fc='#f5f88f', ec='b'))

axes[4].plot(sd_8766.resid)
axes[4].set_title('Residual component', fontdict={'fontsize': 18})

for a in axes:
    a.set_ylabel('MW')
    
plt.show()

결과는 다음과 같다. 

이제 각 스케일 별로 이동평균된 값과 원본 데이터를 비교해 본다. 
#model data is the sum of components
m_data = sd_8766.trend + sd_8766.seasonal + sd_168.seasonal + sd_24.seasonal

f, axes = plt.subplots(4,1,figsize=(18,24),dpi=100)
plt.suptitle('In-sample prediction vs observed', x=0.5, y=0.9)

axes[0].plot(df_aep.iloc[-8766*3:-8766-4383,:], label='Observed data', color='black')
axes[0].plot(m_data[-8766*2:], label='Model data')
axes[0].legend(loc='upper right')

axes[1].plot(df_aep.iloc[-8766*3:-8766*3+3000,:], label='Observed data', color='black')
axes[1].plot(m_data[-8766*2:-8766*2+3000], label='Model data')
axes[1].legend(loc='upper right')

axes[2].plot(df_aep.iloc[-8766*3+3000:-8766*3+6000,:], label='Observed data', color='black')
axes[2].plot(m_data[-8766*2+3000:-8766*2+6000], label='Model data')
axes[2].legend(loc='upper right')

axes[3].plot(df_aep.iloc[-8766*3+6000:-8766*3+9000,:], label='Observed data', color='black')
axes[3].plot(m_data[-8766*2+6000:-8766*2+9000], label='Model data')
axes[3].legend(loc='upper right')

for a in axes:
    a.set_ylabel('MW')

결과는 다음과 같이 각 스케일의 평균이 고려된 것을 알 수 있다.

회귀 모델 피팅을 통한 분석
다항식을 이용해 커브피팅한다. 
pred_idx_p1 = mpl.dates.date2num(df_aep.iloc[-8766*7-4383:-8766-4383,:].index.values)
pred_idx_p3 = mpl.dates.date2num(df_aep.iloc[4383:-8766-4383,:].index.values)

fcast_idx_p1 = mpl.dates.date2num(df_aep.loc[datetime.datetime(2011,1,1):,:].index.values)
fcast_idx_p3 = mpl.dates.date2num(df_aep.loc[datetime.datetime(2006,1,1):,:].index.values)

poly1 = np.polyfit(pred_idx_p1, sd_8766.trend.values[-8766*6-4383:-4383], 1)  # 다항식 커브피팅
poly3 = np.polyfit(pred_idx_p3, sd_8766.trend.values[4383:-4383], 3)

fcast_t_p1m = np.poly1d(poly1)
fcast_t_p3m = np.poly1d(poly3)

fcast_t_p1r = fcast_t_p1m(fcast_idx_p1)
fcast_t_p3r = fcast_t_p3m(fcast_idx_p3)

plt.figure(figsize=(18,6),dpi=100)
plt.suptitle('Comparison of linear and 3rd degree polynomial models of trend component', fontsize=20)
plt.ylabel('MW')
plt.plot(sd_8766.trend[4383:-4383], label='Trend component')
plt.plot(fcast_idx_p1, fcast_t_p1r, label='Linear model')
plt.plot(fcast_idx_p3, fcast_t_p3r, label='3rd degree polynomial model')

plt.legend()

결과는 다음과 같다.

근사 모델 피팅
앞서 획득한 연간 예측 패턴을 근사 모델로 피팅해 확인해 본다. 계절 성분은 fy() 함수에서 삼각 함수로 근사화된다. 
idxh = sd_24.seasonal.index.hour
idxw = sd_168.seasonal.index.dayofweek * 24 + idxh
idxd = sd_8766.seasonal.index.dayofyear

#defining function for approximating yearly seasonal component
#x: datetime, A,C: amplitudes, b,d: phase shifts, E: constant. Periods are predefined
def fy(x, A, b, C, d, E):
    return A * np.sin(4*np.pi/365.25 * x + b) + C * np.cos(2*np.pi/365.25 * x + d) + E

#datetime indices are converted to integers for 'fy' approximation function
tidx = mpl.dates.date2num(sd_8766.seasonal.index.values)

plt.figure(figsize=(18,6),dpi=100)
plt.plot(sd_8766.seasonal[-30000:], label='Yearly seasonal component (YSC)')
plt.plot(sd_8766.seasonal.index[-30000:], fy(tidx, 2300, 0.8, 1000, -0.25, 1)[-30000:], label='Manual approximation of YSC')
plt.legend()
params_y, params_y_covariance = sp.optimize.curve_fit(fy, idxd, sd_8766.seasonal, p0=[2300, 0.8, 1000, -0.25, 1])
print(params_y)

결과는 다음과 같다.

주간 데이터 요소 패턴 예측은 다음과 같다.
plt.figure(figsize=(18,6),dpi=100)
plt.plot(sd_8766.seasonal[-30000:], label='Yearly seasonal component (YSC)')
plt.plot(sd_8766.seasonal.index[-30000:], fy(tidx, params_y[0],
                                            params_y[1],
                                            params_y[2],
                                            params_y[3],
                                            params_y[4])[-30000:], label='Optimized approximation of YSC')
plt.legend()

#x: datetime, A,C: amplitudes, b,d: phase shifts, E: constant. Periods are predefined
def fw(x, A, b, C, d, E):
    return A * np.sin(2*np.pi/168 * x + b) + C * np.cos(2*np.pi/168 * x + d) + E

plt.figure(figsize=(18,6),dpi=100)
plt.plot(sd_168.seasonal[-1000:], label='Weekly seasonal component (WSC)')
plt.plot(sd_168.seasonal.index[-1000:], fw(idxw, 1400, 4, 600, 4, -200)[-1000:], label='Manual approximation of WSC')
plt.legend()
params_w, params_w_covariance = sp.optimize.curve_fit(fw, idxw, sd_168.seasonal, p0=[1400, 4, 600, 4, -200])
print(params_w)


UCM 모델 학습
UCM 모델을 학습해 본다. 1년치를 학습 및 테스트 데이터로 복사한다. 
y_train = df_aep.iloc[:-8766,:].copy()
y_test = df_aep.iloc[-8766:,:].copy()
model_UC1 = sm.tsa.UnobservedComponents(y_train,
                                        level='dtrend',
                                        irregular=True,
                                        stochastic_level = False,
                                        stochastic_trend = False,
                                        stochastic_freq_seasonal = [False, False, False],
                                        freq_seasonal=[{'period': 24, 'harmonics': 1}, # 시간주파수
                                                       {'period': 168, 'harmonics': 1},     # 월 주파수
                                                       {'period': 8766, 'harmonics': 2}])  # 년 주파수
model_UC1res = model_UC1.fit()
print(model_UC1res.summary())

print(f"In-sample mean absolute error (MAE): {'%.0f' % model_UC1res.mae}, In-sample root mean squared error (RMSE): {'%.0f' % np.sqrt(model_UC1res.mse)}")

UCM 모델 예측 테스트
학습된 UCM 모델을 예측하여, 오차를 테스트해본다. 
forecast_UC1 = model_UC1res.forecast(steps=8766)
f, axes = plt.subplots(7,1,figsize=(18,36),dpi=100)

RMSE_UC1 = np.sqrt(np.mean([(y_test.iloc[x,:] - forecast_UC1.values[x]) ** 2 for x in range(len(forecast_UC1))]))
MAE_UC1 = np.mean([np.abs(y_test.iloc[x,:] - forecast_UC1.values[x]) for x in range(len(forecast_UC1))])

결과는 다음과 같다. 

UCM 모델 검증
학습된 UCM 모델을 검증한다. 
model_UC1res.plot_diagnostics(figsize=(18,18),lags=60).set_dpi(200)
plt.show()

print(f"Point forecast one year ahead: {'%.1f' % forecast_UC1.values[-1]}, observed value: {y_test.iloc[-1,0]}, relative difference: {'%.2f' % ((forecast_UC1.values[-1] - y_test.iloc[-1,0]) * 100 / y_test.iloc[-1,0])}%")

결과는 다음과 같다. 정규 분포에서 잔차 이탈도는 예측 신뢰 구간을 보여준다. 이 그래프는 총 연간 소요 정확성을 보여주며, 표준 편차 RMSE의 약 10%정도 오차가 있다는 것을 확인시킨다. 



이 모델은 외인성 변수를 사용하고, 잔차를 회귀분석한다. 계절 모델에서 설명할 수 없는 분산은 제외시킨다. 일일 패턴의 분산은 온도변화에서 비롯될 수 있다. 예를 들어, 에어콘 전력 수요는 여름에 사용된다(개념).

결론

시계열 데이터셋은 여러 사이클이 중첩되어 있으며, 불확정성이 포함된다. 이러한 요소를 분해해 해석할 수 있다면, 좀 더 높은 예측 성능을 가진 딥러닝 모델을 학습할 수 있다. 

UCM은 앤드류 하비 캠브리지 대학 교수가 1989년에 계량경제학을 위한 시계열의 구조적 모델링을 위해 개발한 것이다. 하비 교수는 계량경제학에서 유명한 OxMetrics의 주 개발자 중 한명이며, 그는 영국 학술원 회원이다. 그는 계량경제학에서 큰 업적을 세운 연구자이다. 

추신. 인간이 사는 방법은 다양할 수는 있으나 소유에 집중하는 시대에서 물질의 소비와 체면 포장에 빠져 살다 세상을 떠나는 것은 참 허망한 것이다. 요즘은 불필요하고 무의미한 곳에 너무 많은 한정된 에너지를 사용하는 사람들이 많다. 소비와 체면 놀이에 빠지기 쉬운 요즘이라 이런 분들을 보며 다시 삶의 기준을 세운다. 과거 영국과 같은 선진국이 세계에서 빛날 수 있었던 것 중 하나는 지식인들이 학문적 호기심, 성찰과 노력의 결실을 모두에게 조건 없이 공유했던 철학과 문화였다. 하비 교수와 같은 그 분야의 선구자, 전문가가 남긴 유산은 죽은 뒤 아무도 알아주지 않은 쓸모없는 명품과 비교할 바가 아니다. 

레퍼런스