2016/02/26

구글 검색 차트에 대한 잡상


구글이 언제부터 검색창에서 함수를 검색했을 때 차트를 보여 주었는지는 정확히 모르지만 대략 1~2년 되지 않았나 싶다. 마침 내가 만드는 장난감도 차트 프로그램(일명 Zapary Chart)이기 때문에 함수를 몇개 검색해 보고, 내 장난감 차트와 비교해 보았다. 물론 내 장난감은 혼자서 틈틈이 깔작대는 수준이라 아직 구글 차트하고 비교하기는 이르다. 더구나 최근에 구글 검색해 보고 놀란 것은 3차원 함수는 3차원 차트로 보여 주더라. 아직, 2차원에 살고 있는 내가 3차원 얘기까지는 하고 싶지 않다.

다만, Mathematica를 만든 회사인 Wolfram에서 제공하는 wolframalpha 사이트에서도 차트나 수식을 비롯한 다양한 서비스를 예전부터 제공해 왔는데, 구글이 이런 분야까지 파고들고 있는게 아닌가 싶기도 하다.

여기서는 2차원 구글 검색 차트에 대한 감상을 몇가지 끄적이려고 한다.  구글 검색에서  1/(x*x*cos(x))를 입력해서 검색하거나 이 링크를 누르면 구글 검색 결과가 차트로 나타난다. 일단, 아래에 올린 내가 만든 장난감 차트 결과와 비교해서 설명하면 좋을 것 같다.


구글 검색 차트에서 마우스 움직이면 그래프 따라가면서 x, y 좌표가 보이는 것도 대단하진 않지만, 구글스러운 자잘한 서비스이다. 물론, 확대/축소도 되고, 3차원의 경우 회전도 지원한다.

여기서는 이런 것들보다 차트 자체에 대한 기본적인 테크닉이랄까 머 그런 것만 얘기하려고 한다.

x 축과 y 축 범위 설정 문제

함수를 검색해서 차트로 보여줄 때 가장 문제가 되는 부분이 자동 범위 설정 문제이다. 사용자가 어떤 함수를 입력할지 모르기 때문에 함수만 입력 받고서 자동으로 범위를 지정해서 보여주는 것은 쉽지 않은 일이다. 가령, 1/x은 쉽게 보여 줄수 있지만 1/(x-1e+20) - 1e30은 어떻게 처리할까? 사용자 입력 내용을 어차피 parsing 할 수 있으니 그 정보를 활용하면 도움이 될 수 있기는 하다. 차트의 중심점과 x 절편, y 절편 등을 알면 범위 지정이 한결 쉬워지니까. 하지만, 1/(x-1e+20*cos(x)*x) - 1e30*sin(x)를 검색한다면 문자열 parsing 하는 것이 해결책이 되기는 어려울 것이다. 몇가지 테스트 해봤더니 기본적인 수학 지식만으로도 범위를 지정할 수는 있겠더라.

범위 자동 Scaling

어느 범위를 보여 줄지 결정하고 나면 사용자가 보기 쉽도록 설정된 범위를 자동으로 Scaling 해 주어야 한다. 이런 기법은 옛날 옛적 Excel 초창기 시절부터 제공해 왔던 기능이기 때문에 새로울 건 없다. 데이터가 정해지면 범위를 적당히 조정해 주는 로직이다. DOS 시절에 Turbo C로 만들어 봤던 기억을 장난감 차트에도 적용했다.

점근선 경계 부분 연결선 처리

구글 검색 차트를 보면 데이터라기 보다 사람이 차트를 그린 것처럼 직선을 이어서 차트를 그렸다. 실제로 두 가지 문제가 발생하는데, 첫째는 위의 차트에서와 같이 +/- 무한대로 뻗어 나가는 부분에서 직선으로 차트를 그리면 선들이 이어져서 보기 흉하게 된다. 내 장난감 차트의 경우에도 데이터는 점으로 표시하고 이 들을 직선으로 연결했는데 이 문제를 해결함으로써 원하는 차트를 얻게 된다. 이 역시 고딩 수학을 제대로 배운사람이면 해결 가능한 문제이다.

