Proj4js

이 글은 proj4js.org 사이트에서 제공되는 내용을 파악하기 위해 정리한 포스트이며, 좀 더 상세한 내용을 추가적으로 담고자 노력하였습니다.

Proj4js는 좌표계 간의 상호 변환하기 위한 자바스크립트 라이브러리이며 서로 다른 타원체 간의 Datum 변환 기능을 포함하고 있습니다. 이 라이브러리는 원래 C언어로 개발된 PROJ.4와 MetaCRS 그룹의 프로젝트 중의 하나인 GCTCP C를 JavaScript로 포팅한 것입니다.

설치

개발자의 개발환경에 따라 다르지만, 아래의 4가지 방식 중 한가지 방식으로 설치가 가능합니다.

npm install proj4
bower install proj4
jam install proj4
component install proj4js/proj4js

또는 최신 배포의 dist/ 폴더에서 proj4.js 파일을 직접 사용할 수 있습니다. 아무것도 다운로드 받고 싶지 않다면 CDN을 통한 URL로 라이브러리를 사용해도 됩니다.

사용 방법

기본적인 사용 구문은 다음과 같습니다.

proj4(fromProjection[, toProjection, coordinates])

인자 fromProjection와 toProjection는 proj이거나 WTK 문자열일 수 있습니다. coordinates 인자는 {x:x,y:y}와 같은 객체 형태이거나 [x,y]와 같은 배열 형태일 수 있습니다.

fromProjection, toProjection, coordinates 인자가 모두 지정되면, 지정된 좌표가 fromProjection 좌표 체계에서 toProjection 좌표 체계로 변환된 결과를 반환합니다. 변환된 결과는 지정된 좌표 인자의 형태와 같습니다.

var firstProjection = 'PROJCS["NAD83 / Massachusetts Mainland",GEOGCS["NAD83",DATUM["North_American_Datum_1983",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],AUTHORITY["EPSG","6269"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.01745329251994328,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4269"]],UNIT["metre",1,AUTHORITY["EPSG","9001"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["standard_parallel_1",42.68333333333333],PARAMETER["standard_parallel_2",41.71666666666667],PARAMETER["latitude_of_origin",41],PARAMETER["central_meridian",-71.5],PARAMETER["false_easting",200000],PARAMETER["false_northing",750000],AUTHORITY["EPSG","26986"],AXIS["X",EAST],AXIS["Y",NORTH]]';

var secondProjection = "+proj=gnom +lat_0=90 +lon_0=0 +x_0=6300000 +y_0=6300000 +ellps=WGS84 +datum=WGS84 +units=m +no_defs";

var result = proj4(firstProjection, secondProjection, [2, 5]);
// [-2690666.2977344505, 3662659.885459918]

만약 1개의 projection 만을 사용한다면 해당 인자는 fromProjection을 의미하며, firstProjection은 WGS84 경위도 좌표계가 됩니다.

var firstProjection = 'PROJCS["NAD83 / Massachusetts Mainland",GEOGCS["NAD83",DATUM["North_American_Datum_1983",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],AUTHORITY["EPSG","6269"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.01745329251994328,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4269"]],UNIT["metre",1,AUTHORITY["EPSG","9001"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["standard_parallel_1",42.68333333333333],PARAMETER["standard_parallel_2",41.71666666666667],PARAMETER["latitude_of_origin",41],PARAMETER["central_meridian",-71.5],PARAMETER["false_easting",200000],PARAMETER["false_northing",750000],AUTHORITY["EPSG","26986"],AXIS["X",EAST],AXIS["Y",NORTH]]';

var result = proj4(firstProjection, [-71, 41]);
// [242075.00535055372, 750123.32090043]

또한 coordinates 인자 없이 projection 인자만을 사용한다면, forward와 inverse 매서드를 갖는 객체가 반환되는데, forward는 fromProjection 좌표계에서 toProjection로의 좌표 변환을, inverse는 toProjection 좌표계에서 fromProjection로의 좌표 변환을 수행하는 매서드입니다.

