Tensorflow Core API를 사용하여 사용자 정의 최적화기 비교

Tensorflow Core API를 사용하여 사용자 정의 최적화기 비교

콘텐츠 개요

  • 소개
  • 최적화 개요
  • 설정
  • 그라디언트 하강
  • 운동량으로 구배 하강
  • 적응 모멘트 추정 (Adam)
  • 결론

소개

Keras Optimizers 모듈은 많은 일반 교육 목적으로 권장되는 최적화 툴킷입니다. 여기에는 다양한 미리 빌드 최적화기와 사용자 정의를위한 서브 클래싱 기능이 포함되어 있습니다. Keras Optimizers는 또한 Core API로 구축 된 사용자 정의 레이어, 모델 및 교육 루프와 호환됩니다. 이러한 사전 제작 및 사용자 정의 가능한 최적화기는 대부분의 경우에 적합하지만 핵심 API는 최적화 프로세스를 완전히 제어 할 수 있습니다. 예를 들어, Sharness-Aware Minimization (SAM)과 같은 기술은 ML 최적화기의 기존 정의에 맞지 않는 모델과 Optimizer를 결합해야합니다. 이 안내서는 Core API를 사용하여 맞춤형 최적화기를 처음부터 구축하는 프로세스를 통해 최적화의 구조, 구현 및 동작을 완전히 제어 할 수있는 힘을 제공합니다.

최적화 개요

Optimizer는 모델의 훈련 가능한 매개 변수와 관련하여 손실 함수를 최소화하는 데 사용되는 알고리즘입니다. 가장 간단한 최적화 기술은 그라디언트 하강으로, 손실 기능의 가장 가파른 하강 방향을 단계적으로 수행하여 모델의 매개 변수를 반복적으로 업데이트합니다. 단계 크기는 기울기의 크기에 직접 비례하며, 그라디언트가 너무 크거나 너무 작을 때 문제가 될 수 있습니다. Adam, Adagrad 및 RMSProp과 같은 다른 그라디언트 기반 최적화기가 있으며 메모리 효율 및 빠른 수렴을 위해 다양한 그라디언트의 특성을 활용하는 RMSProp이 있습니다.

설정

import matplotlib
from matplotlib import pyplot as plt
# Preset Matplotlib figure sizes.
matplotlib.rcParams['figure.figsize'] = [9, 6]
import tensorflow as tf
print(tf.__version__)
# set random seed for reproducible results 
tf.random.set_seed(22)
2024-08-15 02:37:52.102563: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:485] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
2024-08-15 02:37:52.123704: E external/local_xla/xla/stream_executor/cuda/cuda_dnn.cc:8454] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
2024-08-15 02:37:52.130222: E external/local_xla/xla/stream_executor/cuda/cuda_blas.cc:1452] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
2.17.0

그라디언트 하강

기본 Optimizer 클래스에는 초기화 방법과 그라디언트 목록이 주어지면 변수 목록을 업데이트하는 기능이 있어야합니다. 학습 속도로 스케일링 된 그라디언트를 빼서 각 변수를 업데이트하는 기본 그라디언트 하강 옵티마이저를 구현하여 시작하십시오.

class GradientDescent(tf.Module):

  def __init__(self, learning_rate=1e-3):
    # Initialize parameters
    self.learning_rate = learning_rate
    self.title = f"Gradient descent optimizer: learning rate={self.learning_rate}"

  def apply_gradients(self, grads, vars):
    # Update variables
    for grad, var in zip(grads, vars):
      var.assign_sub(self.learning_rate*grad)

이 Optimizer를 테스트하려면 단일 변수 x에 대해 최소화 할 샘플 손실 함수를 만듭니다. 그라디언트 기능을 계산하고 매개 변수 최소화 값을 해결하십시오.

l = 2×4+3×3+2

DLDX = 8×3+9×2

DLDX는 x = 0에서 0이며, 이는 안장 지점이고 x = -98, 이는 전역 최소값입니다. 따라서 손실 함수는 x⋆ = -98에서 최적화됩니다.

x_vals = tf.linspace(-2, 2, 201)
x_vals = tf.cast(x_vals, tf.float32)

def loss(x):
  return 2*(x**4) + 3*(x**3) + 2

def grad(f, x):
  with tf.GradientTape() as tape:
    tape.watch(x)
    result = f(x)
  return tape.gradient(result, x)