그렇지만, 컴퓨터에서는 수학적이라기 보다는 수치적으로 처리하기 때문에 완벽할 수 없는 문제들이 생긴다. 실제로 확대 축소를 몇번 해 보면 구글 차트도 깔끔하게 연결선을 정리하지 못하는 경우를 발견하게 된다. 어쨌든 wolframalpha 사이트에서 위의 차트에 대한 함수를 검색해서 그래프를 비교해 보면 구글이 얼마나 자잘한 부분까지 신경쓰고 있는지를 알게 될 것이다.

무한대 부분 처리

그런데, 두번째 문제는 점근선 부근에서는 급격히 y값이 변하기 때문에 다른 곳보다 더 많은 데이터가 필요하다. 즉, 장난감 차트에서는 모든 선들이 위 아래로 쭉쭉 뻗지 못하고 있는데 구글 검색 차트에서는 자로 그은 듯이 쭉쭉 뻗고 있다. 데이터 량이 많아 지는 것은 성능 문제로 이어지기 때문에 샘플링을 많이 하는 것이 좋은 해결책은 아니다. 사실, 이미 해결책을 제시했다. 내 장난감 차트에서도 사실 구글 검색차트처럼 보여 줄 수 있다.

맺음말

구글 검색 차트가 완벽한 건 아니다. 몇가지 어려운 함수를 검색해 봤더니 차트를 못 보여 주더라. 하지만 구글이 무서운 건 모르는 사이에 조금씩 진화해 있다는 것이다. 이세돌과 알파고의 대국이 3월 초에 있으니 기대된다. 소시적 인공신경망은 학습 능력이 그닥이었는데 딥러닝이 얼마나 발전한 알고리즘인지 궁금하긴하다.  예전의 Backpropagation 망의 경우엔 알고보니 Steepest Descent더라는 것도 이미 소시적에 다 밝혀진 사실들이었다. 알고리즘 자체가 좋아진 것도 있겠지만 컴퓨터 처리 속도나 저장 용량의 발전도 알파고를 똑똑하게 만드는 요인이 되긴 할 것이다.

이세돌 lol

2016/02/16

c/c++ 숫자 연산에 대한 잡상 정리


틈틈이 장난감 프로그램을 만들고 있는데 Crash가 발생해서 들여다 보니 숫자 연산에 대한 문제때문이었다. 소시적에도 겪었던 일들인데 오랫만에 하다 보니 같은 실수를 반복하게 된다. 그래서 몇가지 공돌이들이 숫자 연산을 하다가 자주 실수하는 부분들을 정리해 보기로 했다.

수치해석을 들을 때 기본이 되는 컴퓨터에서의 숫자 연산에 대한 부분은 설명이 없어서 시행착오를 많이 겪었던 기억이 난다. 수치해석이란 학문 자체가 가능한 정확한 수치해를 구하는 것이 목적인데 사소한 숫자 연산 오류 때문에 심각한 수치해 오류가 발생할 수 있다. 제목에 c/c++ 라고 했지만, 사실 프로그램 언어와 관계없이 발생할 수 있는 문제들이기도 하다.

1. 정수형에 대한 나누기 연산

초보적인 상식이지만 실제 프로그램을 하다보면 실수를 하게 되는 부분이다. 형 변환시 정확한 값을 사용하지 않음으로 인해 오류가 증폭된다. 아래의 예와 같이 심지어 round() 함수를 쓰면 만사 땡인 줄 알고 실수하는 경우도 많다.
int m = 15, n = 2;

int i1 = m/n;                // i1 = round(1.0*m/n);
int i2 = round(m/n);         // i2 = round(1.0*m/n);
double x1 = 15/n;            // x1 = 15.0/n;
double x2 = m/n;             // x2 = 1.0*m/n;
2. 숫자 0 으로 나누기

정수형을 사용할 경우 0으로 나누면 프로그램이 바로 죽어 버리기 때문에 오류 위치를 쉽게 확인할 수 있다. 그런데, 실수형인 경우엔 프로그램이 0으로 나누는 부분에서 죽지 않는다. 0으로 어떤 수를 나누면 무한대 인데, 어디선가 무한대를 사용하다가 죽어 버린다. 물론 debugger가 어느 정도 죽는 위치를 알려 주지만 정확한 위치를 찾기 어려운 경우도 많다.
int int_zero = 0;
double real_zero = 0;