var firstProjection = 'PROJCS["NAD83 / Massachusetts Mainland",GEOGCS["NAD83",DATUM["North_American_Datum_1983",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],AUTHORITY["EPSG","6269"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.01745329251994328,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4269"]],UNIT["metre",1,AUTHORITY["EPSG","9001"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["standard_parallel_1",42.68333333333333],PARAMETER["standard_parallel_2",41.71666666666667],PARAMETER["latitude_of_origin",41],PARAMETER["central_meridian",-71.5],PARAMETER["false_easting",200000],PARAMETER["false_northing",750000],AUTHORITY["EPSG","26986"],AXIS["X",EAST],AXIS["Y",NORTH]]';

var secondProjection = "+proj=gnom +lat_0=90 +lon_0=0 +x_0=6300000 +y_0=6300000 +ellps=WGS84 +datum=WGS84 +units=m +no_defs";

var resultA = proj4(firstProjection, secondProjection).forward([2, 5]);
// [-2690666.2977344505, 3662659.885459918]

var resultB = proj4(secondProjection,firstProjection).inverse([2,5]);
// [-2690666.2977344505, 3662659.885459918]

projection 인자가 하나만 지정되면, 지정된 인자는 toProjection에 해당되며 fromProjection은 WGS84 경위도 좌표계가 됩니다.

이름을 가지는 투영변환

만약 문자열로 투영변환에 이름을 부여하고, 이 이름으로 좌표 변환을 수행하고자 한다면 proj4.defs 매서드를 사용할 수 있습니다.

proj4.defs('WGS84', "+title=WGS 84 (long/lat) +proj=longlat +ellps=WGS84 +datum=WGS84 +units=degrees");

또는 아래처럼 배열로 여러개의 투영변환을 정의할 수 있습니다.

proj4.defs([
  [
    'EPSG:4326',
    'PROJCS["NAD83 / Massachusetts Mainland",GEOGCS["NAD83",DATUM["North_American_Datum_1983",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],AUTHORITY["EPSG","6269"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.01745329251994328,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4269"]],UNIT["metre",1,AUTHORITY["EPSG","9001"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["standard_parallel_1",42.68333333333333],PARAMETER["standard_parallel_2",41.71666666666667],PARAMETER["latitude_of_origin",41],PARAMETER["central_meridian",-71.5],PARAMETER["false_easting",200000],PARAMETER["false_northing",750000],AUTHORITY["EPSG","26986"],AXIS["X",EAST],AXIS["Y",NORTH]]]'
  ],
  [
    'EPSG:4269',
    '+proj=gnom +lat_0=90 +lon_0=0 +x_0=6300000 +y_0=6300000 +ellps=WGS84 +datum=WGS84 +units=m +no_defs'
  ]
]);

그러면, 언제라도 다음처럼 사용할 수 있습니다.

var p = proj4('EPSG:4326', 'EPSG:4269');
var result = p.forward([-71, 41]);
// [-2690599.9886444192, 3662814.7663661353]

사실 위 코드의 첫번째 라인은 다음의 축약형입니다.

var p = proj4(proj4.defs('EPSG:4326'), proj4.defs('EPSG:4269'));

미리 정의된 이름을 가지는 투영변환은 EPSG:4326, EPSG:4269, EPSG:3857입니다. 아울러 EPSG:4326은 WGS84라는 이름으로도 정의되어 있으며, EPSG:3857은 EPSG:3758, GOOGLE, EPSG:900913, EPSG:102113이라는 다양한 이름으로도 정의되어 있습니다. 이에 대한 proj4js 라이브러리의 코드를 살펴보면 다음과 같습니다.