plt.plot(x_vals, loss(x_vals), c='k', label = "Loss function")
plt.plot(x_vals, grad(loss, x_vals), c='tab:blue', label = "Gradient function")
plt.plot(0, loss(0),  marker="o", c='g', label = "Inflection point")
plt.plot(-9/8, loss(-9/8),  marker="o", c='r', label = "Global minimum")
plt.legend()
plt.ylim(0,5)
plt.xlabel("x")
plt.ylabel("loss")
plt.title("Sample loss function and gradient");
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1723689474.551312  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.555159  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.558916  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.562138  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.573919  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.577367  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.580786  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.583786  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.587331  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.590801  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.594185  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689474.597140  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.839513  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.841532  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.844269  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.846345  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.848395  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.850251  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.852146  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.854142  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.856078  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.857966  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.859861  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.861827  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.899762  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.901715  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.903659  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.905680  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.907718  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.909592  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.911505  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.913511  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.915419  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.917727  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.919991  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 
I0000 00:00:1723689475.922410  124187 cuda_executor.cc:1015] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at 

단일 가변 손실 함수로 Optimizer의 수렴을 테스트하는 기능을 작성하십시오. Timestep T에서 업데이트 된 매개 변수의 값이 Timestep T -1에서 보유한 값과 동일 할 때 수렴이 달성되었다고 가정합니다. 정해진 수의 반복 후 테스트를 종료하고 프로세스 중에 폭발하는 그라디언트를 추적합니다. 최적화 알고리즘에 진정으로 도전하려면 매개 변수를 제대로 초기화하십시오. 위의 예에서, x = 2는 가파른 구배를 포함하고 또한 변곡점으로 이어지기 때문에 좋은 선택이다.

def convergence_test(optimizer, loss_fn, grad_fn=grad, init_val=2., max_iters=2000):
  # Function for optimizer convergence test
  print(optimizer.title)
  print("-------------------------------")
  # Initializing variables and structures
  x_star = tf.Variable(init_val)
  param_path = []
  converged = False

  for iter in range(1, max_iters + 1):
    x_grad = grad_fn(loss_fn, x_star)

    # Case for exploding gradient
    if tf.math.is_nan(x_grad):
      print(f"Gradient exploded at iteration {iter}\n")
      return []

    # Updating the variable and storing its old-version
    x_old = x_star.numpy()
    optimizer.apply_gradients([x_grad], [x_star])
    param_path.append(x_star.numpy())

    # Checking for convergence
    if x_star == x_old:
      print(f"Converged in {iter} iterations\n")
      converged = True
      break

  # Print early termination message
  if not converged:
    print(f"Exceeded maximum of {max_iters} iterations. Test terminated.\n")
  return param_path

다음 학습 속도에 대한 그라디언트 하강 옵티미마 저의 수렴을 테스트하십시오. 1E-3, 1E-2, 1E-1

param_map_gd = {}
learning_rates = [1e-3, 1e-2, 1e-1]
for learning_rate in learning_rates:
  param_map_gd[learning_rate] = (convergence_test(
      GradientDescent(learning_rate=learning_rate), loss_fn=loss))
Gradient descent optimizer: learning rate=0.001
-------------------------------
Exceeded maximum of 2000 iterations. Test terminated.

Gradient descent optimizer: learning rate=0.01
-------------------------------
Exceeded maximum of 2000 iterations. Test terminated.

Gradient descent optimizer: learning rate=0.1
-------------------------------
Gradient exploded at iteration 6

손실 함수의 윤곽 플롯에서 매개 변수의 경로를 시각화하십시오.

def viz_paths(param_map, x_vals, loss_fn, title, max_iters=2000):
  # Creating a controur plot of the loss function
  t_vals = tf.range(1., max_iters + 100.)
  t_grid, x_grid = tf.meshgrid(t_vals, x_vals)
  loss_grid = tf.math.log(loss_fn(x_grid))
  plt.pcolormesh(t_vals, x_vals, loss_grid, vmin=0, shading='nearest')
  colors = ['r', 'w', 'c']
  # Plotting the parameter paths over the contour plot
  for i, learning_rate in enumerate(param_map):
    param_path = param_map[learning_rate]
    if len(param_path) > 0:
      x_star = param_path[-1]
      plt.plot(t_vals[:len(param_path)], param_path, c=colors[i])
      plt.plot(len(param_path), x_star, marker='o', c=colors[i], 
              label = f"x*: learning rate={learning_rate}")
  plt.xlabel("Iterations")
  plt.ylabel("Parameter value")
  plt.legend()
  plt.title(f"{title} parameter paths")
viz_paths(param_map_gd, x_vals, loss, "Gradient descent")
/tmpfs/src/tf_docs_env/lib/python3.9/site-packages/IPython/core/events.py:82: UserWarning: Creating legend with loc="best" can be slow with large amounts of data.
  func(*args, **kwargs)