int i3 = m/int_zero;         // devide by zero. crash~!!!
double x3 = m/int_zero;      // devide by zero. crash~!!!
double x4 = m/real_zero;     // inf (infinity is OK~!!!) 
3. 엄청 크거나 작은 숫자에 대한 예외 처리

위와 같이 무한대에 가까운 큰 수들은 컴퓨터에서 표현할 수 있는 수치상의 한계를 넘기 때문에 예외처리를 해주어야 하는데 c++ 표준에서는 아래의 극한 값들을 정의하고 있다.
#include <limits>

double INFI = std::numeric_limits<double>::infinity();
double MAXI = std::numeric_limits<double>::max();
double EPSI = std::numeric_limits<double>::epsilon();

bool truth1 = (INFI > MAXI);        // true
bool truth2 = (-INFI < -MAXI);      // true
bool truth3 = (x4 > MAXI);          // true

double zero = 1.0/MAXI;             // positive zero
즉, 무한대는 최대값 보다 더 크며, 최대값과 동일하게 다른 수와 비교하여 예외처리를 할 수는 있으나 데이터 연산에 사용되면 프로그램이 갑자기 죽어 버리는 증상이 나타난다. 때로는 안죽고 이상한 숫자를 간직하고 있는 경우도 있다. 최대값에 가까운 큰 수로 어떤 수를 나누면 엄청 작은 수가 발생하는데, 이 놈들 역시 예외 처리를 해 주어야 한다.

4. 실수형에 대한 숫자 비교

프로그램이 죽어 버리면 상대적으로 문제를 쉽게 해결할 수 있는데, if문에서 실수형을 비교하다가 생기는 논리 오류는 찾기가 무척 어렵다. 미리 NearEqual()이나 NearZero() 같은 함수를 만들어 놓고 비교하는 것이 좋은 습관이 될 것이다. 또한, 가능한 상황일 경우, 아예 정수형으로 변환해서 값을 비교하는 것도 좋은 해결책이 될 수 있다.
int n1 = 3, n2 = 15/5;
double d1 = 3, d2 = 15/5.0;

bool b1 = (n1 == n2);             // always true~!!!
bool b2 = (d1 == d2);             // sometimes false.
bool b3 = (fabs(d1-d2) <= EPSI);  // something better.

int n3 = n2 - n1;
double d3 = d2 - d1;

bool b4 = (n3 == 0);              // always true~!!!
bool b5 = (d3 == 0);              // sometimes false.
bool b6 = (fabs(d3) <= EPSI);     // something better.
위에서 절대 오차나 절대값 자체가 매우 작은 수인 epsilon보다 작으면 숫자가 같거나 0이라고 간주하는 부분은 상황에 맞게 사용해야 한다. 가령, log 함수를 사용해야 할만큼 작은 숫자들을 가지고 논다면 상대오차도 비교해야 할 것이다.

5. 0으로 나누기 방지

의도치 않게 종종 나누기에 사용되는 변수 값이 0이 되어 버리는 경우가 발생할 수 있다. 이를 방지 하기 위해서 아래와 같이 protection을 걸어 두는 게 좋은 습관이 된다.
int m = 10, n = 0;
if(n) x2 = m/n;
else std::cerr << "Fatal Error: divide by zero.\n";

double d1 = 15/3.0, d2 = 5;
double t = 0, v = 20, d = d2 - d1;
if(fabs(d) > EPSI) t = v/d;
else std::cerr << "Fatal Error: divide by zero.\n";
이는 pointer 사용시 nullptr가 아닌지 확인해서 작업을 수행할 때 protection을 거는 것과 유사하다.
int n = 3, *p = n;
if(p) *p += 5;
else std::cerr << "Fatal Error: null pointer operation.\n"; 
6. NaN(Not-A-Number) 값에 대한 예외 처리