defs('EPSG:4326', "+title=WGS 84 (long/lat) +proj=longlat +ellps=WGS84 +datum=WGS84 +units=degrees");
defs('EPSG:4269', "+title=NAD83 (long/lat) +proj=longlat +a=6378137.0 +b=6356752.31414036 +ellps=GRS80 +datum=NAD83 +units=degrees");
defs('EPSG:3857', "+title=WGS 84 / Pseudo-Mercator +proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +no_defs");

defs.WGS84 = defs['EPSG:4326'];
defs['EPSG:3785'] = defs['EPSG:3857']; // maintain backward compat, official code is 3857
defs.GOOGLE = defs['EPSG:3857'];
defs['EPSG:900913'] = defs['EPSG:3857'];
defs['EPSG:102113'] = defs['EPSG:3857'];

아래는 한국의 좌표계를 예로 들어 proj4js의 설명을 담은 글입니다. 2013년도 글이라 현재 버전의 API와 맞지 않을 수 있습니다.

[GIS] 오픈소스, 자바스크립트 좌표계 변환 라이브러리, proj4js

끝으로 EPSG 코드를 통한 proj 및 wkt를 얻을 수 있는 사이트에 대한 글은 아래와 같습니다.

EPSG.io를 통한 proj4 문자열 얻기

특성값 2개를 입력하여 3가지로 분류하는 신경망

이 글에서 소개하고 있는 소스코드는 사이토 고키가 저술한 “Deep Learning from Scratch”의 첫장에서 소개하고 있는 신경망입니다. 한국어판으로는 한빛미디어의 “밑바닥부터 시작하는 딥러닝2″로도 보실 수 있습니다. 해당 도서의 원저자의 허락 하에 글을 올립니다. 특히 공간상의 좌표와 속성값들에 대한 분류에 대한 내용인지라 GIS 분야에서 흔하게 활용될 수 있는 내용이라고 생각됩니다. 보다 자세한 내용은 해당도서를 추천드립니다.

공간 상의 (x,y)에 대한 하나의 지점에 대해서.. 가재, 물방개, 올챙이인 3가지 종류의 식생이 분포하고 있는 생태계가 있다고 합시다. 즉, 입력되는 특성값은 2개일때 3개 중 한개를 결정해야 합니다. 다시 말해, 이미 채집한 데이터가 있고, 이 데이터를 이용해 예측 모델을 훈련시킬 것이며, 훈련된 모델을 이용하면 임이의 (x,y) 위치에 대해 존재하는 식생이 무엇인지를 예측하고자 합니다. 그럼 먼저 예측 모델을 훈련할때 사용할 채집 데이터가 필요한데, 아래의 load_spiral_data 함수가 이러한 채집 데이터를 생성해 줍니다. 아래의 코드는 load_spiral_data 함수를 이용해 채집 데이터를 생성하고 시각화합니다.

import numpy as np
import matplotlib.pyplot as plt

def load_spiral_data(seed=7777):
    np.random.seed(seed)
    DIM = 2  # 입력 데이터 특성 수
    CLS_NUM = 3  # 분류할 클래스 수
    N = 100  # 클래스 하나 당 샘플 데이터 수

    x = np.zeros((N*CLS_NUM, DIM))
    t = np.zeros((N*CLS_NUM, CLS_NUM), dtype=np.int)

    for j in range(CLS_NUM):
        for i in range(N): # N*j, N*(j+1)):
            rate = i / N
            radius = 1.0*rate
            theta = j*4.0 + 4.0*rate + np.random.randn()*0.2

            ix = N*j + i
            x[ix] = np.array([radius*np.sin(theta),
                              radius*np.cos(theta)]).flatten()
            t[ix, j] = 1

    return x, t

x, t = load_spiral_data()
N = 100
CLS_NUM = 3
markers = ['o', 'x', '^']
for i in range(CLS_NUM):
    plt.scatter(x[i*N:(i+1)*N, 0], x[i*N:(i+1)*N, 1], s=40, marker=markers[i])