학습 속도가 작을 때 그라디언트 하강은 변곡점에 갇힌 것 같습니다. 학습 속도를 높이면 더 큰 단계 크기로 인해 고원 지역 주변의 빠른 움직임을 장려 할 수 있습니다. 그러나 이는 손실 함수가 매우 가파일 때 조기 반복에서 폭발적인 그라디언트가 발생할 위험이 있습니다.

운동량으로 구배 하강

운동량을 가진 그라디언트 하강은 그라디언트를 사용하여 변수를 업데이트 할뿐만 아니라 이전 업데이트를 기반으로 변수 위치의 변화를 포함합니다. 모멘텀 매개 변수는 Timestep t에서의 업데이트에 미치는 업데이트의 영향 수준을 TimeStep t의 업데이트에 결정합니다. 축적 운동 추진력은 기본 기울기 하강보다 플래타우 지역을지나 변수를 더 빨리 움직이는 데 도움이됩니다. 모멘텀 업데이트 규칙은 다음과 같습니다.

ΔX[t]= lr⋅l ‘(x[t−1])+p⋅Δx[t−1]

엑스[t]= x[t−1]- – 체[t]

어디

  • X : 변수가 최적화되고 있습니다
  • ΔX : x의 변화
  • LR : 학습 속도
  • L ‘(x) : x에 대한 손실 함수의 기울기
  • P : 모멘텀 매개 변수
class Momentum(tf.Module):

  def __init__(self, learning_rate=1e-3, momentum=0.7):
    # Initialize parameters
    self.learning_rate = learning_rate
    self.momentum = momentum
    self.change = 0.
    self.title = f"Gradient descent optimizer: learning rate={self.learning_rate}"

  def apply_gradients(self, grads, vars):
    # Update variables 
    for grad, var in zip(grads, vars):
      curr_change = self.learning_rate*grad + self.momentum*self.change
      var.assign_sub(curr_change)
      self.change = curr_change

다음 학습 속도에 대한 모멘텀 최적화기의 수렴을 테스트하십시오. 1E-3, 1E-2, 1E-1

param_map_mtm = {}
learning_rates = [1e-3, 1e-2, 1e-1]
for learning_rate in learning_rates:
  param_map_mtm[learning_rate] = (convergence_test(
      Momentum(learning_rate=learning_rate),
      loss_fn=loss, grad_fn=grad))
Gradient descent optimizer: learning rate=0.001
-------------------------------
Exceeded maximum of 2000 iterations. Test terminated.

Gradient descent optimizer: learning rate=0.01
-------------------------------
Converged in 80 iterations

Gradient descent optimizer: learning rate=0.1
-------------------------------
Gradient exploded at iteration 6

손실 함수의 윤곽 플롯에서 매개 변수의 경로를 시각화하십시오.

viz_paths(param_map_mtm, x_vals, loss, "Momentum")

적응 모멘트 추정 (Adam)

적응 모멘트 추정 (ADAM) 알고리즘은 효율적이고 고도로 일반화 가능한 최적화 기술입니다. 모멘텀은 붕괴 매개 변수와 함께 첫 모멘트 (그라디언트의 합)를 사용하여 기울기 하강을 가속화하는 데 도움이됩니다. RMSP는 비슷합니다. 그러나 두 번째 순간 (그라디언트 제곱)을 활용합니다.

Adam 알고리즘은 첫 번째와 두 번째 순간을 결합하여보다 일반화 가능한 업데이트 규칙을 제공합니다. 변수 X의 부호는 XX2를 계산하여 결정할 수 있습니다. Adam Optimizer는이 사실을 사용하여 효과적으로 부드러운 부호 인 업데이트 단계를 계산합니다. XX2를 계산하는 대신 Optimizer는 각 변수 업데이트에 대해 X (첫 번째 모멘트) 및 X2 (두 번째 모멘트)의 스무딩 버전을 계산합니다.

아담 알고리즘

β1 ← 0.9 문학적 값

β2 ← 0.999 ° 문학 값

LR ← 1E-3 ▹ 구성 가능한 학습 속도

ϵ ← 1e-7▹ 예비 프레젠트를 0 오류로 나눕니다

VDV ← 0N × 1 → ▹ 각 변수에 대한 모멘텀 업데이트

SDV ← 0N × 1 → ▹ ▹STORES 각 변수에 대한 RMSP 업데이트

T ← 1

반복 t :

그라디언트 변수 쌍의 (dldv, v) for (dldv, v) :

vdv_i = β1vdv_i+(1 -β1) dldv▹momentum 업데이트

SDV_I = β2VDV_I+(1 -β2) (DLDV) 2▹RMSP 업데이트