NaN 값은 0을 실수형 0으로 나누거나 수학 함수 값에 처리할 수 없는 범위의 입력값을 주었을 때 발생한다.
double d1 = 5*(3-3)/0.0;        // d1 = nan
double d2 = 10*log(-1);         // d2 = nan
NaN 값이 발생되어도 프로그램은 문제없이 동작하는 것처럼 보인다. 하지만 if 문에서 모르고 사용하면 엉뚱한 결과가 나올 수 있다.
bool b1 = (d2 <  0);            // false
bool b2 = (d2 >= 0);            // false
NaN이 발생하리라 예상되는 부분에 예외 처리를 해주는 것이 최선이다. 0으로 나누는 부분을 앞서 다룬 바와 같이 잘 처리하고 있다면 NaN이 발생할 수 있는 경우는 log 함수와 같이 특별한 수학 함수를 사용하는 경우가 아니면 발생할 일이 별로 없기는 하다.
if(std::isnan(d2)) std::cerr << "Warning Error: NaN value operation.";
7. 연산자 우선 순위

이 부분도 내가 자주 실수했던 부분이다. 연산자 우선 순위가 애매하면 괄호치는 습관으로 해결할 수 있다. 이런 종류의 논리 오류도 찾기가 무척 어렵다.
int size = 100, step = 10;

if(step && !size%step) ...;       // if(step && !(size%step)) ...;
8. 음수에 대한 나머지(remainder) 연산자(%)

간혹 음수까지 고려해서 나머지 값을 사용해야 할 경우가 생기는데, 항상 조심해야 하는 부분이다.
int a1 = -5%7, a2 = -5%(-7);      // a1 = a2 = -5
참고로, Python에서는 심지어 a1 = 2가 되더라. 리눅스에서 python으로 수식값을 빨리 확인해 보는 경우가 많은데, 프로그램 언어마다 상식적인 연산 결과가 다르게 나올 수도 있다는...

9. 짝수와 홀수

그래픽 프로그램을 만들고 있다면 짝수를 좀더 짝사랑하게 된다. pixel이 어긋나는 문제를 쉽게 해결할 수 있으니까.
if(n%2) ...
10. 유효숫자, 그리고 소수

과학이나 공학적인 계산에서 유효숫자는 매우 중요하다. 갯수를 헤아리거나 size를 다룰 경우 정확한 값을 사용해야 한다. 미분값을 구하는 경우에는 아주 작은 수치오류도 크게 증폭될 수 있다.
int n = 1000000 + 1;            // n = 1000001
double d1 = 1e+6 + 1;           // d1 = 1e+6
double d2 = MAXI - 1e+300;      // d2 = MAXI = 1.79769e+308 (64-bit system)
한편으로, 숫자의 범위를 다루거나 할 때 백만분의 1이라는 숫자는 무시해도 된다. 위에서 최대값에 아주 큰 수를 뺐지만 최대값의 유효숫자에  영향을 줄 정도로 큰 수는 아니므로 d2값은 MAXI가 된다. 유효 숫자를 제대로 고려하는 것은 간단한 문제는 아니다. 이런 문제는 수치해석에서도 다루는 문제들이기도 하다.

유효숫자 자리 수가 엄청 중요한 수가 소수(prime number)이다. 소수 자체가 유효숫자 덩어리니까. 전에 TV에서 봤더니 외국의 어느 인증회사에서는 금고에 몇 메가 바이트에 달하는 소수를 보관하고 있닥카더라.

11. 난수 구하기

가끔 특정 범위의 난수를 사용해야 할 때가 있다. 아래의 함수를 for loop에서 사용한다면 난수가 아니라 상수를 사용하게 된다.
// return random number in the range of (-max, max)
int Rand(int max)
{
    if(!max) return 0;

    srand(time(0));
    return rand() % (2*max) - max;
}
프로그램 실행시마다 새로운 난수가 발생되도록 현재 시간으로 seed를 주었는데 의도와는 달리 컴퓨터가 워낙 빨라서 for loop 내에서 현재시간이 계속 유지되기 때문이다. seed 주는 부분을 빼면 그나마 난수가 발생되는데 이른바 pseudo-random number이다. 즉, 프로그램을 새로 시작할 때마다 동일한 난수가 발생된다.

약간의 trick을 쓰면 의도대로 프로그램을 새로 시작해도 새로운 난수가 발생하도록 할 수 있을 것이다. 그리고, 위의 함수에 한가지 문제가 더 있는데 max 값이 너무 작으면 균일한 난수가 발생되지 않는다(non-uniform distribution). c++11에서는 새로운 <random> 헤더가 있던데 새로 배워야 할만큼 필요하진 않아서 패스...