plt.show()

결과는 아래와 같습니다.

훈련용 데이터가 준비되었으므로, 이제 예측 모델에 대한 신경망에 대한 코드를 살펴보겠습니다. 아래는 은닉층이 1개인 신경망에 대한 클래스입니다. 은닉층이 1개이구요. 이 클래스의 생성자에 대한 인자는 입력되는 특성값의 개수(input_size)와 은닉층의 뉴런 개수(hidden_size) 그리고 분류 결과 개수(output_size)입니다.

class Net:
    def __init__(self, input_size, hidden_size, output_size):
        I, H, O = input_size, hidden_size, output_size

        W1 = 0.01 * np.random.randn(I, H)
        b1 = np.zeros(H)
        W2 = 0.01 * np.random.randn(H, O)
        b2 = np.zeros(O)

        self.layers = [
            Affine(W1, b1),
            Sigmoid(),
            Affine(W2, b2)
        ]
        self.loss_layer = SoftmaxWithLoss()

        self.params, self.grads = [], []
        for layer in self.layers:
            self.params += layer.params
            self.grads += layer.grads

    def predict(self, x):
        for layer in self.layers:
            x = layer.forward(x)
        return x

    def forward(self, x, t):
        score = self.predict(x)
        loss = self.loss_layer.forward(score, t)
        return loss

    def backward(self, dout=1):
        dout = self.loss_layer.backward(dout)
        for layer in reversed(self.layers):
            dout = layer.backward(dout)
        return dout

신경망의 뉴런값과 가중치 그리고 편향에 대한 연산을 위해 Affine 클래스가 사용됬으며, 신경망의 기반이 되는 선형 회귀에 비선형 회귀 특성을 부여하는 활성화함수로 Sigmoid 클래스가 사용되었습니다. 아울러 3개 이상의 출력결과를 확률로 해석하고 이를 기반으로 손실값을 계산할 수 있는 Softmax와 Cross Entropy Error를 하나의 합친 SoftmaxWithLoss 클래스가 사용되었습니다.

먼저 Affine, Sigmoid 클래스의 코드는 다음과 같습니다.

class Affine:
    def __init__(self, W, b):
        self.params = [W, b]
        self.grads = [np.zeros_like(W), np.zeros_like(b)]
        self.x = None

    def forward(self, x):
        W, b = self.params
        out = np.dot(x, W) + b
        self.x = x
        return out

    def backward(self, dout):
        W, b = self.params
        dx = np.dot(dout, W.T)
        dW = np.dot(self.x.T, dout)
        db = np.sum(dout, axis=0)

        self.grads[0][...] = dW
        self.grads[1][...] = db
        return dx

class Sigmoid:
    def __init__(self):
        self.params, self.grads = [], []
        self.out = None

    def forward(self, x):
        out = 1 / (1 + np.exp(-x))
        self.out = out
        return out

    def backward(self, dout):
        dx = dout * (1.0 - self.out) * self.out
        return dx

그리고 SoftmaxWithLoss 클래스는 다음과 같습니다.

class SoftmaxWithLoss:
    def __init__(self):
        self.params, self.grads = [], []
        self.y = None  # Softmax의 출력
        self.t = None  # 정답 레이블

    def forward(self, x, t):
        self.t = t
        self.y = softmax(x)

        # 정답 레이블이 원핫 벡터일 경우 정답의 인덱스로 변환
        if self.t.size == self.y.size:
            self.t = self.t.argmax(axis=1)

        loss = cross_entropy_error(self.y, self.t)
        return loss

    def backward(self, dout=1):
        batch_size = self.t.shape[0]

        dx = self.y.copy()
        dx[np.arange(batch_size), self.t] -= 1
        dx *= dout
        dx = dx / batch_size

        return dx

우의 클래스는 Softmax 연산과 Cross Entropy Error 연산을 각각 softmax와 cross_entropy_error 함수를 통해 처리하고 있습니다. 이 두 함수는 아래와 같습니다.