vdvbc = vdv_i (1 -β1) t▹momentum 바이어스 보정

SDVBC = SDV_I (1 -β2) T▹RMSP 바이어스 보정

v = v -lrvdvbcsdvbc+ϵ▹ 파라미터 업데이트

t = t+1

알고리즘의 끝

VDV 및 SDV가 0으로 초기화되고 β1 및 β2가 1에 가깝다는 점을 감안할 때, 운동량 및 RMSP 업데이트는 자연스럽게 0으로 바이어스된다; 따라서 변수는 바이어스 보정의 이점을 얻을 수 있습니다. 바이어스 보정은 또한 전 세계 최소값에 접근 할 때 가중치의 오스 화질을 제어하는 데 도움이됩니다.

class Adam(tf.Module):

    def __init__(self, learning_rate=1e-3, beta_1=0.9, beta_2=0.999, ep=1e-7):
      # Initialize the Adam parameters
      self.beta_1 = beta_1
      self.beta_2 = beta_2
      self.learning_rate = learning_rate
      self.ep = ep
      self.t = 1.
      self.v_dvar, self.s_dvar = [], []
      self.title = f"Adam: learning rate={self.learning_rate}"
      self.built = False

    def apply_gradients(self, grads, vars):
      # Set up moment and RMSprop slots for each variable on the first call
      if not self.built:
        for var in vars:
          v = tf.Variable(tf.zeros(shape=var.shape))
          s = tf.Variable(tf.zeros(shape=var.shape))
          self.v_dvar.append(v)
          self.s_dvar.append(s)
        self.built = True
      # Perform Adam updates
      for i, (d_var, var) in enumerate(zip(grads, vars)):
        # Moment calculation
        self.v_dvar[i] = self.beta_1*self.v_dvar[i] + (1-self.beta_1)*d_var
        # RMSprop calculation
        self.s_dvar[i] = self.beta_2*self.s_dvar[i] + (1-self.beta_2)*tf.square(d_var)
        # Bias correction
        v_dvar_bc = self.v_dvar[i]/(1-(self.beta_1**self.t))
        s_dvar_bc = self.s_dvar[i]/(1-(self.beta_2**self.t))
        # Update model variables
        var.assign_sub(self.learning_rate*(v_dvar_bc/(tf.sqrt(s_dvar_bc) + self.ep)))
      # Increment the iteration counter
      self.t += 1.

그라디언트 하강 예제와 함께 사용 된 것과 동일한 학습 속도로 Adam Optimizer의 성능을 테스트하십시오.

param_map_adam = {}
learning_rates = [1e-3, 1e-2, 1e-1]
for learning_rate in learning_rates:
  param_map_adam[learning_rate] = (convergence_test(
      Adam(learning_rate=learning_rate), loss_fn=loss))
Adam: learning rate=0.001
-------------------------------
Exceeded maximum of 2000 iterations. Test terminated.

Adam: learning rate=0.01
-------------------------------
Exceeded maximum of 2000 iterations. Test terminated.

Adam: learning rate=0.1
-------------------------------
Converged in 1156 iterations

손실 함수의 윤곽 플롯에서 매개 변수의 경로를 시각화하십시오.

viz_paths(param_map_adam, x_vals, loss, "Adam")

이 특별한 예에서, Adam Optimizer는 작은 학습 속도를 사용할 때 전통적인 기울기 하강에 비해 수렴이 느려집니다. 그러나 알고리즘은 Plataeu 지역을 성공적으로 이동하여 학습 속도가 커질 때 전 세계 최소값으로 수렴합니다. 대규모 그라디언트가 발생할 때 Adam의 학습 속도의 역동적 인 스케일링으로 인해 폭발적인 그라디언트가 더 이상 문제가되지 않습니다.

결론

이 노트북은 최적화를 텐서 플로우 코어 API와 쓰기 및 비교하는 기본 사항을 소개했습니다. Adam과 같은 사전 빌드 최적화기가 일반화 가능하지만 모든 모델이나 데이터 세트에 항상 최상의 선택이 아닐 수도 있습니다. 최적화 프로세스를 세밀하게 제어하면 ML 교육 워크 플로를 간소화하고 전반적인 성능을 향상시키는 데 도움이 될 수 있습니다. 사용자 정의 최적화기의 더 많은 예는 다음 문서를 참조하십시오.

원래 게시 텐서 플로 웹 사이트,이 기사는 새 헤드 라인 아래에 표시되며 CC에 따라 4.0으로 라이센스가 부여됩니다. Apache 2.0 라이센스에 따라 공유 된 코드 샘플.

출처 참조

Post Comment

당신은 놓쳤을 수도 있습니다