def softmax(x):
    if x.ndim == 2:
        x = x - x.max(axis=1, keepdims=True)
        x = np.exp(x)
        x /= x.sum(axis=1, keepdims=True)
    elif x.ndim == 1:
        x = x - np.max(x)
        x = np.exp(x) / np.sum(np.exp(x))

    return x

def cross_entropy_error(y, t):
    if y.ndim == 1:
        t = t.reshape(1, t.size)
        y = y.reshape(1, y.size)
        
    # 정답 데이터가 원핫 벡터일 경우 정답 레이블 인덱스로 변환
    if t.size == y.size:
        t = t.argmax(axis=1)
             
    batch_size = y.shape[0]

    return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size

학습 데이터와 모델까지 준비되었으므로, 이제 실제 학습에 대한 코드를 살펴보겠습니다. 먼저 학습을 위한 하이퍼파라메터 및 모델생성 그리고 기타 변수값들입니다.

# 하이퍼파라미터
max_epoch = 300
batch_size = 30
hidden_size = 10
learning_rate = 1.0

# 모델
model = Net(input_size=2, hidden_size=hidden_size, output_size=3)

# 가중치 최적화를 위한 옵티마이저
optimizer = SGD(lr=learning_rate)

# 학습에 사용하는 변수
data_size = len(x)
max_iters = data_size // batch_size
total_loss = 0
loss_count = 0
loss_list = []

다음은 실제 학습 수행 코드입니다.

for epoch in range(max_epoch):
    # 데이터 뒤섞기
    idx = np.random.permutation(data_size)
    x = x[idx]
    t = t[idx]

    for iters in range(max_iters):
        batch_x = x[iters*batch_size:(iters+1)*batch_size]
        batch_t = t[iters*batch_size:(iters+1)*batch_size]

        # 기울기를 구해 매개변수 갱신
        loss = model.forward(batch_x, batch_t)
        model.backward()
        optimizer.update(model.params, model.grads)

        total_loss += loss
        loss_count += 1

        # 정기적으로 학습 경과 출력
        if (iters+1) % 10 == 0:
            avg_loss = total_loss / loss_count
            print('| 에폭 %d |  반복 %d / %d | 손실 %.2f'
                  % (epoch + 1, iters + 1, max_iters, avg_loss))
            loss_list.append(avg_loss)
            total_loss, loss_count = 0, 0

출력값으로써 매 학습 단계마다 손실값이 줄어드는 것을 확인할 수 있습니다.

아래는 위의 학습이 완료되면 그 결과를 시각화해주는 코드입니다.

plt.plot(np.arange(len(loss_list)), loss_list, label='train')
plt.xlabel('Loop (x10)')
plt.ylabel('Loss')
plt.show()

결과는 다음과 같습니다.

이제 이 학습된 모델을 이용해 새로운 입력 데이터를 분류하고 이에 대한 결과를 효과적으로 시각해 주는 코드는 아래와 같습니다.

# 경계 영역 플롯
h = 0.001
x_min, x_max = x[:, 0].min() - .1, x[:, 0].max() + .1
y_min, y_max = x[:, 1].min() - .1, x[:, 1].max() + .1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
X = np.c_[xx.ravel(), yy.ravel()]
score = model.predict(X)
predict_cls = np.argmax(score, axis=1)
Z = predict_cls.reshape(xx.shape)
plt.contourf(xx, yy, Z)
plt.axis('off')

# 데이터점 플롯
x, t = load_spiral_data()
N = 100
CLS_NUM = 3
markers = ['o', 'x', '^']
for i in range(CLS_NUM):
    plt.scatter(x[i*N:(i+1)*N, 0], x[i*N:(i+1)*N, 1], s=40, marker=markers[i])
plt.show()

결과는 아래와 같습니다.

~은/는, ~이/가, ~을/를

fun _w(name: String, firstValue: String, secondValue: String): String {
    val lastName = name[name.length - 1]

    if (lastName.toInt() < 0xAC00 || lastName.toInt() > 0xD7A3) return name

    val seletedValue = if ((lastName.toInt() - 0xAC00) % 28 > 0) firstValue else secondValue

    return name + seletedValue
}

fun main() {
    val text = "${_w("컴퓨터", "은", "는")} ${_w("키보드", "이", "가")} ${_w("우리", "을", "를")} 돕는다."
    println(text)
}

결과는 다음과 같다.

이 컴퓨터는 키보드가 우리를 돕는다.

다중분류를 위한 대표적인 손실함수, torch.nn.CrossEntropyLoss

딥러닝의 많은 이론 중 가장 중요한 부분이 손실함수와 역전파입니다. PyTorch에서는 다양한 손실함수를 제공하는데, 그 중 torch.nn.CrossEntropyLoss는 다중 분류에 사용됩니다. torch.nn.CrossEntropyLoss는 nn.LogSoftmax와 nn.NLLLoss의 연산의 조합입니다. nn.LogSoftmax는 신경망 말단의 결과 값들을 확률개념으로 해석하기 위한 Softmax 함수의 결과에 log 값을 취한 연산이고, nn.NLLLoss는 nn.LogSoftmax의 log 결과값에 대한 교차 엔트로피 손실 연산(Cross Entropy Loss|Error)입니다.

Softmax와 교차 엔트로피 손실 연산에 대한 각각의 설명은 아래와 같습니다.

활성화 함수(Activation Function)

손실함수(Loss Function)

참고로 nn.NLLLoss의 구현 코드는 아래와 같습니다.

import torch

def NLLLoss(logs, targets):
    out = torch.zeros_like(targets, dtype=torch.float)
    for i in range(len(targets)):
        out[i] = logs[i][targets[i]]
    return -out.sum()/len(out)

물론 PyTorch에서도 torch.nn.NLLLoss를 통해 위와 동일한 기능을 제공합니다. 결과적으로 Softmax의 Log 결과를 Cross Entropy Loss 값의 결과를 얻기 위해 3가지 방식이 존재하는데, 아래와 같습니다.

x = torch.Tensor([[0.8982, 0.805, 0.6393, 0.9983, 0.5731, 0.0469, 0.556, 0.1476, 0.8404, 0.5544]])
y = torch.LongTensor([1])

# Case 1
cross_entropy_loss = torch.nn.CrossEntropyLoss()
print(cross_entropy_loss(x, y)) # tensor(2.1438)

# Case 2
log_softmax = torch.nn.LogSoftmax(dim=1)
x_log = log_softmax(x)
print(NLLLoss(x_log, y)) # tensor(2.1438)

# Case 3
nll_loss = torch.nn.NLLLoss()
print(nll_loss(x_log, y)) # tensor(2.1438)

위의 세가지 방식 중 torch.nn.CrossEntropyLoss처럼 연산을 한번에 처리하는 것이 수식이 간소화되어 역전파가 더 안정적으로 이루지므로 실제 사용에 권장됩니다.

torch.nn.CrossEntropyLoss를 이용하여 손실값을 구하는 것에 초점을 맞춰보면.. 먼저 torch.nn.CrossEntropyLoss의 수식은 다음과 같습니다.

    $$loss(x,class)=-\log\biggl(\frac{\exp(x[class])}{\sum_{j}{\exp(x[j])}}\biggr)=-x[class]+\log\biggl(\sum_{j}{\exp(x[j])}}\biggr)$$

위의 수식을 살펴보면 앞서 언급한 것처럼 Softmax와 Log처리 및 Cross Entropy Loss 연산의 조합이라는 것을 알수 있습니다.

torch.nn.CrossEntropyLoss를 코드를 통해 설명하면… 예를들어 신경망 말단에서 총 10개의 값(앞서 언급한 x값)이 출력되었고, 실제 레이블 값(앞서 언급한 y 또는 class)은 1일때에 손실값을 구하는 코드는 아래와 같습니다.

import torch
import torch.nn as nn
import numpy as np

output = torch.Tensor([[0.8982, 0.805, 0.6393, 0.9983, 0.5731, 0.0469, 0.556, 0.1476, 0.8404, 0.5544]])
target = torch.LongTensor([1])

criterion = nn.CrossEntropyLoss()
loss = criterion(output, target)
print(loss) # tensor(2.1438)

참고로 위의 코드에서 사용된 nn.CrossEntropyLoss의 수식을 알고 있으므로 nn.CrossEntropyLoss을 사용하지 않고 직접 손실값을 계산한다면 다음과 같습니다.

import torch
import torch.nn as nn
import numpy as np

output = [0.8982, 0.805, 0.6393, 0.9983, 0.5731, 0.0469, 0.556, 0.1476, 0.8404, 0.5544]
target = [1]
loss = np.log(sum(np.exp(output))) - output[target[0]]
print(loss) # 2.143818427948945

손실값이 필요할 때는 신경망의 학습인데, 학습에서 데이터는 GPU 자원을 최대한 활용하기 위해 배치 단위로 처리됩니다. 즉, 앞서 언급한 것처럼 1개 단위가 아닌 2개 이상의 데이터가 한꺼번에 들어온다는 것입니다. 이에 대한 처리에 대한 예는 다음 코드와 같습니다.

import torch
import torch.nn as nn
import numpy as np

output = torch.Tensor(
    [
        [0.8982, 0.805, 0.6393, 0.9983, 0.5731, 0.0469, 0.556, 0.1476, 0.8404, 0.5544],
        [0.9457, 0.0195, 0.9846, 0.3231, 0.1605, 0.3143, 0.9508, 0.2762, 0.7276, 0.4332]
    ]
)

target = torch.LongTensor([1, 5])

criterion = nn.CrossEntropyLoss()
loss = criterion(output, target)
print(loss) # tensor(2.3519)

위의 코드를 nn.CrossEntropyLoss()를 사용하지 않고 계산한다면 다음 코드와 같구요.

import torch
import torch.nn as nn
import numpy as np

output = [0.8982, 0.805, 0.6393, 0.9983, 0.5731, 0.0469, 0.556, 0.1476, 0.8404, 0.5544]
target = [1]
loss1 = np.log(sum(np.exp(output))) - output[target[0]]

output = [0.9457, 0.0195, 0.9846, 0.3231, 0.1605, 0.3143, 0.9508, 0.2762, 0.7276, 0.4332]
target = [5]
loss2 = np.log(sum(np.exp(output))) - output[target[0]]

print((loss1 + loss2)/2) # 2.351937720511233

즉, 배치 처리에 대한 손실값은 배치를 구성하는 각 데이터의 손실값들의 평균이라는 점을 확인할 수 있습니다.

전이 학습(Transfer Learning)

전이 학습(Transfer Learning)은 특정 분야에서 학습된 신경망의 일부 능력을 유사하거나 전혀 새로운 분야에서 사용되는 신경망의 학습에 이용하는 것을 의미합니다.

이미지 분류를 예로 들어 Resnet이나 VGG 등과 같은 신경망의 구성 중 앞단은 CNN 레이어로 구성되어 있습니다. 이 CNN 레이어는 이미지의 특징을 추출하는 능력을 갖는데요. 처음에는 신형성을 추출하고 다음에는 패턴을, 마지막에는 형상 등을 추출한다고 알려져 있습니다. 이러한 이미지의 특징을 추출하는 신경망의 능력은 다른 분야에서도 활용될 수 있습니다. 즉, 수만에서 수천만장의 이미지를 통해 학습된 높은 성능을 갖는 Resnet이나 VGG 신경망의 특징 추출 능력을 그대로 이용하고, 마지막 출력 계층으로써.. 주로 선형(Affine; 가중치와 편향에 대한 행렬 연산) 레이어만을 변경하여 이 변경된 레이어만을 재학습시키는 것이 전이 학습입니다.

전이 학습은 학습 데이터의 수가 적을때도 효과적이며, 학습 속도도 빠릅니다. 그리고 전이학습 없이 학습하는 것보다 훨씬 높은 정확도를 제공한다는 장점이 있습니다.

이 글은 Resnet과 VGG 신경망에 대한 전이학습 코드 중 전이학습을 위한 전처리 코드를 정리합니다. 나머지 학습 등의 코드는 여타 다른 신경망과 동일합니다. 먼저 전이학습을 위한 Resnet 신경망의 전처리 코드입니다.

import torch.nn as nn
from torchvision import models

net = models.resnet18(pretrained=True)

for p in net.parameters():
    p.requires_grad = False

fc_input_dim = net.fc.in_features
net.fc = nn.Linear(fc_input_dim, 2)

먼저 이미 학습된 resnet18 신경망을 불러오고, 이 신경망의 가중치가 학습되지 않도록 합니다. 그리고 이 신경망의 마지막 구성 레이어(fully connected layer로써 Affine Layer, Dense layer라고도 함)의 입력 데이터 수를 얻고, 이렇게 얻는 입력 데이터의 수와 출력하고자 하는, 즉 분류 개수인 2에 대한 선형 레이어를 생성하여 신경망을 구성하는 마지막 레이어를 교체합니다. 결과적으로 이 신경망의 마지막 레이어를 제외한 특징 추출 레이어들은 학습되지 않고, 마지막 레이어만이 학습될 것입니다.

참고로 위의 신경망의 구성 레이어를 출력하는 코드와 그 결과는 다음과 같은데, 구성 레이어의 마지막이 fc라는 것을 알 수 있습니다.

for name,module in net.named_children():
    print(name)

''' output:
conv1
bn1
relu
maxpool
layer1
layer2
layer3
layer4
avgpool
fc
'''

다음은 VGG 신경망에 대한 전이학습 전처리 코드입니다.

from torchvision import models

net = models.vgg16(pretrained=True)
 
features = net.features
for params in vgg.features.parameters():
    param.requires_grad = False

net.classifier[6].out_features = 2

객체 net을 생성한 후 바로 print(net)을 실행해 보면 다음과 같은 출력을 볼 수 있습니다.

VGG(
  (features): Sequential(
    (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU(inplace=True)
    (2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU(inplace=True)
    (4): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (5): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (6): ReLU(inplace=True)
    (7): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (8): ReLU(inplace=True)
    (9): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (10): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (11): ReLU(inplace=True)
    (12): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (13): ReLU(inplace=True)
    (14): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (15): ReLU(inplace=True)
    (16): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (17): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (18): ReLU(inplace=True)
    (19): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (20): ReLU(inplace=True)
    (21): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (22): ReLU(inplace=True)
    (23): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (24): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (25): ReLU(inplace=True)
    (26): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (27): ReLU(inplace=True)
    (28): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (29): ReLU(inplace=True)
    (30): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(7, 7))
  (classifier): Sequential(
    (0): Linear(in_features=25088, out_features=4096, bias=True)
    (1): ReLU(inplace=True)
    (2): Dropout(p=0.5, inplace=False)
    (3): Linear(in_features=4096, out_features=4096, bias=True)
    (4): ReLU(inplace=True)
    (5): Dropout(p=0.5, inplace=False)
    (6): Linear(in_features=4096, out_features=1000, bias=True)
  )
)

(classifier)의 마지막 구성요소[6]을 보면 out_features가 1000으로 되어 있는 것을 볼 수 있고, 이를 분류하고자 하는 개수인 2로 변경하는 전처리 코드였습니다.