Category Archives: 배우기

21세기 자본/ 21세기 글로벌 부의 불평등

자본수익률의 불평등

  • 많은 경제 모형이 재산의 많고 적음에 상관없이 자본수익률을 동일한 것으로 가정하지만, 이것은 결코 확실하지 않다. 부유한 사람의 평균 수익률이 그렇지 않은 사람의 평균 수익률보다 높은 것은 가능한 일이다.
    • 부유한 사람은 자산관리자와 금융 자문을 고용할 수 있고, 예비 자금을 갖고 있기 때문에 위험을 감수하고 인내하기가 더 쉽기 때문이다.
  • 이런 메커니즘이 자본의 분배에 자동적으로 근본적인 격차를 만들어낸다는 것을 알 수 있다.
    • 글로벌 부의 계층 구조에서 최상위 십분위 혹은 백분위의 재산이 더 낮은 십분위의 재산보다 구조적으로 더 빠르게 성장한다면, 부의 불평등은 무한히 증가하게 될 것이다.
  • 엄밀한 논리를 적용하면 유일하게 ‘자연적’ 대항력은 역시 성장이다.
    • 세계의 경제성장률이 높으면, 대규모 자산의 상대적 성장률은 그리 높지 않게 유지될 것이다.
  • (이하 설명 생략)

세계적인 부자들의 순위 변화

  • (포브스지의 자산 순위 설명 생략)
  • 포브스에 따르면 지구촌의 억만장자는 1987년 140명을 넘겼지만, 2013년에는 1400명 이상으로 10배 증가했다.

  • 1980년대부터 세계의 부는 평균적으로 소득보다 약간 빠르게 증가했고, 거액의 자산들은 평균치 자산보다 훨씬 더 급속하게 증가했다.

억만장자 순위부터 ‘글로벌 자산보고서’까지

  • (국제 금융기관이 발간하는 ‘글로벌 자산보고서’ 설명 생략)
  • 지구상에서 가장 부유한 사람들에 속하는 0.1%는 평균 1000만 유로의 자산을 소유하는데, 이는 1인당 6만 유로인 세계 평균 자산의 200배이며, 전 세계 부의 20%를 차지한다.
    • 가장 부유한 1%는 평균 300만 유로를 소유하며 이는 세계 평균 자산의 약 50배이고, 전 세계 부의 50%를 차지한다.
  • (이하 자료의 불완전성 설명 생략)

부자 순위에 오른 상속자와 기업가

  • 포브스 순위에서 가장 교훈적인 것 중 하나는 특정 한도를 넘으면 거액의 재산은 그것이 무엇이든 모두 극단적으로 높은 성장률을 보여준다는 것이다.
  • (이하 빌게이츠, 스티브잡스나 포브스에 대한 내용 생략)

부의 도덕적 위계

  • 누구도 사회에서 기업가, 발명, 혁신이 중요하다는 것을 부인하지 않는다. 벨 에포크 시대에도 오늘날과 마찬가지로 자동차, 영화, 전기 같은 혁신이 많이 등장했다.
    • 문제는 단순하다. 부의 불평등이 기업가적인 노력 때문이라는 주장은 부의 모든 불평등을 그것이 얼마나 극단적인지와 상관없이 정당화할 수는 없다는 사실이다.
    • r>g는 초기의 부에 따라 달라지는 자본수익률의 불평등과 결합되어 과도하고 지속적인 자본의 집중을 낳을 수 있다.
    • 처음에 부의 불평등이 어떻게 정당화되었건 재산은 모든 이성적인 한계를 넘어 스스로 성장하고 영속할 수 있다.
  • 기업가는 세대를 거치면서 자신의 일생 동안 자본소득자로 변모해가는 경향이 있다. 특히 인간의 기대수명이 늘어나면서 이런 현상은 더 심해진다.
    • 40세애 좋은 아디이어를 가졌던 사람이라도 90세까지 그럴 수는 없을 것이고, 자녀가 좋은 아이디어를 가질 수도 없다.
    • 그러나 재산은 항상 그대로 남아 있으며, 빌 게이츠와 릴리앙 베탕쿠르처럼 20년 만에 재산이 10배가 넘게 늘어나는 경우도 있다.
  • 이것이 세계적으로 대규모 재산에 대해 매년 부과하는 누진세가 필요한 중요하고 타당한 이유다.
    • 그런 세금은 이와 같이 자산이 잠재적으로 폭발적으로 성장하는 과정을 기업의 역동성과 국제적인 개방경제를 보호하면서 민주적으로 통제하는 유일한 방법이다.
  • 조세적 접근은 부의 도덕적 위계에 대한 헛된 논쟁에서 벗어나는 방법이다. 모든 재산은 부분적으로는 정당하지만, 잠재적으로는 과도하다.
    • 자본에 대한 누진제의 이점은 다양한 상황에 유연하고 일관되며 예측 가능한 방식으로 대처하는 방법인 동시에 대규모 재산을 민주적으로 규제할 수 있는 방법이라는 점이다.
  • (부에 대한 도덕적인 논쟁 내용 생략)
  • 대체로 중요한 사실은 자본의 수익에는 흔히 진정한 기업가적 활동, 순수한 행운, 노골적인 도둑질 등의 요소가 복잡하게 결합되어 있다는 것이다.
    • 재산 축적에서의 임의성은 상속재산에서의 임의성보다 훨씬 더 광범위한 현상이다.
    • 자본의 수익은 원래 변동성이 크고 예측 불가능하며 수십 년간 벌어들인 소득에 달하는 자본이득을 쉽게 만들어낼 수 있다.
    • 부의 최상위 계층에서 이런 현상은 더 극단적이다.

대학 기금의 순수한 수익률

  • (대학 기금과 수익률 내용 생략)

자본과 규모의 경제

  • 하버드 대는 현재 자신의 기금을 운용하는데 매년 거의 1억 달러를 사용하는데 이 금액은 일류 자산운용자들에게 지불된다.
    • 그러나 하버드대 기금이 약 300억 달러임을 생각해 보면 1억 달러의 관리 비용은 0.3%에 불과하고, 이만큼 비용을 지불해서 수익률을 5%에서 10%로 높일 수 있으면 매우 바람직한 거래다.
    • 반면 기금이 10억 달러에 그치는 대학이 관리 비용으로 전체 자산의 10%에 달하는 비용을 지불할 수는 없다.
    • 이렇게 높은 수익률은 미국 최고 일류 대학의 번영을 잘 설명해 준다. 그들의 부흥은 졸업생들이 대학에 준 기부금 때문이 아니다.
    • 졸업생의 연간 기부금은 기금 수익의 10-20%로 기금의 수익에 비해 훨씬 적다.
  • (이하 이런 메커니즘에 의해 커지는 불평등 관련 내용 생략)

인플레이션이 자본수익률의 불평등에 미치는 영향은 무엇인가?

  • 0%가 아닌 2% 혹은 5%에 이르는 인플레이션이 자본수익률에 미치는 영향은 무엇인가?
  • 일부 사람들은 인플레이션이 평균 자본수익률을 감소시켰다는 그릇된 생각을 갖고 있는데, 평균 자산 가격은 소비자물가와 동일한 속도로 상승하는 경향이 있기 때문에 이 생각은 옳지 않다.
    • 만약 소비자물가가 연 2% 상승한다면 평균적으로 자산 가격 역시 2% 상승할 것이다. 따라서 어떤 자본이득이나 자본손실도 발생하지 않을 것이며, 자본수익률은 여전히 5%일 것이다.
    • 반면 인플레이션은 개별 시민 간 자본수익률의 분표를 변화시킬 가능성이 있다.
  • 인플레이션이 계속되는 상황에서 은행에 돈을 맡겨 놓고 마냥 앉아 있다면 부에 대해 세금을 물지 않아도 눈 앞에서 그 돈의 가치가 조금씩 줄어들어 마침내 아무것도 남아 있지 않게 될 것이다.
    • 이렇게 보면 인플레이션은 게으른 부자에 대한 세금, 혹은 더 정확히 말해 투자되지 않은 재산에 부과되는 세금이다.
    • 부동산이나 주식 같은 실질자산에 투자하는 것만으로도 인플레이션이라는 세금은 충분히, 완전하게 회피할 수 있다.
    • (사실 자산은 Risk를 동반하기 때문에, 금융위기 같은 일이 벌어지면 손실이 발생할 수 있다. 이런 경우에는 오히려 자산이 아니라 화폐를 들고 있는게 더 나은 상태가 됨)
  • (이하 인플레이션과 자산 수익률에 대한 설명 생략)
  • 요약하자면 인플레이션의 주요한 영향은 평균 자본수익률을 감소시키는 것이 아니라 자본수익을 재분배하는 것이다.
    • 인플레이션의 효과가 복잡하고 다차원적이기는 해도 수많은 증거를 볼때, 인플레이션이 유발하는 재분배 효과는 주로 가난한 사람들에게는 손해가, 부유한 사람들에게는 이익이 된다.
    • 따라서 인플레이션은 일반적으로 바람직한 방향과 반대되는 결과를 가져온다.
  • 인플레이션은 지대를 없애지 못한다. 아마도 그와 반대로 자본 분배의 불평등을 더 심화시킬 뿐이다.

국부펀드의 수익: 자본과 정치

  • (석유를 가진 나라들의 국부 펀드 내용 생략)

국부펀드는 세계를 소유할 것인가?

  • 2013년 국부펀드의 총투자가치는 5조 3000억 달러를 조금 넘었는데 –이중 석유수출국은 3조 2000억이고, 비산유국 펀드는 2조 1000억을 차지한다–, 이는 포브스에서 억만장자로 지목한 무자들의 재산 총계와 정확히 동일한 금액이라는 사실에 주목할 필요가 있다.
    • 달리 말하면 오늘날 억만장자는 세계 총민간자산 가운데 1.5%를 소유하며, 국부펀드가 또 다른 1.5%를 소유한다.
  • (이하 국부펀드와 억맍장자의 투자처 설명 생략)

중국은 세계를 소유할 것인가?

  • 비석유수출국의 국부펀드는 다른 종류의 문제를 제기한다. 어째서 이렇다 할 특정한 천연자원이 없는 나라가 다른 나라를 소유하려 할까?
    • 한 가지 가능성은 물론 유럽 식민주의 시대와 같은 신식민주의적 야심 및 권력에 대한 순수 의지다.
    • 그러나 차이점은 그 당시 유럽 국가들은 그들의 지배를 확실하게 해주는 기술적 우위를 향휴하고 있었다는 사실이다.
    • 중국과 다른 신흥 비산유국들은 분명 빠르게 성장하고 있지만, 일단 그들의 생산성 및 생활수준 면에서 선두 주자를 따라잡는다면 이런 습속한 성장은 끝난다는 것을 시사하는 증거가 많다.
  • 지식과 생산 기술의 확산은 근본적으로 균등화 과정이다. 다시 말해 덜 발전된 국가가 더 발전된 국가를 따라잡게 되면 그들은 선진국보다 더 급속한 성장을 멈추게 된다.
  • (이하 설명 생략)

국제적 격차 확대와 과두적 격차 확대

  • 어쨌든 중국(또는 석유 수출국들의 국부펀드)이 부유한 국가들을 점진적으로 사들임으로써 발생하는 이 국제적 격차 확대의 위협은 한 국가 내의 과두적 형태의 격차 확대(oligarchic divergence) 보다는 발생 가능성이나 위험성이 적어 보인다.
    • 과두적 형태의 격차 확대는 부유한 국가들이 자국의 억만장자에 의해 소유되는 과정, 혹은 더 일반적으로 말하면 중국과 석유 수출국을 포함한 모든 국가가 점점 더 지구촌 부호들의 소유가 되는 과정을 의미한다.
    • 이러한 과정은 이미 상당히 진행되고 있다.
  • 전 세계에서 중국의 소유가 확대되는 현상과 관련하여 현재 확산되고 있는 두려움은 순전히 환상이라는 점을 강조하는 것이 중요하다.
    • 부유한 국가들은 그들이 생각하는 것보다 사실 훨씬 더 부유하다.
    • 오늘날 유럽의 가계가 소유한 부동산과 금융자산의 총가치는 부채를 빼고 대략 70조 유로에 달하는데, 이에 반해 중국의 다양한 국부펀드에 중국런민은행의 외환보유액을 더한 총 자산은 3조 유로로 유럽 가계 자산의 1/20 미만이다.
    • 부유한 국가들은 가난한 국가들에게 넘어갈 위험에 처해있지 않다. 그와 같은 일이 벌어지기 위해서는 가난한 국가들이 한참 더 부유해져야 하는데, 그러려면 수십 년 이상이 걸릴 것이다.
  • (이하 설명 생략)

부유한 국가들은 정말로 가난한가?

  • 우리는 지금 적어도 부유한 국가들이 세계의 나머지 국가들에 비해 훨씬 더 큰 플러스의 순자산 포지션을 향유하던 식민지 시대에 비하면 국제적 포지션이 비교적 균형을 이룬 시대에 살고 있다.
  • 물론 이런 공식적인 자료에서 약간 마이너스 값을 기록한 순자산 포지션은 원칙적으로 세계 나머지 국가들에게 상응하는 플러스 값으로 균형이 맞춰져야 한다.
    • 그러나 실제로는 그렇지 않은데, 즉 세계 여러 국가의 금융 통계자료를 종합하면 가난한 나라들 역시 순자산 포지션이 마이너스 수준이고, 따라서 세계 전체의 순자산 포지션이 상당한 마이너스 상태다.
    • 다시 말해 지구는 화성을 소유한 것이 틀림 없다.
    • 이것은 상당히 오래된 ‘통계적 모순’이지만, 다양한 국제기구에 따르면 이런 상황은 최근에 더 악화되었다. (전 세계 국제수지의 총합은 보통 마이너스로, 국가들에게 유입되는 돈보다 유출되는 돈이 더 많다)

21세기 자본/ 장기적 관점에서 본 능력과 상속

  • 자본의 중요성은 18세기와 크게 다르지 않고, 그 형태만 달라졌다.
    • 자본은 이전에 주로 토지였으나, 현재는 산업자본, 금융자본, 부동산이다.
    • 우리는 100년 전보다는 덜하지만 부의 집중이 여전하다는 것을 살펴 보았다.
  • 전세계적으로 자본의 규모는 똑같이 유지되었다. 그러나 자본이 과거에는 주로 상속되었으나 이제는 스스로 벌어들인 소득 가운데 일부가 저축되어 평생 동안 누적된다는 의미에서 그 심층적인 구조는 극적으로 변화한 듯 하다.
    • 그러한 변화를 설명해 주는 것 중 하나는 기대수명의 연장인데, 이는 사람들이 퇴직 이후를 대비하여 구조적으로 자본축적을 늘리게 했을 수 있다.
    • 그러나 자본의 속성상 일어났다고 추정되는 거대한 전환은 실제로는 생각만큼 극적이지는 않았다.
  • 나는 다음과 같은 결론을 제시할 것이다. 자본수익률이 현저하게 그리고 지속적으로 경제성장률보다 높은 경우, 거의 필연적으로 상속이 저축을 압도한다.

상속액의 장기적인 추이

  • 모든 사회에는 부를 축적하는 두 가지 주요한 방법이 있는데, 바로 노동과 상속이다. 부의 계층 구조에서 상위 1%와 10%에게 각각의 방법이 얼마나 흔하게 나타날까 하는 것이 핵심적인 질문이다.
  • 장기간에 걸친 연간 상속액의 변화를 살펴보는 것에서 시작하면 도움이 될 것이다. 그것은 국민소득에 대비한 비율로 표시되는 1년 동안의 유산의 총가치를 의미한다.
    • 아래 도표는 해당 연도에 벌어들인 총소득과 비교한 과거 자산의 연간 상속액을 보여준다.

  • 도표 11.1은 1820-2010년까지 프랑스의 연간 상속액의 변화 추이인데, 두 가지 사실이 뚜렷히 드러난다.
    • 첫째, 19세기에는 상속액이 매년 소득의 20-25%에 이르렀고, 19세기 말이 되면서 약간 상승 추세를 보였다.
    • (이하 도표 설명 생략)

조세적 추정과 경제적 추정

  • (자료 수집에 대한 설명 생략)

세가지 힘: 상속의 종말이라는 환상

  • 일반적으로 b_{y} 로 표시되는 국민소득 대비 연간 상속액과 증여액의 비율은 세 가지 힘을 곱한 값과 같다.

b_{y} = \mu \times m \times \beta

  • 여기서 \beta 는 자본/소득 비율, m 은 사망률, \mu 는 사망자의 평균 자산과 살아 있는 개인들의 평균 자산의 비율이다.
  • 세 가지 힘을 살펴보자
    • 첫째는 자본/소득 비율 \beta 이다. 이 힘은 자명한데, 만약 어떤 사회에서 상속되는 부의 금액이 크려면 상속될 수 있는 민간자산의 총량도 당연히 커야 할 것이다.
    • 두 번째 힘 사망률 m 도 이와 같은 자명한 메커니즘을 보여준다.다른 모든 조건이 동일하다면 사망률이 높을수록 상속액이 커진다.
    • 세 번째 힘, 사망자의 평균 자산과 살아 있는 개인들의 평균 자산의 비율 \mu 도 마찬가지다.
  • 사망자의 평균 자산이 인구 전체의 평균 자산과 같다고 가정해 보자
    • 그러면 \mu = 1 이고 상속액 b_{y} 는 단순하게 사망률 m 과 자본/소득 비율 \beta 를 곱한 값이 될 것이다.
    • 예컨대 자본/소득 비율이 600%이고 성인 인구의 사망률이 2%라면 연간 상속액은 자동적으로 국민소득의 12%가 될 것이다.
    • 분명 \mu 는 연령별 분포표에 달려 있다. 연령과 함께 부가 증가할 수록 \mu 는 더욱 높아질 것이고 따라서 상속액도 커질 것이다.
  • (이하 설명 생략)

사망률의 장기적 변화 추이

  • 상속의 자연적인 종말을 설명할 수 있는 두 번째 힘은 기대수명의 상승인데, 이는 사망률 m 을 하락시키고 상속받을 때까지 기다리는 시간을 길게 만든다.

  • 그러나 사망률의 변화로 경제의 중요한 요소인 상속자산이 필연적으로 사라지리라는 생각은 심각한 잘못일 것이다.
  • (이하 설명 생략)

부도 인구와 함께 늙는다: \mu \times m 효과

  • 나는 장기적인 관점을 택해 인구 집단의 규모는 안정적이라고 가정할 것이다. 그러면 기대수명의 상승은 상속자산의 중요성에 실제로 어떤 영향을 미칠까?
    • 분명 기대수명의 증가는 사망률의 구조적인 감소를 가져온다.
  • 그러나 사람들이 더 늦게 사망하고 더 늦게 상속받는다는 사실이 상속 자산의 중요성이 약화되었다는 것을 의미할까? 꼭 그렇지는 않다.
    • 부분적으로는 살아 있는 개인들 사이의 증여가 더 중요해져서 이 고령화 효과를 상쇄하기 때문이고, 부분적으로는 고령화 사회에서 부도 나이가 들어서 나중에 상속받는 이들이 더 많은 액수를 상속 받기 때문이다.

  • 구체적으로 말하면, 사망률의 지속적인 하락에도 불구하고 정의상 상속에 의한 연간 이전율을 나타내는 \mu m 의 곱은 지난 수십 년 동안 확실히 상승하기 시작했다.
  • (중간 설명 생략)

  • 요약하자면 고령화 사회에서는 상속이 더 나중에 이루어지지만 부도 함께 늙어가기 때문에 이 효과가 고령화 효과를 상쇄한다.

사망자의 자산과 살아 있는 사람의 자산

  • 사망자의 평균 자산과 살아 있는 사람의 평균 자산의 비율 \mu 의 변화 추이를 더욱 자세히 살펴보면 흥미롭다.
  • (이하 설명 생략)

50대와 80대: 벨 에포크 시대의 연령과 재산

  • 부의 축적 동학과 \mu 를 계산하기 위해 사용된 상세한 데이터를 더 잘 이해하기 위해서는 평균적인 부의 분포를 연령 함수로 검토하는 것이 유용할 것이다.
  • (이하 설명 생략)

  • 그럼에도 부의 집중 현상을 대부분 설명해 주는 지배적인 동학은 r>g라는 부등식의 필연적인 결과이다.
    • 개인이 50세나 60세에 보유한 자산이 상속된 것이든 노동으로 얻은 것이든, 어떤 한도를 넘어서면 자본은 스스로 재생산하고 기하급수적으로 증가하는 경향이 있다.
    • r>g의 논리는 기업가가 언제나 자본소득자로 변하는 경향이 있음을 의미한다.
    • 그런 일이 인생의 뒤늦은 시기에 일어난다 해도, 기대수명이 늘어나면서 그런 현상은 더욱 중요해진다.

전쟁으로 젊음을 되찾은 부

  • 이런 자립적인 메커니즘은 1914-1945년 자본과 그 소유자들이 겪었던 반복된 충격들로 인해 붕괴되고 말았다.
    • 두 차례 세계대전이 가져온 결과는 상당한 수준에서 일어난 부의 회춘이었다.
    • 도표 11.5가 이를 뚜렷이 보여준다.
  • (중간 설명 생략)
  • 이것이 2차대전 이후 수십 년간 상속액이 예외적으로 감소했던 현실에 대한 주된 설명이다.
    • 1950-1960년 재산을 상속받았어야 할 개인들은 그리 많이 상속받지 못했다. 그들의 부모가 이전 수십 년 동안의 충격으로부터 회복할 만한 충분한 시간이 없었고 그들의 이름으로 지닌 재산이 많지 않은 상태에서 죽었기 때문이다.
  • (이하 설명 생략)

21세기에는 상속액이 어떻게 변화될 것인가

  • 최근 수십 년간 나타난 상속액의 급격한 증가를 고려하면 이러한 증가가 계속될 것인지 묻는게 당연하다.
    • 아래 그림은 21세기 가능한 두 가지 변화를 보여준다. 중심 시나리오는 2010-2100년의 연간 성장률을 1.7%, 자본수익률을 3%로 가정한다.
    • 다른 대안적인 시나리오는 2010-2100년 자본수익률이 5%로 상승하는 반면 연간 성장률은 1% 하락한다고 추정한다.

  • 첫 번째 시나리오를 따르면 상속액은 2060-2070까지 많이 증가한 뒤 국민소득의 24-25% 수준에서 안정을 찾는다.
    • 첫 번째의 경우 상속 자산은 부분적으로만 귀환하지만 두 번째 경우는 모조리 귀환할 것이다.
  • (이하 설명 생략)
  • 여기서 본질적인 요점은 주어진 저축 행위의 구조에서 자본수익률이 상승하고 성장률이 하락하면 누적적 과정이 더욱 빨라지고 불평등해진다는 것이다.
  • (이하 설명 생략)

연간 상속액에서 상속자산 총액까지

  • 연간 상속액이 국민소득의 20-25%이던 19세기와 20세기 초에는 상속자산이 민간자산의 거의 전부를 차지했다.
    • 이때 상속자산은 민간자산의 80-90%를 차지했을 뿐만 아니라 증가 추세였다.
    • 그러나 모든 사회, 모든 계층에서 10-20%에 이르는 상당수의 부유한 개인들이 무일푼으로 시작해 일생 동안 재산을 모았다는 점에 주의하자.
    • 그럼에도 상속받은 자산으로 부유해진 경우가 대다수를 차지했다.
    • 국민소득의 20%에 해당되는 연간 상속액을 30년간 더하면 국민소득의 약 6배에 상당하는 거액의 유산과 증여 재산이 축적되는데, 이는 민간자산의 거의 전부를 차지한다.
  • 20세기에 상속액이 격감하면서 이 균형 상태가 극적으로 변화했다. 1970년대 상속액이 최저 수준으로 떨어졌다.
    • 수십 년간 상속과 새로운 자산축적이 소규모로 이뤄진 뒤, 총민간자본에서 상속받은 자본이 차지하는 몫은 40%를 약간 넘는 수준으로 감소했다.
    • 신생국을 제외하면 역사상 처음으로 개인이 살명서 축적한 재산이 전체 부의 거의 60%에 육박하며 대부분을 차지한 것이다.
  • (이하 설명 생략)

  • 저축률은 일반적으로 국민소득의 약 10%다.
    • 19세기 처럼 상속액이 20-25%라면 해마다 유산과 증여로 받는 액수가 신규 저축의 두 배가 넘는다. 신규 저축의 일부가 상속받은 자본에서 얻은 소득이라는 점까지 더하면 상속자산이 저축한 자산을 크게 능가한다.
    • 역으로 1950년대 상속액이 국민소득의 5%, 즉 신규 저축의 절반으로 떨어지면 당연히 저축된 자본이 상속받은 자본보다 우세할 것이다.
    • 핵심은 1980년대 연간 상속액이 저축액을 다시 넘어섰고 2000-2010년에는 저축액을 훨씬 웃돌았다는 사실이다.
    • 오늘날에는 국민소득의 거의 15%를 차지한다.

보트랭의 설교로 돌아가기

  • 1790년대 프랑스에서 태어난 모든 사람을 살펴보면 평생 이용할 수 있었던 총자원에서 상속이 약 24%를 차지했다. 따라서 노동소득의 비율은 약 76%였다.
    • 이는 1차대전 전까지 모든 집단에서 대략적으로 같은 양상이 나타났다.
  • 1870년대 그 이후 태어난 집단에서는 총자원에서 상속이 차지하는 비율이 서서히 감소하기 시작한다.
  • (이하 내용은 앞선 내용을 계속 반복하고 있어서 생략)

라스티냐크의 딜레마

  • 상속자산의 주요 특징 중 하나는 매우 불평등하게 분배되어 있다는 것이다.
    • 아래 도표는 18세기 말과 19세기에 태어난 집단들이 실제로 보트랭이 설명한 끔찍한 딜레마에 직면했다는 것을 보여준다.
    • 어떻게든 상속받은 자산을 손에 넣은 사람들은 공부와 일을 해서 생활해야 하는 사람들보다 훨씬 더 잘살았다.

  • 19세기 가장 부유한 1%의 상속인들이 평생 이용할 수 있었던 자원은 하류층이 이용할 수 있었던 자원의 25-30배였다.
    • 다시 말해 부모나 배우자를 통해 그 정도의 상속을 받을 수 있는 사람은 평생 25-30명의 하인을 부리며 임금을 지불할 수 있었다.
    • 동시에 상위 1의 노동소득자가 이용할 수 있는 자원은 하류층의 10배였는데, 이 수치는 무시할 정도는 아니지만 상위 1%의 상속인들에 비하면 분명 낮았다.
  • (이하 설명 생략)

자본소득자와 경영자에 대한 기초적 계산

  • 요약하자면 사회계층 구조의 최상위층이 상속자본에서 얻는 소득이 노동소득보다 우세한 사회에서는 두 가지 조건이 충족되어야 한다.
  • 먼저 자본총량이 많아야 하고, 그중 상속받은 자본의 비율이 높아야 한다.
    • 일반적으로 자본/소득 비율이 대략 6, 7이 되어야 하고 자본총량 대부분이 상속받은 자본으로 구성되어야 한다.
    • 그런 사회에서 상속자산은 각 집단이 이용할 수 있는 평균 자원의 약 1/4을 차지할 수 있다.
  • 두 번째 조건은 상속자산이 극도로 집중되어야 한다는 것이다.
    • 상속자산이 노동소득과 같은 방식으로 분배되면 보트랭이 말한 세계는 결코 존재할 수 없을 것이다.
    • 노동소득이 상속자산에서 얻는 소득보다 항상 훨씬 더 많을 것이고 (적어도 3배) 노동소득 상위 1%의 소득이 상속자산 소득 상위 1%의 소득을 체계적이고 자동적으로 넘어설 것이기 때문이다.
  • 집중효과가 물량효과를 누르려면 상속계츠으이 상위 1%가 상속받은 부에서 대단히 큰 몫을 차지해야 한다.
    • 상속계층의 상위 1%가 총자산의 50-60%를 소유해 노동소득 상위 1%가 노동소득에서 차지하는 몫 보다 거의 10배가 높았던 18-19세기가 이런 경우였다.
  • 자본소득자와 경영자에 대해 이런 기초적인 계산을 해보면 오늘날 상속 자산 상위 1%와 노동소득 상위 1%가 거의 균형이 맞는 이유를 이해하는데도 도움이 된다.
  • (이하 설명 생략)

고전적 세습사회: 발자크와 오스틴의 세계

  • (소설 속 등장한 당시 사회 불평등 내용 생략)

부의 극심한 불평등: 가난한 사회에서 문명의 한 조건인가?

  • (소설 속 등장한 당시 사회 불평등 내용 생략)

부유한 사회에서의 극단적인 능력주의

  • 임금불평등은 상속에 따른 불평등보다 더 많이 정당화된다는 생각들이 있다.
  • (중간 설명 생략)
  • 현대사회의 불평등을 능력주의로 정당화 하는 모습은 최상위층 뿐만 아니라 하층과 중산층 사이의 더 낮은 계층에서도 뚜렷이 나타난다는 점 역시 강조해야 한다.
  • (이하 설명 생략)

소자본소득자들의 사회

  • 1970년 이후 태어난 집단에게 상속은 평생의 총자원에서 1/4을 차지한다. 총액의 관점에서 보면 상속은 19세기 태어난 인구집단에서만큼의 중요성을 회복했다.
    • 그러나 총액이 과거 수준을 회복했다고 해서 상속이 동일한 사횢거 역할을 수행한다는 의미는 아니다.
    • 부의 현저한 분산과 세습 중산층의 등장은 오늘날 19세기에 비해 아주 많은 재산을 소유한 사람이 훨씬 더 드물다는 것을 암시한다.
  • (이하 소설 속 내용 설명 생략)
  • 불평등에 대한 사회적 표현의 이러한 커다란 변화는 어느 정도는 당연하지만 많은 오해에 근거하고 있다.
    • 첫째, 분명 오늘날에는 18세기보다 교육이 더 중요한 역할을 한다. 그렇다고 사회가 더욱 능력 본위로 바뀌었다고 말할 수는 없다.
    • 사실 교육 불평등은 그저 상향 이동했을 뿐이며, 교육으로 세대 간의 이동성이 높아졌다는 증거도 없다.
  • 상속자산이 사라져 더 공정한 사회로 나아왔다는 믿음이 널리 퍼지고 부분적으로 정당화 되었지만 여기에는 중요한 오해가 있다.
    • 첫째, 상속은 거의 사라지지 않았다. 상속자본의 분배가 변화했을 뿐이므로 이것은 전적으로 다른 문제다.
    • (저자의 글에 특징 중 하나인데, 첫째라고 해놓고 둘째가 안보이는 경우가 대단히 많다. 뒤에 나오는 title 밑에서 둘째가 나오는 경우도 있음)
  • 다시 말해 우리는 소수로 이루어진 아주 부유한 자본소득자의 사회에서 훨씬 더 많은 수의 덜 부유한 자본소득자의 사회로 옮겨왔다. 말하자면 소자본소득자(pefits rentiers)의 사회인 셈이다.
    • (이하 그래프 설명 생략)

자본소득자, 민주주의의 적

  • 둘째, 21세기에 상속자본의 분배가 19세기만큼 불평등해지지 않으리라는 보장이 없다.
    • (관련 설명 생략)
  • (이하 지대에 대한 설명 생략)
  • 경제적, 기술적 합리성은 계몽주의에서 유래했고, 사람들은 흔히 민주주의적 합리성이 경제적, 기술적 합리성에서 마치 마술처럼 저절로 파생될 것이라고 가정한다.
    • 그러나 진정한 민주주의와 사회적 정의를 이루려면 시장의 제도, 단지 의회나 그 외의 형식적인 민주주의적 제도 뿐만 아니라 민주주의와 사회적의 스스로의 특정한 제도들이 필요하다.
  • 이 책에서 계속 강조했던, 격차를 확대하는 근본적인 힘은 r>g라는 부등식으로 요약될 수 있는데, 이것은 시장의 불완정성과는 아무 관계가 없으며 시장이 더욱 자유로워지고 경쟁이 강화되어도 사라지지 않을 것이다.
    • 무제한적인 경쟁이 상속을 없애고 능력이 더욱 중시되는 사회를 향해 움직일 것이라는 생각은 위험한 착각이다.
    • 보통 선거권이 생기고 투표시 재산에 대한 자격이 없어지면서 부자들의 합법적인 정치적 지배는 끝났다.
    • 그러나 이것이 자본소득자 사회를 낳을 수 있는 경제적 힘을 없애지는 않았다.

상속자산의 귀환: 유럽의 현상인가 아니면 세계적 현상인가?

  • (프랑스의 사례를 다른 국가에 적용할 수 있는가에 대한 설명 생략. 나라별로 조건이 다르고, 자료가 부족한 경우도 있다)

21세기 자본/ 자본 소유의 불평등

  • 부의 불평등 문제가 중요한 이유는 이런 유형의 불평등과 여기서 비롯된 소득의 불평등이 완화된 것이 20세기 전반기에 총소득의 불평등을 완화시킨 유일한 원인이었기 때문이다.
    • 총소득 불평등이 급격히 완화된 것은 본질적으로 자본으로 인한 고소득의 급속한 감소 때문이었다.

극심한 부의 집중: 유럽과 미국

  • 부의 분배, 그리고 자본소득의 분배는 항상 노동소득의 분배보다 훨씬 더 집중되어 있다.
    • 알려진 모든 사회에서 어느 시기든 인구의 가난한 절반은 거의 아무것도 소유하지 않으며 일반적으로 전체 부의 5% 조금 넘게 소유한다.
    • 반면 상위 10%의 부유층이 소유할 수 있는 것의 대다수를 소유한다. 이들은 일반적으로 부의 60% 때로는 90%까지 소유한다.
    • 그리고 구조상 중간 계층의 40%인 나머지 인구가 전체 부의 5-35%를 소유한다.

프랑스: 민간 부의 관측소

  • (자료 수집에 대한 내용 생략)

세습사회의 변형

  • 아래 도표는 1810년부터 2010년까지 부의 분배의 변화에 관해 얻은 주요 결론을 보여준다.

  • 첫 번째 결론은 1914-1945년의 충격 이전에는 자본 소유의 불평등이 축소되는 추세가 가시적으로 나타나지 않았다는 점이다.
    • 19세기 내내 자본의 집중이 심화되는 추세가 약하게 나타났고, 1880-1913년에는 심지어 불평등의 악순환이 가속화되는 경향까지 보였다.
    • (부의 집중 정도에 대한 사례 생략)

  • 18세기 프랑스 혁명 직전(1789년)에는 상위 10%의 부유층이 전체 부의 90% 이상을 차지했고, 상위 1%의 몫은 60% 이상이었다.

벨 에포크 시대 유럽의 자본 불평등

  • 다른 유럽 국가들에 관해 이용할 수 있는데이터는 18세기와 19세기 1차대전 직전까지 나타난 부의 극단적인 집중이 단지 프랑스 뿐 아니라 유럽 전반의 현상임을 보여준다.
  • (각국 사례 생략)

세습중산층의 등장

  • 도표 10.1에 제시된 프랑스의 데이터에서 분명히 드러나는 두 번째 결론은 자본소득의 집중 뿐만 아니라 부의 집중이 1914-1945년의 충격에서 아직 완전히 회복되지 않았다는 것이다.
    • 전체 부에서 상위 10%가 차지하는 몫은 1910-1920년의 90%에서 1950-1970년에 60-70%로 떨어졌다.
    • 상위 1%가 차지하는 몫은 더 가파르게 떨어져서 1910-1920년 60%에서 1950-1970년에는 20-30%까지 낮아졌다.
  • 1980-1990년 부의 불평등 정도는 다시 높아지기 시작했지만, 오늘날 부의 불평등은 1세기 전보다 상당히 낮은 수준이다.
    • 전체 부에서 상위 10%가 차지하는 몫은 현재 60-65%로 높기는 하지만 벨 에포크 시대보다는 현저히 낮은 수준이다.
    • 지금은 국부의 1/3 가량을 소유한 ‘세습중산층’이 존재한다. 국부의 1/3이면 그리 적은 비중이 아니다.

미국의 자본 불평등

  • 유럽과 미국이 그린 궤적에는 중요한 차이점이 두드러진다. 첫째, 1800년경 미국의 부의 불평등은 1970-1980년의 스웨덴보다 높지 않았다.
    • 미국은 재산이 거의 없이 신대륙으로 건너온 이민자들로 인구가 구성된 새로운 국가였기 때문에 이는 그리 놀랍지 않다. 부가 축적되거나 집중될 만큼 충분한 시간이 흐르지 않았던 것이다.
  • 19세기가 흘러가면서 미국에서 부가 점차 집중되었다는 것은 확고한 사실이다. 1910년에 미국의 자본 소유의 불평등은 매우 높았지만 유럽보다는 여전히 현저하게 낮았다.
    • 상위 10%의 부유층이 전체 부의 약 80%를, 상위 1%가 약 45%를 소유했다.

  • 미국에서는 1910-1950년에 부의 불평등이 소득불평등과 마찬가지로 완화되었지만, 유럽보다는 그 정도가 훨씬 덜했다.
  • 미국은 2010년에 상위 10%가 차지하는 몫이 70%를 넘었고 상위 1%의 몫은 35%에 가까웠다.

  • 20세기 동안 미국에서 나타난 부의 불평등 감소는 상당히 제한적이었다. 상위 10%가 차지하는 몫이 미국에서는 80%에서 70%로 떨어진 반면, 유럽에서는 90%에서 60%로 떨어졌다.
  • 유럽은 20세기에 완전히 변화했는데, 1차대전 직전 앙시앵레짐 시대만큼 높았던 부의 불평등이 전례 없이 낮은 수준으로 떨어졌다.
    • 인구의 거의 절반이 어느 정도의 부를 획득할 수 있었고 처음으로 국가의 전체 부에서 상당한 몫을 차지할 정도로 불평등 정도가 낮아졌다.
    • 이 현상은 1945-1975년 유럽을 휩쓸었던 낙관주의를 부분적으로 설명해 준다.
  • 오늘날 미국의 부의 불평등은 19세기 초보다 훨씬 높다.

부의 양극화 메커니즘: 역사 속 자본수익률 대 성장률

  • 유럽은 1914-1945년 충격 후 부의 불평등이 상당히 완화되었으며 지금까지 과거 수준으로 회귀하지 않았다.
  • 여기에는 몇 가지 메커니즘이 작용했을 수 있는데, 다음은 우리가 알고 있는 자료에서 도출 가능하다고 생각되는 주요 결론이다.
  • 격차를 확대하는 근본적인 힘은 다음과 같이 작용한다.
    • 가령 연간 성장률이 0.5~1% 정도로 낮은 세계를 생각해 보자. 이는 18세기와 19세기 이전에는 모든 곳이 그러했다.
    • 따라서 연간 4-5% 정도인 자본수익률이 성장률보다 높았다.
    • 구체적으로 말하면 과거에 축적된 부가 경제성장보다 훨씬 더 빠른 속도로 다시 자본으로 축적된다는 뜻이다. 심지어 노동소득이 전혀 없는 경우에도 그렇다.
    • 예컨대 g가 1%, r이 5%일 경우 자본소득의 1/5을 저축하면 이전 세대에서 물려받은 자본이 경제성장과 같은 비율로 증가하도록 하는데는 충분하다.
    • 수학적인 이유로 이는 ‘상속사회(inheriance society)’가 번창하기에 이상적인 조건이다.
  • 역사적으로 많은 나라에서 이런 조건이 성립했다. 다음 도표에서 알 수 있는 것처럼 1820-1913년까지 프랑스에서는 자본수익률이 성장률보다 현저히 높았다.

자본수익률은 왜 성장률보다 높은가

  • 오랜기간 r이 g보다 더 높았다는 것은 반박의 여지가 없는 역사적 사실이다.
  • 다음 도표에서 알 수 있는 것처럼, 일반적으로 4-5%인 자본수익률은 역사를 통틀어 항상 글로벌 성장률보다 뚜렷하게 높았다.

  • 20세기 특히, 세계 경제가 연간 3.5-4%의 성장률을 보인 20세기 후반 이 둘의 격차는 크게 줄어들었는데, 21세기에는 성장 (특히 인구 성장)이 둔화되면서 십중팔구 차이가 다시 벌어질 것이다.
    • 세계성장률은 2050년에서 2100년 사이에 매년 1.5%일 것으로 추정되는데 이는 19세기와 거의 같은 성장률이다. 이러면 r과 g의 격차는 산업혁명 당시와 맞먹는 수준으로 되돌아갈 것이다.
  • 이런 상황에서는 자본에 대한 과세와 다양한 종류의 충격이 중요한 역할을 할 수 있다는 것을 쉽게 알 수 있다.
    • 1차대전 이전에는 자본에 대한 세금이 매우 낮았다. 1차대전 이후 최상위층 소득, 이윤 그리고 부에 대한 세율이 빠른 속도로 상승해 높은 수준이 되었다.
    • 그러나 1980년 이후 금융세계화와 국가들 간의 자본 유치 경쟁 격화에 따라 세율이 떨어지기 시작했고, 어떤 경우에는 완전히 사라지다시피 했다.
  • 아래 도표는 세금을 빼고 1913-1950년 재산 파괴로 인한 자본손실 추정치를 반영한 뒤 평균 자본수익률을 추정한 것이다.

  • 우리는 1913-1950년에 세금 공제 뒤의 자본수익률이 1-1.5%로 떨어져 성장률보다 낮았다는 것을 발견했다. 이런 새로운 상황은 이례적일 정도로 높은 성장률 덕분에 1950-2012년에도 계속되었다.
    • 결과적으로 우리는 20세기 조세적, 비조세적 충격들로 인해 역사상 최초로 자본의 순수익률이 성장률보다 낮은 상황이 나타났다는 것을 발견했다.
  • 21세기 어느 시점에 r과 g의 차이가 19세기와 비슷한 수준으로 돌아갈 것으로 예상되는 자본에 대한 세율이 평균 30% 정도에 머물 경우 자본의 순수익률이 성장률보다 상당히 높은 수준으로 상승할 가능성이 높다.

시간선호 문제

  • 자본수익률이 자연적이고 지속적으로 2-3% 이하로 떨어진 적은 없으며, 우리가 볼 수 있는 평균 수익률은 일반적으로 세전 4-5%에 가깝다.
  • 이렇게 자본수익률이 4-5%로 비교적 고정되는 현상, 그리고 2-3% 아래로 결코 떨어지지 않는다는 사실을 설명하기 위해 일반적으로 사용되는 경제모형은 현재를 선호하는 ‘시간선호(time preference)’ 개념에 바탕을 두고 있다.
    • 달리 말하면 경제 주체들은 얼마나 인내심이 있는지와 얼마나 미래를 고려하는지를 측정하는 시간선호율(보통 세타 \theta 로 표시된다)에 따라 특징 지어진다.
    • 예컨대 \theta = 5% 라면 해당 주체는 오늘 100유로를 더 쓰기 위해 미래에 쓸 수 있는 105유로를 희생할 의사가 있다는 것이다.
    • 이 이론은 따른 예측은 과격하고 무자비한데, 성장률이 제로인 경제라면 자본수익률이 시간선호율 \theta 와 동일해야 한다는 것이 놀랍지 않은 일이다.
    • 이 이론에 따르면 자본수익률이 역사적으로 4-5%로 고정적이었던 것은 궁극적으로 심리적인 이유 때문이다.
  • 다른 상황이 모두 같다면 인내심이 좀 더 강한 사회, 즉 미래의 충격을 예측하는 사회가 당연히 더 많은 예비 자금을 모으고 더 많은 바존을 축적할 것이다.
    • 마찬가지로 한 사회가 너무 많은 자본을 축적해서 자본수익률이 지속적으로 낮다면 –가령 연간 1%라면– 재산을 소유한 개인들 가운데 상당수가 집이나 금융자산을 팔 것이고, 그리하여 수익률이 상승할 때까지 자본총량이 줄어들 것이다.
  • 이 이론의 문제점은 지나치게 단순하고 체계적이라는 것이다. 모든 저축 행위나 미래에 대한 태도를 심리적 매개변수 하나로 요약할 수는 없다.
    • (이하 이론에 대한 내용 생략)
  • 내가 생각하기에 r>g 부등식은 절대적인 논리적 필연성으로서가 아니라 다양한 메커니즘에 의존하는 역사적 사실로서 분석되어야 한다. r>g 는 각각 상당히 독립적인 요인이 함께 작용한 결과다.
    • 성장률 g는 구조적으로 낮은 경향이 있는데, 인구 변천이 완료되고 국가 기술력이 세계적인 첨단 수준에 이르러 혁신의 속도가 둔화되면 일반적으로 1%를 크게 웃돌지 않는다.
    • 자본수익률 r은 무수한 기술적, 심리적, 사회적, 문화적 요인에 의존하며 이 요인들이 함꼐 작용해서 약 4-5%의 수익률을 낳는 것으로 보인다.

균형 분배는 존재할까?

  • 자본수익률이 성장률보다 지속적이고 명백하게 높다는 사실은 부의 분배를 더 불평등하게 만드는 강력한 동인이다. 이런 상황에서 불평등의 무한한 악순환을 막고 부의 불평등을 제한된 수준으로 안정시킬 수 있는 유일한 힘은 다음과 같다.
    • 첫째, 부유한 개인의 재산이 평균 소득보다 빠른 속도로 증가하면 자본/ 소득 비율이 무한히 높아질 것이고 결국 자본수익률 감소로 이어질 것이다. 그러나 이 메커니즘이 작동하려면 수십 년이 걸릴 수 있다.
  • (상속 관련 내용 생략)

민법과 프랑스 혁명에 대한 환상

  • 실제로 일단 자본수익률이 성장률보다 지속적으로 훨씬 더 높으면 부의 축적과 상속의 동학이 자동적으로 매우 심각한 부의 집중을 낳고 이때 형제자매 간의 평등한 분배는 별로 영향을 미치지 않게 된다.
    • 개별 가족이 소유한 재산의 궤적에 영향을 미치는 경제적, 인구적 충격들이 항상 존재한다.
  • 아주 간단한 수학적 모형을 활용하면 이런 유형의 충격들이 발생하는 일정한 틀 안에서 부의 분배는 장기적으로 균형을 향해 나아가는 경향이 있으며, 불평등의 균형 수준은 자본수익률과 성장률의 차이, 즉, r-g의 증가함수라는 사실을 입증할 수 있다.
    • 직관적으로 봤을 때, r-g는 자본소득을 전혀 소비하지 않고 모두 자본에 재투자했을 경우 자본소득과 평균 소득 간 격차의 정도를 나타낸다. r-g 차이가 클수록 격차가 확대되는 힘이 강해진다.
    • 인구적, 경제적 충격이 곱셈적 형태를 띤다면 장기적인 균형 분포는 파레토 법칙에서 예상한 형태가 된다.
    • 또한 파레토 분포의 계수는 r-g의 가파른 증가함수라는 것을 쉽게 보일 수 있다.
  • 이것이 의미하는 바를 구체적으로 살펴보자. 연간 5%의 평균 수익률과 1%의 성장률을 기록한 19세기 프랑스의 수준만큼 자본수익률과 성장률이 차이날 경우, 이 모델은 부가 축적되는 누적적 동하긍로 인해 자동적으로 상위 10%가 약 90%, 상위 1%가 약 50 이상의 자본을 소유할 정도로 부가 극도로 집중될 것으로 예측한다는 것이다.
  • 다시 말해 r>g라는 기본적인 부등식은 19세기 관찰되는 매우 높은 수준의 자본의 불평등을 설명해주고, 따라서 어떤 의미에서는 프랑스혁명의 실패까지 설명할 수 있다.
  • (장자상속제나 프랑스 상황 설명 생략)
  • 마지막으로 r-g 차이가 일정한 한도를 넘으면 균형 분배가 나타나지 않는다는데 주의하자. 이때 부의 불평등은 한없이 심화되고, 부의 분포에서 최대치와 평균 사이의 차이가 무한정 증가할 것이다.

파레토와 안정적인 불평등에 대한 환상

  • 지니계수는 불평등 정도를 하나의 수치로 나타내기 위해 고안되었지만 실제로는 상황을 단순하고 지나치게 낙관적이며 해석하기 어렵게 묘사한다. 더 흥미로운 것은 파레토의 법칙이다.
    • 놀라운 것은 파레토가 자신의 안정성 이론을 지지할 증거를 제시하지 않았다는 점이다.
  • 파레토 법칙으로 불리게 된, 혹은 일반적인 함수의 한 예로 ‘멱법칙’이라고 알려진 수학법칙을 통해 부와 소득의 분배를 연구한다. 그러나 멱법칙은 분포의 상위 꼬리 부분에만 적용되며, 그 관계는 대략적이고 국지적으로만 유효하다는 점에 유의해야 한다.
  • (사례 생략)
    • 그 각각에 대해 우리는 파레토 분포를 다루지만 계수들은 상당히 다르다. 그리고 각각의 경우에 해당하는 사회적, 경제적, 정치적 현실들은 완전히 다르다.
  • 오늘날에도 어떤 사람들은 파레토가 그랬던 것처럼 부의 분포가 마치 자연법칙처럼 견고하게 안정적이라고 생각하는데, 실제로 이런 견해는 사실과 전혀 맞지 않는다.
    • 역사적 관점에서 불평등을 연구할 때 설명해야 할 중요한 사항은 분포의 안정성이 아니라 때때로 발생하는 커다란 변화다.

부의 불평등이 과거 수준으로 돌아가지 않는 이유

  • 이제 본질적인 의문에 이르렀다. 왜 부의 불평등이 벨 에포크 시대의 수준으로 돌아가지 않을까? 이 의문에 대해 내가 확실한 답을 가지고 있는 것은 아니다.
  • (20세기 부의 변화에 대한 설명 생략)

부분적인 설명: 시간, 세금, 성장

  • 결국 1910-1950년 사이에 모든 곳에서 부의 집중이 급격히 완화되었다는 것은 그리 놀랄 일이 아니다.
  • 자본축적이 몇 세대애 걸친 장기적인 과정임을 인식하는 것은 분명 중요하다. 벨 에포크 시대 유럽에서 진행된 부의 집중은 수십 년 혹은 수백 년에 걸쳐 누적된 과정의 결과였다.
  • 오늘날 부가 과거만큼 불평등하게 분배되지 않은 이유는 1945년 이후 충분한 시간이 지나지 않았기 때문이다.
  • (중간 설명 생략 -결론이 눈에 보이는데 자꾸 몇 단락 째 빙빙 돌리고 있다)
  • 1914-1945년 사이 어떤 구조적인 변화가 일어났을까? 이 변화들은 오늘날 민간의 부가 전반적으로 과거만큼 거의 성공적으로 번창하고 있는데도 부의 집중이 예전의 수준을 회복하지 못하게 가로막고 있다.
    • 가장 자연스럽고 중요한 설명은 20세기의 정부들이 자본과 자본소득에 상당한 세율로 세금을 부과하기 시작했다는 사실이다.
    • 1900-1910년 관찰된 매우 높은 부의 집중은 장기간 큰 전쟁이나 재난이 일어나지 않았을 뿐 아니라 세금이 거의 없었던 결과다.
    • 1차대전까지는 자본소득이나 기업 이윤에 세금이 부과되지 않았고, 드물게 부과되더라도 세율이 아주 낮았다.
    • 따라서 상당한 재산을 축적해 물려주고, 그런 재산에서 얻은 소득으로 생활하기 이상적인 환경이었다.
  • (세율에 대한 설명 생략)
  • 이런 맥락에서 볼 때 자본에 부과된 세금의 효과는 부의 전체적인 축적을 줄이는 것이 아니라 장기적인 부의 분배 구조를 변화시키는 것임에 주의해야 한다.
    • 역사적 데이터나 이론 모형에서 보면 자본소득에 대한 세금이 30%로 올라도 (자본에 대한 순수익률이 5%에서 3.5%로 줄어도) 장기적으로 자본총량은 변하지 않을 것이다.
    • 상위 1%가 차지하는 몫의 감소가 중산층의 부상으로 상쇄되기 때문이다.
    • 이것이 바로 20세기에 일어난 일이다.
  • 20세기에 최대 규모의 상속재산에 대한 상속세와 함께 누진세, 즉 최상위 소득과 특히 최상위 자본소득에 더 높은 세율을 적용하는 세금이 강화되었다는 점에 주목해야 한다.
  • 1914-1945년 군사적, 경제적, 정치적 충격에 뒤이어 대부분의 부유한 국가에서 부과된 20-30% 혹은 그 이상의 세율은 완전히 다른 영향을 미쳤다.
    • 이러한 세금의 결과로 인해, 만약 가족의 재산이 평균 소득만큼 빠르게 증가하기를 원한다면 이후 세대가 지출을 줄이고 저축을 많이 해야 했다.
    • 그리하여 부자들은 부의 계층 구조에서 원래의 지위를 유지하기 점점 더 어려워졌고, 거꾸로 바닥에서 출발한 사람들은 위쪽으로 올라가기가 쉬워졌다.
    • 간단한 시뮬레이션을 해보면 누진적 상속세가 장기적으로 상위 1%가 부에서 차지하는 몫을 상당히 줄일 수 있음을 알 수 있다.

21세기는 19세기보다 훨씬 더 불평등할까?

  • 지금까지의 분석은 세금 제도의 변화와 관계없이 아마도 두 요인이 중요한 역할을 했으며, 미래에도 그럴 것임을 보여주었다.
    • 첫 번째는 소득에서 자본이 차지하는 비중과 자본수익률이 장기적으로 약간 낮아질 가능성이고,
    • 두 번째는 성장률이 21세기에 약간 둔화될 것으로 보이지만 18세기까지 인간의 역사 대부분의 기간에 관찰된 극도로 낮은 수준보다는 높을 것이라는 점이다.
    • 구체적으로 말하면 미래에는 r>g라는 차이가 18세기 이전보다 작을 것이다. 자본수익률이 낮아질 것이고 성장률이 높아질 것이기 때문이다.
  • 그러나 기뻐할 이유는 없다. 여전히 부의 불평등은 상당히 높아질 것이기 때문이기도 하고, 시뮬레이션에 상당히 불확실성이 존재하기 때문이기도 하다.
  • 요약하면 다음과 같다. 오늘날 유럽에서 부의 집중이 벨 에포크 시대보다 두드러지게 낮은 현실은 주로 우연적인 사건들과 자본 및 자본소득에 부과된 세금 같은 특정한 제도의 결과다.
    • 그러한 제도들이 결국 무너진다면 부의 불평등이 과거 수준과 비슷해지고 어떤 상황에서는 더 높아질 위험이 있다.

21세기 자본/ 노동소득의 불평등

임금불평등: 교육과 기술 간의 경주?

  • 노동소득의 불평등, 특히 임금불평등이 시기에 따라 차이가 나는 것에 대해 널리 인정되는 이론은 교육과 기술 간의 경주 때문이라는 주장이다.
    • 이 이론은 모든 것을 설명하진 못하는데, 특히 슈퍼경영자의 등장이나 1980년 이후 미국의 임금불평등에 대해서 만족스러운 설명을 내놓지 못한다.
    • 그럼에도 이 이론은 특정한 역사적 전개과정을 설명하는데 흥미롭고 중요한 단서를 제공한다.
  • 이 이론은 다음 두 가지 가설을 바탕으로 한다.
    • 첫째, 한 노동자의 임금은 그의 한계생산성, 즉 그가 일하는 기업이나 사무실의 생산에 기여한 가치와 동일하다.
    • 둘째, 노동자의 생산성은 무엇보다 그가 보유한 기능 그리고 주어진 사회에서 그 기능의 수급에 따라 좌우된다.
  • 이 이론은 제한적이고 고지식하다고 할 수 있는데, 노동자의 생산성은 객관적인 수치가 아니며, 노동자의 급여를 결정하는데는 종종 다른 사회집단들의 상대적인 힘이 중요한 역할을 하기 때문이다.
    • 그러나 이 이론은 임금불평등을 결정짓는데 근본적인 영향을 미치는 두 가지, ‘수요’와 ‘공급’을 강조한다는 장점이 있다.
    • 실제로 기능의 공급은 다른 무엇보다 교육제도의 상태에 좌우된다. 그리고 기능에 대한 수요는 무엇보다 사회가 소비하는 상품과 서비스를 생산하기 위해 이용 가능한 기술의 상태에 좌우된다.
  • 다른 어떤 요인들이 관련되었든 간에 교육제도의 상태와 기술의 상태는 중요한 역할을 한다. 물론 이 두 요인 자체도 다른 많은 힘에 좌우된다.
    • 교육제도는 공공정책, 다양한 교육과정에 대한 선택 기준, 재원 확보 방식, 학생과 가족들이 부담하는 학비, 평생교육의 이용 가능성에 의해 형성되고,
    • 기술의 진보는 혁신이 얼마나 빠른 속도로 이루어지고 실행되는지에 달려있다. 기술의 진보는 새로운 기능에 대한 수요를 증대시키고 새로운 직업을 창출한다.
  • 여기서 교육과 기술 간의 경주라는 개념이 도출된다.
    • 기능의 공급이 그 수요와 같은 속도로 증가하지 않으면 충분한 고등교육을 받지 못한 집단들은 소득이 낮아질 것이고 저평가되는 직업으로 밀려나며, 그리하여 노동과 관련된 불평등이 심화될 것이다.
    • 이런 문제를 피하려면 교육제도가 새로운 유형의 교육과 그 교육 결과로서의 새로운 기능들을 충분히 빠른 속도로 공급해야 한다.
    • 더 나아가 불평등을 줄이려면 새로운 기능의 공급이 훨씬 더 빠른 속도로 늘어나야 하는데, 특히 교육을 가장 덜 받은 사람들의 경우 더욱 그러하다.
  • 프랑스의 임금불평등을 생각해 보자. 프랑스에서 임금불평등은 오랜 시간 꽤 안정적이었는데, 20세기가 흘러가면서 평균 임금은 엄청나게 상승했음에도 최고 10%와 최저 10% 사이의 격차는 그대로였다.
    • 이에 대한 가장 자연스러운 설명은 모든 기능의 수준이 거의 같은 속도로 발전함으로써 불평등한 급여 체계가 위쪽으로 이동했다는 것이다.
    • 과거 초등학교 졸업장으로 만족했던 집단은 이제 대학이나 대학원에 진학한다.
    • 교육제도의 민주화는 교육 불평등을 제거하지 못했고, 임금불평등도 제거하지 못했다. 그러나 교육 민주화가 이루어지지 않았다면 임금불평등은 더 심화되었을 것이 분명하다.
  • 반면 미국은 1970년대까지 규칙적으로 감소했던 임금 격차가 1980년대 다시 증가했는데, 이 자료를 조사한 클라우디아 골딘(Claudia Goldin)과 로런스 카츠(Lawrence Katz)는 그 원인을 고등교육에 충분히 투자하는데 실패했기 때문이라고 믿었다.
  • 프랑스의 미국의 사례는 같은 교훈을 가리키는데, 궁극적으로 노동과 관련된 불평등을 줄이고, 생산성과 경제성장률을 높이려면 교육에 투자해야 한다는 것이다.
    • 장기적으로 봤을 때 교육과 기술은 임금 수준을 결정하는 요인이다.
    • (이에 대한 반론도 있는 것으로 아는데, 교육 수준이 올라가도 GDP는 올라가지 않는 사례가 아프리카에 많다고 함. 이에 대해 반론을 제기하는 사람들은 교육은 그저 교육일 뿐 경제성장과는 관계가 없다고 주장한다.)

이론 모형의 한계: 제도의 역할

  • 교육과 기술은 장기적으로 결정적인 역할을 하지만, 한 노동자의 임금이 그의 한계생산성에 의해, 따라서 주로 그가 보유한 기능에 의해 완전하게 결정된다는 개념을 바탕으로 한 이 이론 모형은 여러 측면에서 한계를 갖고 있다.
    • (여러 자잘한 설명들 생략)
  • 임금불평등이라는 좁은 맥락에서 한계생산성 이론의 주된 문제는 이 이론이 서로 다른 시기의 서로 다른 국가들에서 관찰한 다양한 임금 분포 양상을 설명하지 못한다는 것이다.
    • 임금불평등의 동학을 이해하려면 각 사회 노동시장의 작동을 지배하는 제도와 규칙 같은 다른 요인들을 끌어들여야 한다.
    • 노동 시장은 특정한 규칙과 타협에 근거한 하나의 사회적 구조이며 이런 특징이 다른 시장들보다 훨씬 강하기 때문이다.
  • 1950년 이후 프랑스에 나타난 임금불평등의 전개과정을 설명하려면 최저임금의 변화가 중요하다.
    • 이 기간은 명확히 세 시기로 구분되는데, 최저임금이 조절되지 않고 임금의 계층구조가 확장된 시기(1950-1968)와, 최저임금이 상승하고 임금불평등이 급격히 줄어든 시기(1968-1983), 최저임금이 비교적 서서히 상승하고 임금불평등이 확장되는 시기(1983-현재)가 그것이다.
  • 미국에서는 프랑스보다 거의 20년 앞선 1933년 연방 최저임금제가 도입되었는데, 프랑스에서와 마찬가지로 임금불평등이 전개되는데 중요한 역할을 했다.
    • (설명 생략)
  • 미국의 임금 분포에서 최하위층의 불평등은 최저임금의 변화에 긴밀하게 반응하며 움직여왔다.
    • 임금 분포에서 하위 10%와 전체 평균 임금 간의 격차는 1980년대 상당히 벌어지다가 1990년대 좁아졌고, 2000년대 들어서는 다시 확대되었다.
    • 반면 임금 분포에서 최상위층의 불평등은 이 시기에 꾸준히 증가했다.
    • 최저임금은 임금 분포의 최하위층에는 영향을 미쳤지만, 다른 요인들이 작용하는 최상위층에 미치는 영향은 그보다 훨씬 덜하다.
    • (국내에서 최저임금 급격히 올리다가 문제가 발생한 사건도 있고, 위 설명 종합해 보면 최저임금과 불평등의 관계는 상관성이 낮다고 보는게 맞지 않을까?)

임금 체계와 최저임금

  • 최저임금제가 임금불평등의 형성과 전개에 중요한 역할을 한다는데는 의심의 여지가 없다.
    • (이하 국가별 설명 생략)
  • 최저임금제와 고정된 임금 체계는 왜 필요할까?
    • 특정 노동자의 한계생산성을 측정하기가 쉬운 것이 아니기 때문이다.
    • 한계생산성의 측정이 불확실한데 임금은 어떻게 설정되어야 할까? 경영진에게 절대 권력을 부여한다면 독단적이고 불공정한 요소가 끼어들 뿐 아니라 기업에도 비효율적일 것이다.
  • 일당 대신 월급을 지급하는 것은 혁신적인 방법이었다.
    • 이런 변화는 노동자 계급의 형성에 결정적인 단계였는데, 노동자들은 법적 지위를 누리고 안정되고 예측 가능한 보수를 받게 되었다.
    • 그리하여 18-19세기 전형적인 피용자였던 일용직 노동자, 개수급 노동자(piece worker)와 명확히 구분되었다.
  • (이하 최저임금제와 정액 임금제를 지지하는 설명 생략)
  • 요약하자면 임금을 인상하고 궁극적으로 임금불평등을 줄이는 가장 좋은 방법은 교육과 기술에 투자하는 것이다.
    • 장기적으로 최저임금과 임금제도가 임금을 5배, 10배 늘리지 못하지만, 그러한 수준의 진전을 이루기 위해서는 교육과 기술의 역할이 결정적이다.

미국에서의 불평등 폭발을 어떻게 설명할 것인가?

  • 한계생산성 이론과 교육과 기술 간의 경주 이론에서 가장 뚜렷하게 눈에 띄는 한계는 1980년 이후 미국 최상위 노동소득이 폭발적으로 증가한 현상을 적절히 설명할 수 없다는 점이다.
    • 미국의 일부 경제학자는 최상위 노동소득이 평균 임금보다 훨씬 빠른 속도로 증가한 이유는 특별한 기능들과 신기술로 인해 이 노동자들의 생산성이 훨씬 더 높아졌기 때문이라 생각한다.
  • 미국에서 임금불평등이 심화된 것은 주로 임금 분포의 최상위층, 즉 상위 1%와 더 나아가 0.1%의 임금이 높아졌기 때문이다.
    • 상위 10% 전체를 살펴보면 9%임금은 평균 노동자들보다는 빠른 속도로 인상되었지만 1%의 인상 속도에는 훨씬 못 미쳤다.
    • 교육 기간이나 교육 기관의 선택성, 전문적인 경험 등 어떤 기준을 선택해도 9%와 1% 사이의 불연속성은 찾아보기 힘들다.
  • (중간 설명 생략)
  • 이런 현상을 교육 요인에 초점을 맞추는 것은 적절하지 않다.

슈퍼경영자의 부상: 영미권 국가들의 현상

  • 또 다른 문제는 일부 선진국에서는 고액 연봉의 폭등이 일어났지만 다른 국가들에서는 그렇지 않았다는 점이다.
    • 이런 현상은 기술 변화 같은 전반적이고 선험적인 요인보다 국가 간의 제도적 차이가 이 문제에 중심적인 역할을 한다는 것을 시사한다.
  • (아래 그래프들에 대한 설명 생략)

  • 지역별 차이를 간단히 표현하면 다음과 같다.
    • 미국에서는 2000-2010년 소득불평등이 1910-1920년에 관찰되던 수준으로 되돌아갔다. 하지만 소득의 구성은 달라져서 노동으로 버는 고소득의 역할이 커지고 자본으로 얻는 고소득의 역할을 줄어들었다.
    • 영국과 캐나다에서도 같은 방향으로 변화가 일어난 반면, 유럽 대륙 국가들과 일본의 임금 불평등은 장기적인 관점에서 보면 20세기 초보다 훨씬 더 낮은 수준에 머물러 있고 실제로 1945년 이후 크게 변하지 않았다.
  • 세계의 부유한 지역들 간에 나타나는 차이가 놀라운 것은 모든 곳에서 기술 변화가 어느 정도 비슷했기 때문이다. 특히 정보기술 혁명은 미국, 영국, 캐나다 못지 않게 일본, 독일, 프랑스, 스웨덴, 덴마크에서도 영햐을 미쳤다.
    • 이런 사실을 볼 때 부유한 국가들의 소득분배 변화 과정에서 나타난 이 커다란 차이는 한계생산성 이론이나 교육과 기술 간의 경주 이론으로는 설명하기 어렵다.

1900-1910년대에는 신대륙보다 불평등했던 유럽

  • 미국이 유럽보다 항상 불평등했던 것은 아니다. 20세기 초 유럽은 소득불평등이 매우 높았다.
    • 벨 에포크 시대에는 모든 유럽 국가에서 비슷한 수준으로 소득의 집중이 나타났다. 이 시기 최상위 소득은 거의 전적으로 자본소득으로 이루어졌기 때문에 주로 자본의 집중리나는 부문에서 설명의 근거를 찾아야 한다.
    • 유럽과 비교하면 미국과 캐나다, 호주와 뉴질랜드의 불평등 정도가 더 낮았다. 벨 에포크 시대에는 신대륙, 그중에서도 가장 최근에 정착이 이뤄진 지역이 구유럽보다 더 평등했다.
  • 유럽과 일본에서 나타난 훨씬 더 높은 부의 불평등은 낮은 인구성장률로써 꽤 자연스럽게 설명된다. 인구성장률이 낮으면 거의 자동으로 자본축적과 집중이 심화된다.
  • 여기서는 국가와 대륙 간의 상대적 지위를 바꿔 놓은 변화의 크기를 설명하려는데, 이는 국민소득에서 상위 10%가 차지하는 비율의 변화 양상을 살펴보면 명확히 알 수 있다.
    • (이하 그래프 관련 설명 내용 생략)

신흥경제국에서의 불평등: 미국보다 더 낮을까?

  • (자료 수집에 대한 내용 생략)
  • 내 추정치는 도표 9.9에 제시했다. 몇 가지 점을 강조하는데 다음과 같다.
  • 가장 놀라운 결과는 가난한 신흥경제국들에서 상위 1%의 소득이 국민소득에서 차지하는 비율이 부유한 국가들과 비슷하다는 점이다.

  • (이하 그래프에 대한 설명과 자료 수집에 대한 설명 생략)

한계생산성이라는 환상

  • 앞서 언급했듯이 한계생산성 이론이나 기술과 교육 간의 경주 이론은 그다지 설득력이 없다.
    • 폭발적으로 늘어난 보수는 임금 분포상 상위 1% 혹은 0.1%에 고도로 집중되어 있는데, 어떤 나라들의 불평등에는 큰 영향을 미친 반면, 다른 나라들에는 그다지 영향을 미치지 않았다.
  • 2000-2010년 미국의 소득불평등이 과거 여러 시기에 가난한 국가와 신흥경제국에서 관찰된 수준 보다 높아졌다는 사실은 생산성의 객관적인 불평등에만 근거한 설명에 의문을 제기한다.
  • 내가 생각하기에 미국의 최상위 소득의 폭발적인 증가에 대한 가장 설득력 있는 설명은 다음과 같다.
    • 앞서 언급한 것처럼 최고소득자 중 대다수는 대기업의 고위경영자다. 이들이 받는 높은 급여를 개인적인 ‘생산성’에서 찾는 것은 순진한 생각이다.
    • (이하 한계생산성의 추정이 정확할 수 없다는 설명 생략)
  • 불확실한 정보와 인지적 어려움을 감안할 때 실제로 보수는 어떻게 결정되는 것일까?
    • (경영자의 보수는 노동자들과 다른 방식에 의해 책정된다는 내용 생략)
  • 간단히 말하면 미국과 영국에서 임금불평등이 급속도로 증대된 것은 1970-1980년 이후 양국 기업들이 극도로 후한 급여 패키지에 훨씬 관대해졌기 때문이다.
    • 유럽과 일본 기업에서도 사회 규범들이 비슷한 방향으로 진화했지만 이 경향이 더 늦게 나타났고 지금까지는 미국처럼 큰 변화가 일어나지 않았다.
    • 어떤 자료를 보더라도 고위경영진에 대한 보수의 변화가 각국의 임금불평등의 변화에 핵심적인 역할을 했음을 알 수 있다.

슈퍼경영자들의 도약: 강력한 양극화 요인

  • 분명히 말하지만 나는 지금 모든 임금불평등이 공정한 보수와 관련된 사회 규범에 의해 결정된다고 주장하는 것이 아니다.
  • 하지만 선험적으로 결코 분명하게 나타나지 않는 핵심적인 사실은 1970-1980년 이후 부유한 국가들이 보여준 서로 다른 변화 양상에서 알 수 있듯이 총임금에서 상위 1%가 차지하는 몫은 국가나 시기마다 상당히 다를 수 있다는 점이다.
    • 최상위 소득의 폭발적인 증가는 ‘극단적 능력주의’의 한 형태로 설명될 수 있다.
  • 어쨌든 최고위 경영자들에게 주어진 극도로 후한 보수는 부의 분배를 불평등하게 만든 강력한 요인이다.
    • 가장 많은 보수를 받은 개인들이 자신의 급여를 스스로 정한다면 불평등은 계속 커져 갈 것이다.
  • 경영진의 급여가 가장 빠른 속도로 상승하는 것은 외부적인 요인들로 매출과 이익이 증가할 때다.
    • 미국 기업들에서 특히 이런 현상이 뚜렷하다. 베르트랑과 멀레이너선은 이런 현상을 ‘행운의 보수’라고 부른다.

저자가 교수라서 그런지 노동자와 경영자의 역할 차이를 전혀 이해하지 못하고 있는 것이 아닐까 싶다. 노동자는 자신의 기술을 바탕으로 한 노동을 회사에 공급하고 그에 대한 보수를 받는 사람들인거고, 경영자는 회사의 자원 –여기에는 유무형의 각종 인프라 뿐만 아니라 노동자도 포함된다– 을 이용해 사업을 수행하고 그 성과를 통해 보수를 받는 사람들이다.

사업은 당연히 Risk –투자보다 사업의 Risk가 훨씬 크다– 가 따르는 것이고 그에 반대로 성과가 높을 때 그만큼의 보수가 따르는 것이다. 그렇기 때문에 회사에 이익이 발생하면 그만큼 많은 보수를 받을 수 있는 것.회사가 어려워지면 가장 유연하게 깎을 수 있는게 경영진의 임금과 성과급이지 않은가. 기업은 이익을 고려하지 않아도 되는 정부 기관이 아니다.

자신의 기술력에 대한 생산성을 대가로 확정된 임금을 지급받는 일반 노동자들과는 역할이 아예 다른데, 경영진에게도 노동자와 같은 잣대를 들이대면서 왜 그들은 높은 보수를 받을까 하는 것은 내가 보기에 현실을 전혀 모르고 하는 판단에 불과하다.

글의 내용을 미루어 짐작해 보면 왜 그들이 높은 연봉을 받는가가 아니라 어떻게 그렇게 높은 연봉을 받을 수 있었는가가 중요한거고, 그것이 가능한 이유는 그만큼 미국의 기업들이 많은 이익을 낼 수 있었기 때문이었다는 것인데, 그것은 미국 기업이 글로벌 시장에서 큰 경쟁력을 가지고 글로벌 시장의 점유율을 키웠기 때문이라고 보는게 합당하지 않을까 싶다.

머신 러닝 교과서/ 순환 신경망으로 시퀀스 데이터 모델링

시퀀스 데이터 소개

시퀀스 데이터 모델링: 순서를 고려한다

  • 다른 데이터 타입과 다르게 시퀀스는 특별하다. 시퀀스 원소들은 특정 순서를 가지므로 상호 독립적이지 않기 때문이다.
  • 일반적으로 지도 학습의 머신 러닝 알고리즘은 입력 데이터가 독립 동일 분포(Independent and Identically Distributed, IID)라고 가정한다.
    • 예컨대 n 개의 데이터 샘플 x^{(1)}, x^{(2)}, ... , x^{(n)} 이 있을 떄 머신 러닝 알고리즘을 훈련하기 위해 데이터를 사용하는 순서는 상관없다.
    • 시쿠너스 데이터를 다룰 때는 이런 가정이 유효하지 않다. 시퀀스는 정의 자체가 순서를 고려한 데이터이기 때문이다.

시퀀스 표현

  • 입력 데이터에서 의미 있는 순서를 가지도록 시퀀스를 구성한다. 그 다음 머신 러닝 모델이 이런 유용한 정보를 사용하도록 만들어야 한다.
  • 이 장에서는 시퀀스를 x^{(1)}, x^{(2)}, ... , x^{(T)} 처럼 나타내겠다. 위 첨자는 샘플 순서이고 T 는 시쿠너스 길이이다.
    • 시퀀스의 좋은 예시는 시계열 데이터이다. 여기서 각 샘플 포인트 x^{(t)} 는 특정 시간 t 에 속한다.
  • 아래 그림은 시계열 데이터 예를 보여준다. x y 는 시간축을 따라 순서대로 나열되어 있다. 따라서 x y 는 시퀀스 데이터이다.

  • MLP와 CNN 같이 지금까지 다룬 기본적인 신경망 모델은 입력 샘플의 순서를 다루지 못한다. 쉽게 생각해서 이런 모델은 이전에 본 샘플을 기억하지 못한다.
    • 샘플이 정방향과 역방향 단계를 통과하고 가중치는 샘플이 처리되는 순서와 상관없이 업데이트 된다.
  • 반면 RNN은 시쿠너스 모델링을 위해 고안되었다. 지난 정보를 기억하고 이를 기반으로 새로운 이벤트를 처리할 수 있다.

시퀀스 모델링의 종류

  • 시퀀스 모델링은 언어 번역, 이미지 캡셔닝, 텍스트 생성처럼 흥미로운 애플리케이션이 많이 있다.
  • 적절한 모델을 개발하기 위해 시퀀스 모델링의 종류를 이해할 필요가 있다. 아래 그림은 각기 다른 종류의 입력과 출력 데이터에 대한 관계를 보여준다.

  • 어떤 입력 데이터와 출력 데이터가 있다고 가정하자. 입력과 출력 데이터가 시퀀스로 표현되지 않는다면 일반 데이터로 처리한다.
    • 이런 데이터를 모델링하려면 MLP나 CNN 같은 방법 중 하나를 사용할 수 있다.
  • 만일 입력이나 출력이 시퀀스라면 데이터는 다음 세 가지 중 하나로 구성된다.
    • 다대일(many-to-one): 입력 데이터가 시퀀스이다. 출력은 시퀀스가 아니라 고정 크기의 벡터이다. 예컨대 감성 분석에서 입력은 텍스트고 출력은 클래스 레이블이다.
    • 일대다(one-to-many): 입력 데이터가 시퀀스가 아니라 일반적인 형태이다. 출력은 시퀀스이다. 이런 종류의 예로는 이미지 캡셔닝이 있다. 입력이 이미지고 출력은 영어 문장이다.
    • 다대다(many-to-many): 입력과 출력 배열이 모두 시퀀스이다. 이런 종류는 입력과 출력이 동기적인지 아닌지에 따라 더 나눌 수 있다. 동기적인 다대다 모델링의 작업의 에는 각 프레임이 레이블되어 있는 비디오 분류이다. 그렇지 않은 다대다 모델의 예는 한 언어에서 다른 언어로 번역하는 작업이다.

시퀀스 모델링을 위한 RNN

RNN 구조와 데이터 흐름 이해

  • RNN 구조를 소개하겠다. 아래 그림에 비교를 위해 기본 피드포워드 신경망과 RNN을 나란히 놓았다.

  • 두 네트워크 모두 하나의 은닉층만 있다. 위 그림에서는 유닛을 표시하지 않았다.
    • 입력층(x ), 은닉층(h ), 출력층(y ) 모두 벡터고 여러 개의 유닛이 있다고 가정한다.
  • 기본 피드포워드 네트워크에서 정보는 입력에서 은닉층으로 흐른 후 은닉층에서 출력층으로 전달된다.
    • 반면 순환 네트워크에서는 은닉층이 입력층과 이전 타임 스텝(time step)의 은닉층으로부터 정보를 받는다.
    • 인접합 타임 스텝의 정보가 은닉층에 흐르기 때문에 네트워크가 이전 이벤트를 기억할 수 있다.
    • 이런 정보 흐름을 보통 루프(loop)로 표시한다. 그래프 표기법에서는 순환 에지(recurrent edge)라고도 하기 때문에 이 구조 이름이 여기서 유래되었다.
    • 아래 그림은 하나의 은닉층을 가진 순환 네트워크와 다층 순환 네트워크를 비교하여 보여준다.

  • RNN 구조와 정보 흐름을 설명하게 위해 순환 에지를 위 그림과 같이 펼쳐서 나타낼 수 있다.
  • 표준 신경망의 은닉 유닛은 입력층에 연결된 최종 입력 하나만 받는다. 반면 RNN의 은닉 유닛은 두 개의 다른 입력을 받는다.
    • 입력층으로부터 받은 입력과 같은 은닉층에서 t - 1 타임 스텝의 활성화 출력을 받는다.
    • 맨 처음 t = 0 에서는 은닉 유닛이 0 또는 작은 난수로 초기화 된다.
    • t > 0 인 타임 스텝에서는 은닉 유닛이 현재 타입 스텝의 데이터 포인트 x^{(t)} 와 이전 타입 스텝 t-1 의 은닉 유닛 값 h^{(t-1)} 을 입력으로 받는다.
  • 비슷하게 다층 RNN의 정보 흐름을 다음과 같이 요약할 수 있다.
    • layer=1: 은닉층의 출력을 h_{1}^{(t)} 로 표현한다. 데이터 포인트 x^{(t)} 와 이 은닉층의 이전 타입 스텝 출력 h_{1}^{(t-1)} 을 입력으로 받는다.
    • layer=2: 두 번째 은닉층의 h_{2}^{(t)} 는 이전 층의 현재 타임 스텝 출력 h_{1}^{(t)} 와 이 은닉층의이전 타임 스텝 출력 h_{2}^{(t-1)} 을 입력으로 받는다.

RNN의 활성화 출력 계산

  • RNN의 구조와 일반적인 정보 흐름을 이해했으므로 구체적으로 운닉층과 출력층의 실제 활성화 출력을 계산해 보겠다.
    • 간소하게 나타내기 위해 하나의 은닉층만 고려하지만 다층 RNN에도 동일한 개념이 적용된다.
  • 그림 16-4에서 유향 에지(directed edge)는 가중치 행렬과 연관된다. 이 가중치는 특정 시간 t 에 종속적이지 않고 전체 시간 축에 공유된다.
  • 단일층 RNN의 각 가중치는 다음과 같다.
    • W_{xh} : 입력 x^{(t)} 와 은닉층 h 사이의 가중치 행렬
    • W_{hh} : 순환 에지에 연관된 가중치 행렬
    • W_{hy} : 은닉층과 출력층 사이의 가중치 행렬
  • 아래 그림에 이 가중치를 나타냈다.

  • 구현에 따라 가중치 행렬 W_{xh} W_{hh} 를 합쳐 연결된 행렬 W_{h} = [W_{xh};W_{hh}] 를 사용한다. 나중에 이런 방식을 사용해 보겠다.
  • 활성화 출력의 계산은 기본적인 다층 퍼셉트론이나 다른 피드포워드 신경망과 매우 비슷하다.
    • 은닉층의 최종 입력 z_{h} (활성화 함수를 통과하기 전의 값)는 선형 조합으로 계산한다.
    • 즉, 가중치 행렬과 대응되는 벡터를 곱해서 더한 후 절편 유닛을 더한다. (z_{h}^{(t)} = W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h} )
    • 그 다음 타입 스텝 t 에서 은닉층의 활성화를 계산한다.

h^{(t)} = \phi_{h} (z_{h}^{(t)}) = \phi_{h} (W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h})

  • 여기서 b_{h} 은 은닉 유닛의 절편 벡터이고 \phi_{h}(\cdot) 는 은닉층의 활성화 함수이다.
  • 가중치 행렬을 W_{h} = [W_{xh};W_{hh}] 처럼 연결하면 은닉 유닛의 계산 공식은 다음과 같이 바뀐다.

h^{(t)} = \phi_{h}( [W_{xh};W_{hh}] \ \left[ \begin{array}{rr} x^{(t)} \\ h^{(t-1)} \end{array} \right] + b_{h} )

  • 현재 타임 스텝에서 은닉 유닛의 활성화 출력을 계산한 후 출력 유닛의 활성화를 다음과 같이 계산한다.

y^{(t)} = \phi_{y} (W_{hy} h^{(t)} + b_{y})

  • 이해를 돕기 위해 아래 그림에 두 공식으로 활성화 출력을 계산하는 과정을 나타냈다.

긴 시퀀스 학습의 어려움

  • 앞서 노트에서 간략히 소개한 BPTT(BackPropagation Through Time)는 새로운 도전 과제가 되었다.
  • 손실 함수의 그래디언트를 계산할 때 곱셈 항인 {\partial h^{(t)} \over \partial h^{(k)}} 때문에 소위 그래디언트 폭주(exploding gradient) 또는 그래디언트 소실(vanishing gradient) 문제가 발생한다.
    • 이 문제를 아래 그림에서 하나의 은닉 유닛이 있는 예를 들어서 설명하겠다.

  • {\partial h^{(t)} \over \partial h^{(k)}} t - k 개의 곱셈으로 이루어진다. 즉, 가중치 w t-k 번 곱해져 w_{t-k} 가 된다.
    • 결국 |w| < 1 이면 t-k 가 클 때 이 항이 매우 작아진다.
    • 반면 순환 에지의 가중치 값이 |w|>1 이면 t-k 가 클 때 w_{t-k} 가 매우 커진다.
    • t-k 값이 크다는 것은 긴 시간 의존성을 가진다는 의미이다.
  • 그래디언트 소실이나 폭주를 피하는 간단한 해결책은 |w| = 1 이 되도록 만드는 것이다. 자세한 정보는 관련 논문을 참고하고 실전에서 이 문제의 해결책은 다음과 같다.
    • T-BPTT(Truncated BackPropagation Through Time)
    • LSTM(Long Short-Term Memory)
  • T-BPTT는 주어진 타임 스텝 너머의 그래디언트를 버린다. T-BPTT가 그래디언트 폭주 문제를 해결할 수 있지만 그래디언트가 시간을 거슬러 적절하게 가중치가 업데이트 될 수 있는 타임 스텝을 제한한다.
  • 다른 방법으로 1997년 호크라이더(Hochreiter)와 슈미트후버(Schmidhuber)가 고안한 LSTM은 그래디언트 소실 문제를 극복하여 긴 시퀀스를 성공적으로 모델링할 수 있게 되었다.

LSTM 유닛

  • LSTM은 그래디언트 소실 문제를 극복하기 위해 처음 소개되었다. LSTM의 기본 구성요소는 은닉층을 의미하는 메모리 셀(memory cell)이다.
  • 이전에 언급 했듯이 그래디언트 소실과 폭주 문제를 극복하기 위해 메모리 셀에 적절한 가중치 w = 1 를 유지하는 순환 에지가 있다. 이 순환 에지의 출력을 셀 상태(cell state)라고 한다.
    • 자세한 LSTM 구조가 아래 그림에 나타나 있다.

  • 이전 타임 스텝의 셀 상태 C^{(t-1)} 은 어떤 가중치와도 직접 곱해지지 않고 변경되어 현재 타임 스텝의 셀 상태 C^{(t)} 를 얻는다.
  • 메모리 셀의 정보 흐름은 다음에 기술된 몇 개의 연산으로 제어된다.
    • 위 그림에서 \odot 는 원소별 곱셈(element-wise multiplication), \oplus 는 원소별 덧셈(element-wise addition)을 나타낸다.
    • x^{(t)} 는 타임 스텝 t 에서 입력 데이터고 h^{(t-1)} 는 타임 스텝 t-1 에서 은닉 유닛의 출력이다.
    • 네 개의 상자는 시그모이드 함수(\sigma )나 하이퍼볼릭 탄젠트(tanh) 활성화 함수와 일련의 가중치로 표시된다.
    • 이 상자는 입력에 대해 행렬-벡터 곱셈을 수행한 후 선형 조합된다.
    • 시그모이드 함수로 계산하는 유닛을 게이트(gate)라고 하며 \odot 을 통해 출력된다.
  • LSTM 셀에는 세 종류의 게이트가 있다. 삭제 게이트(forget gate), 입력 게이트(input gate), 출력 게이트(output gate)이다.
  • 삭제 게이트(f_{t} )는 메모리 셀이 무한정 성장하지 않도록 셀 상태를 다시 설정한다.
    • 사실 삭제 게이트가 통과할 정보와 억제할 정보를 결정한다.
    • f_{t} 는 다음과 같이 계산된다.

f_{t} = \sigma (W_{xf} x^{(t)} + W_{hf} h^{(t-1)} + b_{f})

  • 삭제 게이트는 원본 LSTM 셀에 포함되어 있지 않았다. 초기 모델을 향상시키기 위해 몇 년 후에 추가되었다.
  • 입력 게이트(i_{t} )와 입력 노드(g_{t} )는 셀 상태를 업데이트하는 역할을 담당하며 다음과 같이 계산한다.

i_{t} = \sigma (W_{xi} x^{(t)} + W_{hi} h^{(t-1)} + b_{i})

g_{t} = tanh(W_{xg} x^{(t)} + W_{hg} h^{(t-1)} + b_{g})

  • 타임 스텝 t 에서 셀 상태는 다음과 같이 계산한다.

C^{(t)} = (C^{(t-1)} \odot f_{t}) \oplus (i_{t} \odot g_{t})

  • 출력 게이트 (o_{t} )는 은닉 유닛의 출력 값을 업데이트 한다.

o_{t} = \sigma (W_{xo} x^{(t)} + W_{ho} h^{(t-1)} + b_{o})

  • 이를 가지고 현재 타임 스텝에서 은닉 유닛의 출력을 다음과 같이 계산한다.

h^{(t)} = o_{t} \odot tanh(C^{(t)})

  • LSTM 셀의 구조와 연산이 매우 복잡해 보일 수 있다. 다행히 텐서플로의 tf.keras API에 래퍼 함수로 이미 모두 구현되어 있어서 간단하게 LSTM 셀을 정의할 수 있다.

텐서플로의 tf.keras API로 시퀀스 모델링을 위한 다층 RNN 구현

  • RNN 이론을 소개했으므로 tf.keras API를 사용하여 RNN을 구현하는 구체적인 단계로 넘어가 보겠다. 이 장 나머지에서 두 개의 문제에 RNN을 적용하겠다.
    • 감성 분석
    • 언어 모델링

첫 번째 프로젝트: 다층 RNN으로 IMDb 영화 리뷰의 감성 분석 수행

데이터 준비

  • 8장의 전처리 단계에서 만든 정제된 데이터셋인 movie_data.csv 파일을 다시 사용하겠다.
import pyprind
import pandas as pd
from string import punctuation
import re
import numpy as np

df = pd.read_csv('movie_data.csv', encoding='utf-8')
  • 데이터프레임 df에는 ‘review’와 ‘sentiment’ 두 개의 컬럼이 있다.
    • ‘review’ 에는 영화 리뷰 텍스트가 담겨 있고 ‘sentiment’에는 0 또는 1 레이블이 들어 있다. 영화 리뷰 텍스트는 단어의 시퀀스이다.
    • RNN 모델을 만들어서 시퀀스 단어를 처리하고 마지막에 전체 시퀀스를 0 또는 1 클래스로 분류해보자.
  • 신경망에 주입할 입력 데이터를 준비하기 위해 텍스트를 정수 값으로 인코딩해야 한다. 이를 위해 전체 데이터셋에서 고유한 단어를 먼저 찾아야 한다.
    • 파이썬의 set를 사용할 수 있지만, 대규모 데이터셋에서 고유한 단어를 찾는데 집합을 사용하는 것은 효율적이지 않으므로 collection 패키지에 있는 Counter를 사용하자.
  • 아래 코드에서 Counter 클래스의 counts 객체를 정의하고 텍스트에 있는 모든 고유한 단어의 등장 횟수를 수집한다.
    • 특히 이 애플리케이션은 (BoW(Bag-ofWord) 모델과 달리) 고유한 단어의 집합만 고나심 대상이고 부수적으로 생성된 단어 카운트는 필요하지 않다.
    • 그 다음 데이터셋의 고유한 단어를 정수 숫자로 매핑한 딕셔너리를 만든다. 이 word_to_int 딕셔너리를 이용하여 전체 텍스트를 정수 리스트로 변환하겠다.
    • 고유한 단어가 카운트 순으로 정렬되어 있지만 순서는 최종 결과에 영향을 미치지 않는다.
from collections import Counter

counts = Counter()
pbar = pyprind.ProgBar(len(df['review']), title='단어의 등장 횟수를 카운트 한다')

for i, review in enumerate(df['review']):
    text = ''.join([c if c not in punctuation else ' ' + c + ' ' for c in review]).lower()
   df.loc[i, 'review'] = text
    pbar.update()
    counts.update(text.split())

word_counts = sorted(counts, key=counts.get, reverse=True)
print(word_counts[:5])

word_to_int = {word: ii for ii, word in enumerate(word_counts, 1)}

mapped_reviews = []
pbar = pyprind.ProgBar(len(df['review']), title='리뷰를 정수로 매핑합니다')

for review in df['review']:
    mapped_reviews.append([word_to_int[word] for word in review.split()])
    pbar.update()
  • 단어 시퀀스를 정수 시퀀스로 변환했지만 한 가지 풀어야 할 문제가 있다. 이 시퀀스들은 길이가 서로 다르다. RNN 구조에 맞게 입력 데이터를 생성하려면 모든 시퀀스가 동일한 길이를 가져야 한다.
    • 이를 위해 sequence_length 파라미터를 정의하고 200으로 값을 설정한다.
    • 200개의 단어보다 적은 시퀀스는 왼쪽에 0으로 패딩된다. 반대로 200개의 단어보다 긴 시퀀스는 마지막 200개의 단어만 사용하도록 잘라낸다.
    • 두 단계로 전처리 과정을 구현하면 다음과 같다.
      1. 행 길이가 시퀀스 크기 200에 해당하는 행렬을 만들고 0으로 채운다.
      2. 행렬 오른쪽부터 시퀀스의 단어 인덱스를 채운다. 시퀀스 길이가 150이면 이 행의 처음 50개 원소는 0으로 남는다.
    • 이 두 단계를 그림으로 나타내면 아래와 같다.

  • 사실 sequence_length는 하이퍼파라미터이므로 최적의 성능을 위해 튜닝해야 한다.
    • 여기서는 지면 관계상 생략했지만 sequence_length를 바꾸어보며 시도해 볼 것.
  • 코드는 아래와 같다.
sequence_length = 200
sequences = np.zeros((len(mapped_reviews), sequence_length), dtype=int)

for i, row in enumerate(mapped_reviews):
    review_arr = np.array(row)
    sequences[i, -len(row):] = review_arr[-sequence_length:]
  • 데이터셋을 전처리한 후 데이터를 훈련 세트와 테스트 세트로 나눈다. 이미 무작위로 섞여 있기 때문에 75%를 훈련 세트로, 25%를 테스트 세트로 사용한다.
    • 훈련 세트 중 일부를 모델의 fit 메서드를 호출할 때 검증 세트로 지정하겠다.
X_train = sequences[:37500, :]
y_train = df.loc[:37499, 'sentiment'].values

X_test = sequences[37500:, :]
y_test = df.loc[37500:, 'sentiment'].values

임베딩

  • 이전의 데이터 준비 단계에서 동일한 길이의 시퀀스를 생성했다. 이 시퀀스의 원소는 고유한 단어의 인덱스에 해당하는 정수 숫자이다.
    • 이런 단어 인덱스를 입력 특성을 변환하는 몇 가지 방법이 있다. 간단하게 원-핫 인코딩을 적용하여 인덱스를 0 또는 1로 이루어진 벡터로 변환할 수 있다.
    • 각 단어는 전체 데이터셋의 고유한 단어의 수에 해당하는 크기를 가진 벡터로 변환된다. 고유한 단어의 수가 2만 개라면 입력 특성 개수는 2만개가 된다.
    • 이렇게 많은 특성에서 훈련된 모델은 차원의 저주(curse of dimensionality)로 인한 영향을 받는다.
    • 또 하나를 제외하고 모든 원소가 0이므로 특성 벡터가 매우 희소해진다.
  • 좀 더 고급스러운 방법은 각 단어를 실수 값을 가진 고정된 길이의 벡터로 변환하는 것이다. 원-핫 인코딩과 달리 고정된 길이의 벡터를 사용하여 무한히 많은 실수를 표현할 수 있다.
  • 임베딩(embedding)이라고 하는 특성 학습 기법을 사용하여 데이터셋에 있는 단어를 표현하는데 중요한 특성을 자동으로 학습할 수 있다.
    • 고유한 단어의 수를 unique_words 라고 하면 고유 단어의 수보다 훨씬 작게(embedding_size << unique_words) 임베딩 벡터 크기를 선택하여 전체 어휘를 입력 특성으로 나타낸다.
  • 원-핫 인코딩에 비해 임베딩의 장점은 다음과 같다.
    1. 특성 공간의 차원이 축소되므로 차원의 저주로 인한 영향을 감소 시킨다.
    2. 신경망에서 임베딩 층이 훈련되기 때문에 중요한 특성이 추출된다.
  • 아래 그림은 임베딩이 어휘 사전의 인덱스를 어떻게 훈련 가능한 임베딩 행렬로 매핑하는지 보여준다.

  • 텐서플로에는 고유한 단어에 해당하는 정수 인덱스를 훈련 가능한 임베딩 행렬의 행으로 매핑해 주는 tf.keras.layers.Embedding 클래스가 구현되어 있다.
    • 예컨대 정수 1이 첫 번째 행으로 매핑되고 정수 2는 두 번째 행에 매핑되는 식이다.
    • <0, 5, 3, 4, 19, 2, … > 처럼 정수 시퀀스가 주어지면 시퀀스의 각 원소에 해당하는 행을 찾는다.
  • 실제로 임베딩 층을 어떻게 만드는지 알아보자. Sequential 모델을 만들고 [n_words x embedding_size] 크기의 Embedding 층을 추가하면 된다.
from tensorflow.keras import models, layers

model = models.Sequential()
model.add(layers.Embedding(n_words, 200, embeddings_regularizer='l2'))
  • Embedding 클래스의 첫 번째 매개변수는 입력 차원으로 어휘 사전의 크기가 된다. 앞서 word_to_int 크기에 1을 더해 n_words를 구했다.
    • 두 번째 매개변수는 출력 차원이다. 여기서는 200차원의 벡터로 단어를 임베딩한다.
    • 다른 층과 마찬가지로 임베딩 층도 가중치를 규제할 수 있는 매개변수를 지원한다. 이 예제에서는 L2 규제를 추가했다. 가중치 초기화는 기본적으로 균등 분포를 사용한다.
    • embeddings_initializer 매개변수에서 다른 초기화 방법을 지정할 수 있다.
  • 임베딩 층을 추가한 후에 summary 메서드로 모델 구조를 출력해 보자.
model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# embedding (Embedding) (None, None, 200) 20593400
# =================================================================
# Total params: 20,593,400
# Trainable params: 20,593,400
# Non-trainable params: 0
  • 임베딩 층의 출력은 3차원 텐서이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 타임 스텝이다. 마지막 차원이 임베딩 벡터의 차원이다.
    • 앞서 n_words 크기가 102,967이었으므로 200차원을 곱하면 전체 모델 파라미터 개수는 20,593,400이 된다.

RNN 모델 만들기

  • 이제 본격적으로 RNN 층을 추가할 차례다. 여기서는 긴 시퀀스를 학습하는데 유리한 tf.keras.layers.LSTM 층을 사용하겠다. 이 LSTM 층은 16개의 순환 유닛을 사용한다.
model.add(layers.LSTM(16))
  • LSTM 층의 첫 번째 매개변수는 유닛 개수이다. 나머지 매개변수는 모두 기본값을 사용한다.
    • 몇 가지 언급할 만한 매개변수가 있는데, activation 매개변수는 히든 상태(층의 출력)에 사용할 활성화 함수를 지정한다. 기본 값은 ‘tanh’이다.
    • recurrent_activation은 셀 상태에 사용할 활성화 함수를 지정한다. 기본값은 ‘hard_sigmoid’이다.
  • 순환층에도 드롭아웃을 추가할 수 있다. dropout 매개변수는 히든 상태를 위한 드롭아웃 비율을 지정하며, recurrent_dropout은 셀 상태를 위한 드롭아웃 비율을 지정한다.
    • 기본값은 0이다.
  • 기본적으로 순환층은 마지막 타임 스텝의 히든 상태만 출력한다. 이는 마지막 출력 값을 사용하여 모델을 평가하는데 사용하기 때문이다.
    • 만약 두 개 이상의 순환층을 쌓는다면 아래층에서 만든 모든 스텝의 출력이 위층 입력으로 전달되어야 한다. 이렇게 하려면 return_sequences 매개변수를 True로 지정해야 한다.
  • 순환층을 추가한 후에는 출력층에 연결하기 위해 펼쳐야 한다. 앞서 합성곱 신경망에서 보았던 것과 유사하다.
    • 감성 분석은 긍정 또는 부정 리뷰를 판단하는 것이므로 출력층의 유닛은 하나이고, 활성화 함수는 시그모이드 함수를 사용한다.
model.add(layers.Flatten())
model.add(layers.Dense(1, activation='sigmoid'))
  • 순환 신경망 모델이 만들어졌다. 완전 연결 신경망이나 합성곱 신경망 보다 어렵지 않다. 전체 모델 구조를 살펴보자.
model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# embedding (Embedding) (None, None, 200) 20593400
# _________________________________________________________________
# lstm (LSTM) (None, 16) 13888
# _________________________________________________________________
# flatten (Flatten) (None, 16) 0
# _________________________________________________________________
# dense (Dense) (None, 1) 17
# =================================================================
# Total params: 20,607,305
# Trainable params: 20,607,305
# Non-trainable params: 0
  • LSTM 층의 출력 크기는 (None, 16)이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 셀의 출력(유닛 개수) 차원이다. LSTM 층이 가지는 모델 파라미터 개수는 13,888개 이다.
  • 좀 더 자세히 분석해 보자. 먼저 삭제 게이트(f_{t} )에 필요한 모델 파라미터를 계산해 보자.
    • 임베딩된 입력 벡터와 곱해지는 W_{xf} 는 (16, 200) 차원이고 이전 셀의 히든 상태와 곱해지는 W_{hf} 는 (16, 16) 차원이다. 마지막으로 절편은 유닛마다 하나씩 있으므로 b_{f} 는 (16,) 차원이다. 이를 모두 더하면 16 x 200 + 16 x 16 + 16 = 2,472 개이다.
    • LSTM 층에는 삭제 게이트와 같은 계산이 세 개 더 있다. i_{t}, g_{t}, o_{t} 이다. 이들 모두 동일한 차원의 가중치 두 개와 절편을 가진다. 따라서 LSTM 층에 있는 전체 모델 파라미터는 3,472 x 4 = 13,888개가 된다.
    • 마지막 Dense 층은 16개의 입력을 처리하기 위한 가중치와 절편을 합쳐서 17개의 모델 파라미터가 있다.

감성 분석 RNN 모델 훈련

  • 모델 구성을 완료 했으므로 Adam 옵티마이저를 사용하여 모델을 컴파일해 보자. 감성 분석 문제는 이진 분류 문제이므로 손실 함수는 binary_crossentropy로 지정한다.
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc'])
  • 합성곱 신경망에서 했던 ㄱ서처럼 가장 좋은 검증 점수의 모델 파라미터를 체크포인트로 저장하고 텐서보드를 취한 출력을 지정하겠다.
import time
from tensorflow.keras.callbacks import ModelCheckpoint, TensorBoard

callback_list = [ModelCheckpoint(filepath='sentiment_rnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard(log_dir="sentiment_rnn_logs/{}".format(time.asctime()))]

# 역시나 log_dir 폴더가 안 만들어져서 그냥 TensorBoard()만 사용
# callback_list = [ModelCheckpoint(filepath='sentiment_rnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard()]
  • 이제 모델을 훈련할 단계이다. 배치 크기는 64로 지정하고 열 번 에포크 동안 훈련하겠다.
    • validation_split을 0.3으로 지정하여 전체 훈련 세트의 30%를 검증 세트로 사용한다.
history = model.fit(X_train, y_train, batch_size=64, epochs=10, validation_split=0.3, callbacks=callback_list)
  • fit 메서드에서 반환된 history 객체에서 손실과 정확도를 추출하여 그래프로 그려보자. 먼저 손실 점수에 대한 그래프이다.
import matplotlib.pyplot as plt

epochs = np.arange(1, 11)

plt.plot(epochs, history.history['loss'])
plt.plot(epochs, history.history['val_loss'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 정확도 그래프를 그려보자.
epochs = np.arange(1, 11)

plt.plot(epochs, history.history['acc'])
plt.plot(epochs, history.history['val_acc'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 출력 결과를 보면 2번째 에포크만에 손실이 크게 감소하고 정확도가 상승한 것을 볼 수 있다.
    • 그 이후에는 훈련 정확도와 간격을 두며 검증 정확도가 조금씩 상승하고 있다. 이런 효과는 임베딩 층에 L2 규제를 추가했기 때문이다.
    • 임베딩 층에 규제가 없다면 훈련 세트에 금방 과대적합될 것이다.

감성 분석 RNN 모델 평가

  • 훈련 과정에서 만들어진 최상의 체크포인트 파일을 복원하여 테스트 세트에서 성능을 평가해보자.
    • 체크 포인트를 복원 하려면 모델의 load_weights 메서드를 사용하면 된다.
model.load_weights('sentiment_rnn_checkpoint.h5')
model.evaluate(X_test, y_test)

### 결과
# ...======] - 27s 2ms/sample - loss: 0.4732 - acc: 0.8777
  • evaluate 메서드는 기본적으로 손실 점수를 반환한다. 만약 compile 메서드의 metrics 매개변수에 측정 지표를 추가했다면 반환되는 값이 늘어난다.
    • 앞서 반환된 결과의 첫 번째 원소는 손실 점수고, 두 번째는 정확도 이다.
    • LSTM 층 하나로 테스트 세트에서 87% 정도의 정확도를 달성했는데, 8장에서 얻은 테스트 정확도와 비교할 만하다.
  • 샘플의 감성 분석 결과를 출력하려면 predict 메서드를 사용한다.
    • 이전 장에서 보았듯이 predict 메서드는 확률 값을 반환한다. 감성 분석 예제는 이진 분류 문제이므로 양성 클래스, 즉 긍정 리뷰일 확률을 반환한다.
    • Sequential 클래스는 predict 메서드와 동일하게 확률을 반환하는 predict_proba 메서드를 제공한다.
    • 의도를 분명하게 하기 위해 predict_proba 메서드로 테스트 샘플 열 개의 확률을 출력해 보자.
print(model.predict_proba(X_test[:10]))

### 결과
# [[0.00631011]
# [0.00777742]
# [0.00151676]
# [0.95133054]
# [0.99530613]
# [0.9786582 ]
# [0.00557807]
# [0.8497387 ]
# [0.00201363]
# [0.5879719 ]]
  • 다중 분류에서는 가장 큰 확률의 레이블이 예측 클래스가 되고 이진 분류 문제에서는 0.5보다 크면 양성 클래스가 된다.
    • 간단하게 0.5보다 큰 값을 구분할 수 있지만 Sequential 클래스는 친절하게 이를 위한 predict_classes 메서드도 제공한다.
print(model.predict_classes(X_test[:10]))

### 결과
# [[0]
# [0]
# [0]
# [1]
# [1]
# [1]
# [0]
# [1]
# [0]
# [1]]
  • 최적화를 위해 LSTM 층의 유닛 개수, 타임 스텝의 길이, 임베딩 크기 같은 모델의 하이퍼파라미터를 튜닝하면 더 높은 일반화 성능을 얻을 수 있다.
    • 6장에서 설명한 것처럼 테스트 데이터를 사용하여 편향되지 않은 성능을 얻으려면 평가를 위해 테스트 세트를 반복적으로 사용하면 안된다는 점에 주의하라.

두 번째 프로젝트: 텐서플로로 글자 단위 언어 모델 구현

  • 언어 모델링(language modeling)은 영어 문장 생성처럼 기계가 사람의 언어와 관련된 작업을 수행하도록 만드는 흥미로운 애플리케이션이다.
    • 이 분야에서 관심을 끄는 결과물 중 하나는 서스키버(Sutskever), 마틴(Martens), 힌튼(Hinton)의 작업이다.
  • 앞으로 만들 모델의 입력은 텍스트 문장이다. 목표는 입력 문서와 비슷한 새로운 텍스트를 생성하는 모델을 개발하는 것이다.
    • 입력 데이터는 책이나 특정 프로그래밍 언어로 쓰여진 컴퓨터 프로그램일 수 있다.
  • 글자 단위 언어 모델링에서 입력은 글자의 시퀀스로 나뉘어 한 번에 글자 하나씩 네트워크에 주입된다.
    • 이 네트워크는 지금까지 본 글자와 함께 새로운 글자를 처리하여 다음 글자를 예측한다.
    • 아래 그림은 글자 단위 언어 모델링의 예이다.

  • 데이터 전처리, RNN 모델 구성, 다음 글자를 예측하고 새로운 텍스트를 생성하는 세 개의 단계로 나누어 구현하겠다.
  • 이 장의 서두에서 그래디언트 폭주 문제를 언급했는데, 이 애플리케이션에서 그래디언트 폭주 문제를 피하기 위해 그래디언트 클리핑 기법을 사용해 보겠다.

데이터 전처리

  • 글자 수준의 언어 모델링을 위한 데이터를 준비하자.
    • 수천 권의 무료 전자책을 제공하는 구텐베르크 프로젝트 웹사이트에서 입력 데이터를 구하겠다. 이 예에서는 셰익스피어의 햄릭 텍스트를 사용하겠다. (http://www.gutenberg.org/cache/epub/2265/pg2265.txt)
  • 데이터가 준비되면 파이썬에서 일반 텍스트로 읽는다.
    • 다음 코드에서 파이썬 매개변수 chars는 이 텍스트에 있는 고유한 글자 집합이다.
    • 그 다음 각 글자와 정수를 매핑한 딕셔너리 char2int와 거꾸로 정수와 글자를 매핑한 int2char 딕셔너리를 만든다.
    • char2int 딕셔너리를 사용하여 텍스트를 넘파이 정수 배열로 변환한다.
    • 아래 그림은 변환 예시이다.

import numpy as np

with open('pg2265.txt', 'r', encoding='utf-8') as f:
    text = f.read()

text = text[15858:]
chars = set(text)
char2int = {ch:i for i, ch in enumerate(chars)}
int2char = dict(enumerate(chars))
text_ints = np.array([char2int[ch] for ch in text], dtype=np.int32)

print(len(text))
print(len(chars))

### 결과
# 163237
# 68
  • 이 텍스트를 char2int 딕셔너리를 사용하여 모두 정수로 바꾸어 text_ints 배열에 저장했다.
  • 데이터 전처리에서 가장 중요한 단계는 이 데이터를 시퀀스의 배치로 바꾸는 작업이다. 지금까지 본 글자 시퀀스를 기반으로 새로운 글자를 예측하는 것이 목적이다.
    • 따라서 신경망의 입력(x )과 출력(y )을 한 글자씩 이동한다.
    • 텍스트 데이터셋에서 데이터 배열 x y 를 생성하는 것부터 시작해서 아래 그림에 이 전처리 단계를 나타냈다.

  • 그림에서 볼 수 있듯이 훈련 배열 x y 는 동일한 크기 또는 차원을 가진다. 행 개수는 배치 크기와 같고, 열 개수는 배치 횟수 x 스텝 횟수이다.
  • 텍스트 데이터의 글자를 표현한 정수 입력 배열 data가 주어지면 다음 함수는 위의 그림과 동일한 구조의 x y 를 만든다.
def reshape_data(sequence, batch_size, num_steps):
    mini_batch_length = batch_size * num_steps
    num_batches = int(len(sequence) / mini_batch_length)

   if num_batches * mini_batch_length + 1 > len(sequence):
       num_batches = num_batches - 1   

   x = sequence[0: num_batches * mini_batch_length]
    y = sequence[1: num_batches * mini_batch_length + 1]

   x_batch_splits = np.split(x, batch_size)
    y_batch_splits = np.split(y, batch_size)

    x = np.stack(x_batch_splits)
    y = np.stack(y_batch_splits)

    return x, y
  • 시퀀스 길이를 10으로 가정하고 reshape_data 함수를 사용하여 배치 크기 64에 맞게 데이터를 바꾸어 보자.
train_x, train_y = reshape_data(text_ints, 64, 10)

print(train_x.shape)
print(train_x[0, :10])
print(train_y[0, :10])
print(''.join(int2char[i] for i in train_x[0, :10]))
print(''.join(int2char[i] for i in train_y[0, :10]))

### 결과
# (64, 2550)
# [49 48 2 63 48 14 2 38 49 48]
# [48 2 63 48 14 2 38 49 48 40]
# e of more
# of more t
  • text_ints를 64개의 행을 가진 2차원 배열 train_x와 train_y로 바꾸었다. train_x 크기를 출력해 보면 배치 크기의 행이 만들어진 것을 확인할 수 있다.
    • train_x와 train_y를 정수 크기대로 출력하고 int2char 딕셔너리를 사용하여 문자로도 출력했다.
    • 출력 결과를 보면 train_y가 한 글자씩 밀려 있다는 것을 확인할 수 있다.
  • 다음 단계에서 배열 x y 를 나누어 열 길이가 스텝 횟수와 동일한 미니 배치를 만든다. 데이터 배열 x 를 나누는 과정이 아래 그림에 나와있다.

  • 다음 코드에서 위 그림에 나온 데이터 배열 x y 를 나누어 배치를 출력하는 create_batch_generator를 정의한다.
    • 차후에 이  제너레이터를 사용하여 네트워크를 훈련하는 동안 미니 배치를 반복하겠다.
def create_batch_generator(data_x, data_y, num_steps):
    batch_size, tot_batch_length = data_x.shape[0:2]
    num_batches = int(tot_batch_length/num_steps)

    for b in range(num_batches):
        yield (data_x[:, b * num_steps:(b+1) * num_steps], data_y[:, b * num_steps: (b+1) * num_steps])
  • 이 코드에서 정의한 제너레이터는 메모리 부족을 해결할 수 있는 좋은 기법이다.
    • 신경망을 훈련하는 동안 모든 데이터를 미리 나누어 메모리에 저장하지 않고 데이터셋을 미니 배치로 나누는 방식이 바람직하다.
  • train_x와 train_y 배열에서 길이 100까지만 사용하여 배치 데이터를 테스트로 만들어보겠다.
    • 시퀀스 길이는 15로 설정하고, 길이가 100이므로 제너레이터 함수는 길이 15인 시퀀스의 배치를 여섯 번 반환한다.
bgen = create_batch_generator(train_x[:, :100], train_y[:, :100], 15)

for x, y in bgen:
   print(x.shape, y.shape, end='  ')
    print(''.join(int2char[i] for i in x[0, :]).replace('\n', '*'), '  ', ''.join(int2char[i] for i in y[0, :]).replace('\n', '*'))

### 결과
# (64, 15) (64, 15) e of more than of more than 3
# (64, 15) (64, 15) 30 different*Fi 0 different*Fir
# (64, 15) (64, 15) rst Folio editi st Folio editio
# (64, 15) (64, 15) ons' best pages ns' best pages.
# (64, 15) (64, 15) .**If you find **If you find a
# (64, 15) (64, 15) any scanning er ny scanning err
  • 64개 배치 중 첫 번째 배치만 문자로 바꾸어 출력했다. 훈련 데이터와 타깃 데이터가 올바르게 추출되었다.
  • 실제 모델에 사용할 데이터를 만들기 위한 준비를 거의 마쳤다. 먼저 reshape 메서드를 사용하여 text_ints 배열을 64개의 배치 행을 가진 형태로 바꾼다.
batch_size = 64
num_steps = 100
train_x, train_y = reshape_data(text_ints, batch_size, num_steps)

print(train_x.shape, train_y.shape)

### 결과
# (64, 2500) (64, 2500)
  • 배치 크기를 64, 타임 스텝 길이를 100으로 설정했으므로 text_ints에서 자투리 부분은 제외하고 (64, 2500) 크기의 배열이 되었다.
  • 데이터 전처리의 마지막 단계는 이 데이터를 원-핫 인코딩으로 바꾸는 작업이다.
    • 이전 감성 분석 예제에서는 임베딩 층을 사용하여 단어를 길이가 200인 벡터로 인코딩했었다. 이때 타깃 데이터는 긍정 또는 부정 리뷰를 나타내는 1차원 배열이었다. 일련의 시퀀스를 처리한 후 손실 함수로부터 그래디언트를 계산했다.
    • 글자 단위 RNN 모델에서는 조금 다른 방식을 사용하는데, 모델에서 처리하는 글자마다 그래디언트를 모두 계산하여 사용한다. 이렇게 하려면 타깃 데이터도 전체 타임 스텝에 걸쳐 원-핫 인코딩 되어야 한다.
    • 텐서플로에서 제공하는 to_categorical 함수를 사용하여 원-핫 인코딩을 간단하게 만들어보자.
from tensorflow.keras.utils import to_categorical

train_encoded_x = to_categorical(train_x)
train_encoded_y = to_categorical(train_y)

print(train_encoded_x.shape, train_encoded_y.shape)

### 결과
# (64, 2500, 68) (64, 2500, 68)
  • to_categorical 함수는 입력된 데이터에서 가장 큰 값에 맞추어 자동으로 원-핫 인코딩된 벡터로 변환시킨다.
    • 정수 값이 0부터 시작한다고 가정하므로 원-핫 인코딩 벡터의 길이는 최댓값에 1을 더해야 한다.
    • 만약 train_x와 train_y에 있는 최댓값이 다르면 원-핫 인코딩 크기가 달라진다. 여기서는 train_y가 train_x에서 한 글자만 이동했기 때문에 최댓값이 같지만 문제에 따라 다를 수 있으므로 주의하라

글자 단위 RNN 모델 만들기

  • Sequential 클래스를 사용하여 글자 단위 RNN 모델을 만들어보겠다. 먼저 Sequential 클래스 객체를 생성한다.
from tensorflow.keras import models, layers

char_model = models.Sequential()
  • 훈련 데이터를 원-핫 인코딩 했으므로 임베딩 층 대신 LSTM 층을 바로 추가하겠다. 이때 두 가지를 고려해야 한다.
  • 첫째, 이 모델은 훈련할 때 길이가 100인 시퀀스를 주입한다. 즉, 타임 스텝 길이가 100이다. 하지만 새로운 글자를 생성할 때는 이전 글자를 주입하여 한 글자씩 생성한다. 다시 말해 샘플링 시에는 배치 크기가 1이 된다. 따라서 훈련과 샘플링 시에 배치 크기와 타임 스텝 크기가 다음과 같이 정의된다.

sampling mode = \begin{cases} batch size = 1 \\ num steps = 1 \end{cases}

training mode = \begin{cases} batch size = 64 \\ num steps = 100 \end{cases}

  • 훈련과 샘플링 모드에서 사용하는 시퀀스 길이가 다르다. 흔히 이런 RNN 네트워크의 구조를 ‘시간에 따라 동적으로 펼친다’라고도 한다.
    • 텐서플로의 케라스 API를 사용하면 가변 길이 시퀀스를 다루는 작업도 간단하게 처리할 수 있다.
  • 이전 장의 합성곱 모델에서 보았듯이 모델에 추가하는 첫 번째 층에는 input_shape 매개변수로 배치 차원을 제외한 입력 크기를 지정해야 한다.
    • LSTM 층에서 가변 길이 시퀀스를 처리하려면 타임 스텝 길이에 해당하는 input_shape의 첫 번째 차원을 None으로 지정하면 된다.
    • 두 번째 차원은 원-핫 인코딩 벡터의 크기가 된다.
  • 둘째, 모든 타임 스텝에 대해 그래디언트를 계산하여 모델을 업데이트할 것이다. 따라서 LSTM 층이 시퀀스의 마지막 타임 스텝의 출력만 반환하지 않고 전체 시퀀스에 대해 출력을 만들어야 한다.
    • 이렇게 하려면 앞서 언급한 대로 LSTM 층의 return_sequences 매개변수를 True로 지정해야 한다.
  • 이런 점을 고려하여 다음과 같이 128개의 순환 유닛을 가진 LSTM 층을 모델에 추가한다.
num_classes = len(chars)
char_model.add(layers.LSTM(128, input_shape=(None, num_classes), return_sequences=True))
  • 이 모델에 입력할 데이터는 num_classes 크기로 원-핫 인코딩 되었다는 것을 기억하라. num_classes는 텍스트에 있는 모든 글자 수이다.
  • 그 다음은 각 글자에 대한 확률을 출력하는 완전 연결 층을 추가한다. 이 출력층의 유닛 개수는 num_classes가 된다. 다중 출력이므로 활성화 함수는 소프트맥스 함수를 사용한다.
  • 지금까지는 Dense 층을 추가하기 전에 Flatten 층을 추가했다. 이 층은 배치 차원을 제외하고 입력 텐서의 나머지 차원을 일렬로 펼친다. Dense 층은 이렇게 전형적으로 2차원 텐서를 다룬다.
    • 하지만 이 예제에서는 모든 타임 스텝에 대한 손실을 계산해야 하기 때문에 LSTM 층에서 출력되는 3차원 텐서를 그대로 다루어야 한다.
    • LSTM 층에서 출력되는 텐서 크기는 (배치 개수, 타임 스텝 개수, 순환 유닛 개수)이다. Dense 층을 통과할 때 이 텐서츼 엇 번째와 두 번째 차원이 유지되어야 한다.
    • 이 작업을 처리하기 위해 Flatten 층을 추가하지 않고 LSTM 층의 출력을 타임 스텝 순으로 Dense 층에 주입하고 결과를 받아 다시 타임 스텝 순서대로 쌓아야 한다.
    • 이런 작업을 위한 클래스도 텐서플로에 이미 준비되어 있다. tf.keras.layers.TimeDistribute 클래스를 사용하면 Dense 층을 감싸서 타임 스텝을 가진 입력을 다룰 수 있다.
char_model.add(layers.TimeDistributed(layers.Dense(num_classes, activation='softmax')))
  • 전체 모델 구성이 끝났다. tf.keras API를 사용하면 간단하게 RNN 모델을 만들 수 있다.
    • summary 메서드로 구성된 네트워크를 출력해 보자.
char_model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# lstm (LSTM) (None, None, 128) 100864
# _________________________________________________________________
# time_distributed (TimeDistri (None, None, 68) 8772
# =================================================================
# Total params: 109,636
# Trainable params: 109,636
# Non-trainable params: 0
  • 가변 길이 시퀀스를 다루기 위해 LSTM 층과 TimeDistributed 층의 출력에서 두 번째 차원이 None으로 된 것을 볼 수 있다. 또 최종 출력에 타임 스텝 차원이 포함되었다.
  • 모델 구성을 마치면서 각 츠으이 모델 파라미터의 크기를 계산해 보자.
    • 순환 유닛이 128개이고 원-핫 인코딩의 크기가 65이므로 W_{xf} 는 (128, 65) 차원이다. 셀의 히든 상태와 곱해지는 W_{hf} 는 (128, 128)이다. 여기에 절편을 더하면 삭제 게이트에 필요한 모델 파라미터 개수는 128 x 65 + 128 x 128 + 128 = 24,832 개가 된다.
    • LSTM 층에는 이런 가중치가 네 벌 더 있으므로 전체 모델 파라미터 개수는 99,328개가 된다.
  • TimeDistributed 층은 모델 파라미터를 가지고 있지 않다. summary 메서드에서 출력한 값은 Dense 층의 모델 파라미터 개수이다.
    • Dense 층의 입력 차원은 128이고 65개의 유닛이 있으므로 절편을 고려한 전체 모델 파라미터 개수는 65 x 128 + 65 = 8,386개이다.

글자 단위 RNN 모델 훈련

  • 이전 예제에서는 옵티마이저의 기본값을 사용했다. 이 예제에서는 그래디언트 폭주를 피하기 위한 대표적인 방법인 그래디언트 클리핑을 적용해 보겠다.
    • 그래디언트 클리핑을 하려면 옵티마이저 클래스의 객체를 직접 만들어 모델의 compile 메서드에 전달해야 한다.
    • 앞선 예제와 같이 Adam 옵티마이저를 사용한다.
from tensorflow.keras.optimizers import Adam

adam = Adam(clipnorm=5.0)
  • tf.keras.optimizers에 있는 옵티마이저들은 그래디언트 클리핑을 위한 두 개의 매개변수를 제공한다. 하나는 L2 노름 임계 값을 지정하는 clipnorm이고 다른 하나는 절댓값으로 임계 값을 지정하는 clipvalue이다.
    • clipnorm 매개변수가 설정되면 그래디언트의 L2 노름이 clipnorm 보다 클 경우 다음과 같이 클리핑 된 그래디언트를 계산한다.
      • 클리핑된 그래디언트 = 그래디언트 * clipnorm / 그래디언트의 L2 노름
    • clipvalue 매개변수가 설정되면 -clipvalue 보다 작은 그래디언트는 -clipvalue가 되고 clipvalue 보다 큰 그래디언트는 clipvalue로 만든다.
  • 이 두 클리핑 방식을 동시에 사용할 수도 있다. 여기서는 clipnorm 매개변수만 사용했다.
    • 65개의 글자에 대한 확률을 출력하는 다중 클래스 모델이므로 손실 함수는 categorical_crossentropy를 사용한다.
    • 그 다음 옵티마이저 객체와 함께 char_model을 컴파일 한다.
char_model.compile(loss='categorical_crossentropy', optimizer=adam)
  • 훈련된 모델을 저장하여 나중에 학습을 이어 가거나 텍스트를 생성할 수 있도록 체크포인트 콜백을 준비한다.
from tensorflow.keras.callbacks import ModelCheckpoint

callback_list = [ModelCheckpoint(filepath='char_rnn_chckpoint.h5')]
  • 이제 500번의 에포크 동안 모델을 훈련하겠다. Sequential 모델은 입력과 타깃 배치를 반환하는 제너레이터와 함께 쓸 수 있는 fit_generator 메서드를 제공한다.
    • 앞서 만든 create_batch_generator 함수로부터 제너레이터 객체를 만들어 fit_generator 메서드에 전달하겠다.
    • file_generator 메서드는 파이썬 제너레이터에서 배치를 끝없이 반환할 것으로 기대한다. 데이터가 끝없이 생성되므로 하나의 에포크를 정의하기 위해 제너레이터로부터 몇 번이나 배치를 뽑을 것인지 알려주어야 한다.
    • fit_generator 메서드의 steps_per_epoch 매개변수에서 이를 설정한다.
  • 이 예제에서는 시퀀스 길이가 100이므로 전부 25번의 배치가 생성된다.
    • 사실 create_batch_generator 함수는 배치를 순환하지 않기 때문에 25번째 배치 이후에는 더는 추출하지 못하고 에러가 발생된다.
    • 이를 해결하기 위해 for 반복문에서 fit_generator 메서드를 호출할 때 epochs를 1로 설정한다.
    • 전체 훈련 횟수는 500번이고 훈련할 때마다 제너레이터를 다시 초기화 해야 한다.
for i in range(500):
    bgen = create_batch_generator(train_encoded_x, train_encoded_y, num_steps)
   char_model.fit_generator(bgen, steps_per_epoch=25, epochs=1, callbacks=callback_list, verbose=0)

### 결과
# 윈도우 설정 문제인지, 책 코드가 있는 git의 소스를 그대로 써도 에러가 나서 이 예제는 이후 결과 없이 종료
  • 반복 횟수가 많기 때문에 verbose 매개변수를 0으로 설정하여 훈련 과정을 출력하지 않았다.

글자 단위 RNN 모델로 텍스트 생성

  • 텍스트를 만들기 위해 앞서 설명한 것처럼 배치 크기 1, 타임 스텝 길이 1을 만들어 모델에 주입한다. 그 다음 예측된 문자를 다음번 예측을 하기 위해 다시 모델에 주입하는 과정을 반복한다.
  • 먼저 모델에서 출력된 65개의 확률 값에서 하나를 랜덤하게 선택할 get_top_char 함수를 정의하자. 이 함수는 전달된 확률을 정렬하고 numpy.random.choice 함수로 상위 다섯 개의 확률 중 하나를 랜덤하게 선택한다.
np.random.seed(42)

def get_top_char(probas, char_size, top_n=5):
    p = np.squeeze(probas)
   p[np.argsort(p)[:-top_n]] = 0.0
    p = p / np.sum(p)
    ch_id = np.random.choice(char_size, 1, p=p)[0]

    return ch_id
  • “The ” 란 초기 문자열을 사용하여 이어지는 텍스트를 생성하겠다. 텍스트를 생성하는 방법은 다음과 같다.
    • 먼저 모델에 문자열 “The “를 한 글자씩 주입하고 마지막 글자에서 다음 글자를 예측한다.
    • 그 다음 이글자를 사용하여 계속 다음 글자를 예측하는 식이다.
  • 모델에 주입할 데이터를 만드는 과정은 앞서 훈련 데이터에서 했던 것과 유사하다. 한 가지 주의할 점은 한 글자씩 인코딩 하기 때문에 to_categorical 함수를 호출할 때 num_classes 매개변수로 원-핫 인코딩될 벡터 크기를 지정해야 한다.
    • 배치 차원을 만들기 위해 넘파이 expand_dims 함수로 첫 번째 차원을 추가했다.
    • 만들어진 onehot 배열의 차원은 (1, 1, 65)이다.
seed_text = "The "

for ch in seed_text:
   num = [char2int[ch]]
   onehot = to_categorical(num, num_classes=65)
    onehot = np.expand_dims(onehot, axis=0)
    probas = char_model.predict(onehot)

num = get_top_char(probas, len(chars))
seed_text += int2char[num]
  • 초기 문자열 “The “를 모델에 차례대로 주입한 후 마지막에 얻은 probas 출력을 사용하여 다음 글자를 예측한다.
    • 이 값은 정수이기 때문에 int2char를 사용하여 문자로 바꾼 후 seed_text 문자열 끝에 추가한다.
  • 이제 예측한 문자열을 비슷한 과정으로 인코딩하여 다시 모델에 주입한다.
    • 반환된 클래스 확률 값을 사용하여 다시 다음 글자를 선택한다.
    • 이런 과정을 for 반복문을 사용하여 500번 되풀이 하여 긴 텍스트를 만들어 보자.
for i in range(500):
   onehot = to_categorical([num], num_classe=65)
    onehot = np.expand_dims(onehot, axis=0)
    probas = char_model.predict(onehot)
   num = get_top_char(probas, len(chars))
    seed_text += int2char[num]

print(seed_text)
  • 결과에서 볼 수 있듯이 일부 영어 단어는 거의 그대로 유지되었다. 이 예제는 오래된 영어 텍스트를 사용했으므로 원본 텍스트에는 낯선 단어가 일부 포함되어 있다.
  • 더 나은 결과를 얻으려면 에포크 수를 늘려서 모델을 훈련하거나 훨씬 더 큰 문서를 사용해도 좋다. np.random.choice 대신 확률 값의 크기에 따라 글자 선택 가능성을 높일 수도 있다.

머신 러닝 교과서/ 심층 합성곱 신경망으로 이미지 분류

합성곱 신경망의 구성 요소

  • 합성곱 신경망 또는 CNN은 뇌의 시각 피질이 물체를 인식할 때 동작하는 방식에서 영감을 얻은 모델이다.
    • CNN 개발은 1990년대로 거슬러 올라간다. 이 시기에 얀 르쿤(Yann LeCun)과 그의 동료들은 손글씨 숫자를 분류하는 새로운 신경망 구조를 발표했다.
    • 이미지 분류 작업에서 CNN이 탁월한 성능을 내기 때문에 크게 주목을 받았다. 이로 인해 머신 러닝과 컴퓨터 비전 애플리케이션에서 엄청난 발전을 이루었다.

CNN과 특성 계층 학습

  • 관련이 높은 핵심 특징을 올바르게 추출하는 것은 모든 머신 러닝 알고리즘의 성능에서 아주 중요한 요소이다.
    • 전통적인 머신 러닝 모델은 도메인 전문가가 만든 특성에 의존하거나 컴퓨터를 사용한 특성 추출 기법에 바탕을 두고 있다.
    • 신경망은 원본 데이터에서 작업에 가장 유용한 특성을 자동으로 학습한다. 이런 이유 때문에 신경망을 특성 추출 엔진으로 생각하기도 한다. 예컨대 입력에 가까운 층은 저수준 특성을 추출한다.
  • 다층 신경망과 특히 심층 합성곱 신경망은 각 층별로 저수준 특성을 연결하여 고수준 특성을 만듦으로써 소위 특성 계층을 구성한다.
    • 예컨대 이미지를 다룬다면 에지(edge)나 동그라미 같은 저수준 특성이 앞쪽 층에서 추출된다.
    • 이런 특성들이 연결되어 건물, 자동차, 강아지 같은 고수준 특성을 형성한다.
  • 아래 그림에서 보듯이 CNN은 입력 이미지에서 특성 맵(feature map)을 만든다. 이 맵의 각 원소는 입력 이미지의 국부적인 픽셀 패치에서 유도된다.

  • 이런 국부적인 픽셀 패치를 국부 수용장(local receptive field)라고 한다. CNN은 일반적으로 이미지 관련 작업을 매우 잘 수행한다. 이는 다음 두 개의 중요한 아이디어 때문이다.
    • 희소 연결: 특성 맵에 있는 하나의 우너소는 작은 픽셀 패치 하나에만 연결된다 (퍼셉트론 처럼 모든 입력 이미지에 있는 연결되는 것과 매우 다르다)
    • 파라미터 공유: 동일한 가중치가 입력 이미지의 모든 패치에 사용된다.
  • 이 두 아이디어 결과로 네트워크의 가중치(파라미터) 개수가 극적으로 감소하고 중요 특징을 잡아내는 능력이 향상된다. 당연히 가까이 있는 픽셀들이 멀리 있는 픽셀보다 연관성이 높다.
  • 일반적으로 CNN은 여러 개의 합성곱(conv) 층과 풀링(Pooling)이라고도 하는 서브샘플링(subsampling) 층으로 이루어져 있다. 마지막에는 하나 이상의 완전 연결(FC) 층이 따라온다.
    • 완전 연결 층은 모든 입력 유닛 i 가 모든 출력 유닛 j 에 가중치 w_{ij} 로 연결되어 있는 다층 퍼셉트론이다.
  • 풀링 층으로 알려진 서브샘플링 층은 학습되는 파라미터가 없다. 즉, 풀링 층에는 가중치나 절편 유닛이 없다. 합성곱이나 완전 연결 층은 가중치와 절편을 가진다.

이산 합성곱 수행

  • 이산 합성공(discrete convolution)(또는 간단히 합성곱)이 CNN의 기본 연산이다. 이 연산의 작동 원리를 아는 것이 아주 중요하다.
    • 여기서는 합성곱의 수학적 정의를 살펴보고 1차원 벡터나 2차원 행렬에서 합성곱을 계산하는 간단한 알고리즘을 설명하겠다.
    • 여기서는 합성곱 연산의 작동 원리를 이해하는 것이 목적이다. 텐서플로 패키지의 실제 합성곱 연산은 훨씬 효율적으로 구현되어 있다.

1차원 이산 합성곱 연산 수행

  • 앞으로 사용할 기본적인 정의와 기호를 설명하는 것부터 시작하겠다.
    • 두 개의 1차원 벡터 x w 에 대한 이산 합성곱은 y = x * w 로 나타낸다.
    • x 는 입력이고 (이따금 신호라고 부름) w 는 필터(filter) 또는 커널(kernel)이라 부른다.
    • 이산 합성곱의 수학적 정의는 아래와 같다.

y = x * w \to y[i] = \sum_{k=-\infty}^{+\infty} x[i-k] w[k]

  • 여기서 대괄호는 벡터 원소의 인덱스를 나타내는데 사용한다. 인덱스 i 는 출력 벡터 y 의 각 원소에 대응한다.
  • 위 공식에서 특이한 점이라면 -\infty 에서 +\infty 까지의 인덱스와 x 의 음수 인덱싱이다.
  • 인덱스 -\infty 에서 \infty 까지의 합은 특히 이상하게 보인다. 머신 러닝 애플리케이션은 항상 유한한 특성 벡터를 다루기 때문이다.
    • 예컨대 x 0, 1, 2, ... , 8, 9 인덱스로 열 개의 특성을 가지고 있다면 -\infty:-1 10:+\infty 인덱스는 x 의 범위 밖이다.
    • 이전 공식에 있는 덧셈을 올바르게 계산하려면 x w 가 0으로 채워져 있다고 가정해야 한다. 또 출력 벡터 y 도 0으로 채워진 무한 크기가 된다.
    • 이는 실제 상황에서는 유용하지 않기 때문에 유한한 개수의 0으로 x 가 패딩된다.
    • 이 과정을 제로 패딩(zero padding) 또는 패딩(padding)이라고 한다. 각 방향으로 추가된 패딩 수는 p 로 나타난다.
    • 1차원 벡터 x 의 패딩 예가 아래 그림에 나타나 있다.

  • 원본 입력 x 와 필터 w 가 각각 n m 개의 원소를 가지고 m \leq n 이라고 가정해 보자.
    • 패딩된 벡터 x^{p} 의 크기는 n + 2p 이다.
    • 이산 합성곱을 계산하기 위한 실제 공식은 다음과 같이 바뀐다.

y = x * w \to y[i] = \sum_{k=0}^{m-1} x^{p}[i+m-k] w[k]

  • 무한한 인덱스 문제를 해결했다. 둘째 이슈는 i+m-k x 를 인덱싱하는 것이다.
    • x w 가 이 식에서 다른 방향으로 인덱싱한다는 점이 중요하다.
    • 이 때문에 패딩된 후에 x 또는 w 벡터 중 하나를 뒤집어 간단히 점곱으로 계산할 수 있다.
  • 필터 w 를 뒤집어서 회전된 필터 w^{r} 을 얻었다고 가정해 보자.
    • 점곱 x[i:i+m] \cdot w^{r} 을 계산하면 y[i] 원소 하나가 얻어진다. x[i:i+m] 은 크기가 m x 의 패치이다.
    • 이 연산이 모든 출력 원소를 얻기 위해 슬라이딩 윈도우(sliding window) 방식으로 반복된다.
    • 아래 그림은 x = (3, 2, 1, 7, 1, 2, 5, 4) 이고 w = ({1 over 2}, {3 over 4}, 1, {1 over 4}) 일 때 처음 세 개의 출력 원소를 계산하는 경우를 보여준다.

  • 이 예에서 패딩 크기는 0이다. (p = 0 )
    • 회전된 필터 w^{r} 은 2칸씩 이동한다. 이동하는 양은 스트라이드(stride)라고 하며, 또 하나의 합성곱 하이퍼파라미터이다.  여기서 스트라이드는 2이다. (s = 2 )
    • 스트라이드는 입력 벡터의 크기보다 작은 양수 값이어야 한다.

합성곱에서 제로 패딩의 효과

  • 지금까지 유한한 크기의 출력 벡터를 얻기 위해 합성곱에 제로 패딩을 사용했다.
    • 기술적으로 p \geq 0 인 어떤 패딩도 적용할 수 있다. p 값에 따라 x 에서 경계에 있는 셀은 중간 셀과 다르게 처리된다.
  • n=5, m=3, p=0 인 경우를 생각해 보자. x[0] 은 하나의 출력 원소를 계산하는데만 사용된다. (예컨대 y[0] )
    • 반면 x[1] 은 두 개의 출력 원소를 계산하는데 사용된다. (y[0], y[1] )
    • x 원소를 이렇게 다르게 취급하기 때문에 가운데 있는 [2]x 가 대부분의 계산에 사용되어 강조되는 효과를 낸다.
    • 여기서는 p=2 를 사용하면 이 문제를 피할 수 있다.
    • x 의 각 원소가 세 개의 y 원소 계산에 참여한다.
  • 또 출력 y 크기는 사용한 패딩 방법에 따라 달라진다. 실전에서 자주 사용하는 세 개의 패딩 방법은 풀(full) 패딩, 세임(same) 패딩, 밸리드(valid) 패딩이다.
    • 풀 패딩은 패딩 파라미터 p p=m-1 로 설정한다. 풀 패딩은 출력 크기를 증가시키기 때문에 합성곱 신경망 구조에서는 거의 사용되지 않는다.
    • 세임 패딩은 출력 크기가 입력 벡터 x 와 같아야 할 때 사용한다. 이때 패딩 파라미터 p 는 입력과 출력 크기가 동일해야 하기 때문에 필터 크기에 따라 결정된다.
    • 마지막으로 밸리드 패딩 합성곱은 p=0 인 경우를 말한다. (패딩 없음)
  • 아래 그림은 세 개의 패딩 모드를 보여준다. 입력은 5×5 픽셀, 커널은 3×3 크기, 스트라이드는 1인 경우이다.

  • 합성곱 신경망에서 가장 많이 사용되는 패딩 방법은 세임 패딩이다. 다른 패딩 방식에 비해 장점은 세임 패딩이 입력 이미지나 텐서의 높이와 너비를 유지시킨다는 것이다. 이 때문에 네트워크 구조를 설계하기 쉽다.
  • 풀 패딩이나 세임 패딩에 비해 밸리드 패딩의 단점은 신경망에 층이 추가될수록 점진적으로 텐서 크기가 줄어든다는 것이다. 이는 신경망 성능을 나쁘게 만들 수 있다.
  • 실전에서는 세임 패딩으로 너비와 높이를 유지시키고 풀링에서 크기를 감소시킨다. 풀 패딩은 입력보다 출력 크기를 증가시키므로 경계 부분의 영향을 최소화하는 것이 중요한 신호 처리 애플리케이션에서 보통 사용된다.
    • 딥러닝에서는 경계 부분의 영향이 크지 않기 때문에 풀 패딩이 거의 사용되지 않는다.

합성곱 출력 크기 계산

  • 합성곱 출력 크기는 입력 벡터 위를 필터 w 가 이동하는 전체 횟수로 결정된다.
    • 입력 벡터의 크기는 n 이고 필터의 크기는 m , 패딩이 p , 스트라이드가 s x * w 출력 크기는 다음과 같이 계산된다.

o = \lfloor {n + 2p - m \over s} \rfloor + 1

  • 여기서 \lfloor \cdot \rfloor 는 버림 연산을 나타낸다.
  • 입력 벡터 크기가 10이고 합성곱 커널 크기가 5, 패딩이 2, 스트라이드가 1일 때 출력 크기는 다음과 같다.

n=10, m=5, p=2, s=1 \to o = \lfloor {10 + 2 \times 2 - 5 \over 1} \rfloor + 1= 10

  • 커널 크기가 3이고 스트라이드가 2이면 같은 입력 벡터일 때 출력 크기는 다음과 같다.

n=10, m=3, p=2, s=2 \to o = \lfloor {10 + 2 \times 2 - 3 \over 2} \rfloor + 1 = 6

  • 1차원 합성곱의 계산 방법을 익히기 위해 단순하게 구현해 보고 이 결과를 numpy.convolve 함수와 비교해 보자.
import numpy as np

def conv1d(x, w, p=0, s=1):
   w_rot = np.array(w[::-1])
    x_padded = np.array(x)

    if p > 0:
       zero_pad = np.zeros(shape=p)
        x_padded = np.concatenate([zero_pad, x_padded, zero_pad])
       res = []

       for i in range(0, int(len(x)/s), s):
           res.append(np.sum(x_padded[i:i+w_rot.shape[0]] * w_rot))       

        return np.array(res)

x = [1, 3, 2, 4, 5, 6, 1, 3]
w = [1, 0, 3, 1, 2]

print('Conv1d 구현:', conv1d(x, w, p=2, s=1))

### 결과
# Conv1d 구현: [ 5. 14. 16. 26. 24. 34. 19. 22.]

print('넘파이 결과:', np.convolve(x, w, mode='same'))

### 결과
# 넘파이 결과: [ 5 14 16 26 24 34 19 22]

2D 이산 합성곱 수행

  • 앞서 배운 개념은 2차원으로 쉽게 확장 가능하다. m_{1} \leq n_{1} 이고 m_{2} \leq n_{2} 인 행렬 X_{n_{1} \times n_{2}} 와 필터 행렬 W_{m_{1} \times m_{2}} 같은 2차원 입력을 다룰 떄 X W 의 2D 합성곱 결과는 행렬 Y = X * W 가 된다.

Y = X * W \to Y[i, j] = \sum_{k_{1} = -\infty}^{+\infty} \sum_{k_{2} = -\infty}^{+\infty} X[i-k_{1}, j-k_{2}] W[k_{1}, k_{2}]

  • 차원 하나를 제거하면 남은 공식이 이전의 1D 합성곱과 동일하다.
  • 사실 제로 패딩, 필터 행렬의 회전, 스트라이드 같은 이전에 언급한 모든 기법도 2D 합성곱에 적용할 수 있다. 양쪽 차원에 독립적으로 확장된다.
  • 다음 예는 패딩 p = (1, 1) 과 스트라이드 s = (2, 2) 일 때 입력 행렬 X_{3 \times 3} 과 커널 행렬 W_{3 \times 3} 사이의 2D 합성곱 계산을 보여준다.
    • 여기서는 입력 행렬의 네 면에 0이 한줄씩 추가되어 X_{5 \times 5}^{padded} 행렬을 만든다.

  • 필터를 뒤집으면 다음과 같다.

W^{r} = \left[ \begin{array}{rrr} 0.5 & 1 & 0.5 \\ 0.1 & 0.4 & 0.3 \\ 0.4 & 0.7 & 0.5 \end{array} \right]

  • 이 변환은 전치 행렬과 다르다. 넘파이에서 필터를 역전시키려면 W_rot = W[::-1, ::-1] 처럼 쓴다. 그 다음 패딩된 입력 행렬 X^{padded} 를 따라 슬라이딩 윈도우처럼 역전된 필터를 이동하면서 원소별 곱의 합을 계산한다.
    • 아래 그림에 \odot 연산자로 표기했다.

  • 결과값 Y 는 2×2 행렬이다.
  • 단순한 알고리즘을 사용하여 2D 합성곱도 구현해 보겠다. scipy.signal 패키지는 2D 합성곱을 계산할 수 있는 scipy.signal.convolve2d 함수를 제공한다.
import numpy as np
import scipy.signal

def conv2d(X, W, p=(0, 0), s=(1, 1)):
    W_rot = np.array(W)[::-1, ::-1]
    X_orig = np.array(X)
   n1 = X_orig.shape[0] + 2*p[0]
   n2 = X_orig.shape[1] + 2*p[1]
   X_padded = np.zeros(shape=(n1, n2))
    X_padded[p[0]:p[0]+X_orig.shape[0], p[1]:p[1]+X_orig.shape[1]] = X_orig

    res = []
   for i in range(0, int((X_padded.shape[0] - W_rot.shape[0])/s[0])+1, s[0]):
        res.append([])

        for j in range(0, int((X_padded.shape[1] - W_rot.shape[1])/s[1])+1, s[1]):
            X_sub = X_padded[i:i+W_rot.shape[0], j:j+W_rot.shape[1]]
           res[-1].append(np.sum(X_sub * W_rot))   

    return (np.array(res))

X = [[1, 3, 2, 4], [5, 6, 1, 3], [1, 2, 0, 2], [3, 4, 3, 2]]
W = [[1, 0, 3], [1, 2, 1], [0, 1, 1]]

print('Conv2d 구현:\n', conv2d(X, W, p=(1, 1), s=(1,1)))

### 결과
# Conv2d 구현:
# [[11. 25. 32. 13.]
# [19. 25. 24. 13.]
# [13. 28. 25. 17.]
# [11. 17. 14. 9.]]

print('사이파이 결과:\n', scipy.signal.convolve2d(X, W, mode='same'))

### 결과
# 사이파이 결과:
# [[11 25 32 13]
# [19 25 24 13]
# [13 28 25 17]
# [11 17 14 9]]

서브샘플링

  • 서브샘플링은 전형적인 두 종류의 풀링 연산으로 합성곱 신경망에 적용된다. 최대 풀링(max-pooing)과 평균 풀링(mean-pooling 또는 average-pooling)이다.
    • 풀링 층은 보통 P_{n_{1} \times n_{2}} 로 표시한다.
    • 아래 첨자는 최댓값과 평균 연산이 수행되는 이웃한 픽셀 크기이다. (차원별로 인접 픽셀 개수)
    • 이런 이웃 픽셀 개수를 풀링 크기라고 한다.
  • 아래 그림에 이 연산을 나타냈다. 최대 풀링은 이웃한 픽셀에서 최댓값을 취하고 평균 풀링은 픽셀의 평균을 계산한다.

  • 풀링의 장점은 두가지 이다.
    • 풀링(최대 풀링)은 일종의 지역 불변경을 만든다. 국부적인 작은 변화가 최대 풀링의 결과를 바꾸지 못한다는 의미이다. 결국 입력 데이터에 있는 잡음에 좀 더 안정적인 특성을 생성한다. 아래에서 보듯 두 개의 다른 입력 행렬 X_{1} X_{2} 가 같은 결과를 만든다.
    • 풀링은 특성 크기를 줄이므로 계산 효율성을 높인다. 또 특성 개수가 줄어들면 과대적합도 감소된다.

기본 구성 요소를 사용하여 심층 합성곱 신경망 구성

  • 지금까지 합성곱 신경망의 기본 구성 요소를 배웠다. 이 장에서 설명한 개념들은 전통적인 다층 신경망보다 아주 어렵지 않다. 일반적인 신경망에서 가장 중요한 연산은 행렬-벡터 곱셈이다.
  • 예컨대 행렬-벡터 곱셈을 사용하여 활성화 함수의 입력(또는 최종 입력) a = Wx + b 을 계산한다.
    • 여기서 x 는 픽셀을 나타내는 열 벡터고, W 는 입력 픽셀과 각 은닉 유닛을 연결하는 가중치 행렬이다.
    • 합성곱 신경망에서 이 연산은 합성곱 연산 A = W * X + b 로 바뀐다. X 는 높이 x 너비의 픽셀을 나타내는 행렬이다.
    • 두 경우 모두 은닉 유닛의 활성화 출력 H = \phi (A) 를얻기 위해 활성화 함수에 입력으로 전달된다. 여기서 \phi 는 활성화 함수이다.
    • 이전 절에서 설명한 것처럼 풀리응로 표현되는 서브샘플링도 합성곱 신경망의 구성 요소 중 하나이다.

여러 개의 입력 또는 컬러 채널 다루기

  • 합성곱 층의 입력 샘플에는 N_{1} \times N_{2} 차원 (예컨대 이미지의 높이와 너비 픽셀)인 하나 이상의 2D 배열 또는 행렬이 포함될 수 있다.
    • 이런 N_{1} \times N_{2} 행렬을 채널(channel)이라고 한다.
    • 여러 개의 채널을 합성곱 층 입력에 사용하기 때문에 랭크 3 텐서 또는 3차원 배열 X_{N_{1} \times N_{2} \times C_{in}} 을 사용해야 한다.
    • 여기서 C_{in} 이 입력 채널 크기이다.
  • 예컨대 CNN의 첫 번째 층에 입력되는 이미지를 생각해 보자. RGB 모드의 컬러 이미지라면 C_{in} = 3 이다. (RGB의 빨간색, 초록색, 파란색 채널)
    • 이미지가 그레이스케일(grayscale)이라면 흑백의 픽셀 강도를 가진 하나의 채널만 있으므로 C_{in} = 1 이다.
  • 합성곱 연산에서 여러 개의 입력 채널을 어떻게 다룰 수 있을까?
    • 해답은 간단하다. 각 채널별로 합성곱 연산을 수행하고 행렬 덧셈으로 결과를 합친다.
    • 채널 (c)별 합성곱은 개별적인 커널 행렬 W[:,:,c] 를 사용한다.
    • 활성화 함수에 입력되는 결과값은 다음 공식으로 계산된다.

  • 최종 결과 h 를 특성 맵이라고 한다.
    • 보통 CNN의 합성곱 층은 하나 이상의 특성 맵을 만든다.
    • 여러 개의 특성 맵을 사용하면 커널 텐서는 width \times height \times C_{in} \times C_{out} 으로 4차원이 된다.
    • 너비와 높이는 커널의 크기고 C_{in} 은 입력 채널의 개수, C_{out} 은 출력 특성 맵의 개수이다.
    • 이전 공식에 출력 특성 맵의 개수를 포함시키면 아래와 같다.

  • 아래 그림에 나온 합성곱 층과 풀링 층이 포함된 예제를 통해 신경망의 합성곱 계산을 정리하겠다.
    • 이 예는 입력 채널이 3개이다. 커널 텐서는 4차원이다. 각 커널 행렬은 m_{1} \times m_{2} 크기고 입력 채널에 한 개씩 세 개 이다.
    • 이런 텐서가 다섯 개의 출력 특성 맵을 만들기 위해 다섯 개가 있다.
    • 마지막으로 특성 맵을 서브샘플링하기 위해 풀링 층이 있다.
    • 전체 구조는 아래 그림과 같다.

드롭아웃으로 신경망 규제

  • 일반적인 (완전 연결) 신경망 또는 CNN 중 어떤 것을 사용하든지 네트워크 크기를 결정하는 것은 항상 어려운 문제이다. 어느 정도 좋은 성능을 얻으려면 가중치 행렬 크기와 층 개수를 튜닝해야 한다.
    • 파라미터 개수가 비교적 적은 네트워크는 용량이 작기 때문에 과소적합되기 쉽다. 이는 복잡한 데이터셋에 내재된 구조를 학습할 수 없기 때문에 성능이 나빠진다.
    • 반면 아주 큰 네트워크는 과대적합될 가능성이 많다. 이런 네트워크가 훈련 데이터를 외워 버리면 훈련 세트에서는 잘 작동하지만 테스트 데이터에서는 나쁜 성능을 낼 것이다.
    • 실제 머신 러닝 문제를 다룰 때는 얼마나 네트워크가 커야 하는지 사전에 알 수 없다.
  • 이 문제를 해결하기 위한 한 가지 방법은 다음과 같다.
    • 먼저 훈련 세트에서 잘 동작하도록 비교적 큰 용량의 네트워크를 구축한다 (실제로 필요한 것보다 좀 더 큰 용량을 선택한다)
    • 그 다음 과대적합을 막기 위해 한 개 이상의 규제 방법을 적용하여 별도의 테스트 세트 같은 새로운 데이터에서 일반화 성능을 높인다.
    • 널리 사용되는 규제 방법은 L2 규제이다.
  • 최근에 드롭아웃(dropout)이라는 새로운 규제 기법이 (심층) 신경망을 규제하는데 매우 뛰어나다는 것이 밝혀졌다.
    • 드롭아웃을 앙상블 모델의 (평균적인) 조합으로 생각할 수 있다. 앙상블 학습에서는 독립적으로 여러 개의 모델을 훈련 시킨다.
    • 예측을 할 때는 훈련된 모델을 모두 사용하여 결정한다. 여러 개의 모델을 훈련하고 출력을 모아 평균으 ㄹ내는 작업은 계산 비용이 비싸다.
    • 드롭아웃은 많은 모델을 동시에 훈련하고 테스트나 예측 시에 평균을 효율적으로 계산하는 효과적인 방법을 제공한다.
  • 드롭아웃은 보통 깊은 층의 은닉 유닛에 적용한다. 신경망의 훈련 단계에서 반복마다 P_{drop} 확률로 은닉 유닛의 일부가 랜덤하게 꺼진다 (또는 P_{keep} = 1 - P_{drop} 확률만큼 랜덤하게 켜진다)
    • 드롭아웃 확률은 사용자가 지정해야 하며 보통 p = 0.5 를 사용한다. 입력 뉴런의 일부를 끄면 남은 뉴런에 연결된 가중치가 누락된 뉴런 비율만큼 증가된다.
  • 랜덤한 드롭아웃의 영향으로 네트워크는 데이터에서 여분의 표현을 학습한다. 따라서 네트워크가 일부 은닉 유닛의 활성화 값에 의존할 수 없다.
    • 훈련 과정에서 언제든지 은닉 유닛이 꺼질 수 있기 때문이다.
    • 이는 네트워크가 데이터에서 더 일반적이고 안정적인 패턴을 학습하게 만든다.
  • 랜덤한 드롭아웃은 과대적합을 효과적으로 방지한다. 아래 그림은 훈련 단계에서 p = 0.5 의 확률로 드롭아웃을 적용하는 사례를 보여준다.
    • 절반의 뉴런은 랜덤하게 활성화 되지 않는다.
    • 예측할 때는 모든 뉴런이 참여하여 다음 층의 활성화 함수 입력을 계산한다.

  • 여기서 보듯이 훈련 단계에서만 유닛이 랜덤하게 꺼진다는 것이 중요하다.
    • 평가 단계에서는 모든 은닉 유닛이 활성화 되어야 한다 (즉 P_{drop} = 0 이고 P_{keep} = 1 이다)
    • 훈련과 예측 단계의 전체 활성화 값의 스케일을 맞추기 위해 활성화된 뉴런 출력이 적절히 조정되어야 한다. (예컨대 훈련할 때 드롭아웃 확률이 p = 0.5 라면 테스트할 때 활성화 출력을 절반으로 낮춘다)
  • 실전에서 예측을 만들 때 활성화 값의 출력을 조정하는 것은 불편하기 때문에 텐서플로나 다른 라이브러리들은 훈련 단계의 활성화를 조정한다 (예컨대 드롭아웃 확률이 p = 0.5 라면 활성화 함수의 출력을 2배로 높인다)
  • 드롭아웃과 앙상블 학습간에 어떤 관계가 있을까? 반복마다 다른 은닉 유닛을 끄기 때문에 다른 모델을 훈련하는 효과를 낸다.
    • 이런 모델을 모두 훈련시킨 후 유지 확률을 1로 설정하고 모든 은닉 유닛을 사용한다.
    • 이는 모든 은닉 유닛으로부터 평균적인 활성화 출력을 얻는다는 의미가 된다.

텐서플로를 사용하여 심층 합성곱 신경망 구현

다층 CNN 구조

  • 여기서 구현할 네트워크는 아래 그림에 나타나 있다.
    • 입력은 28×28 크기의 그레이스케일 이미지이다.
    • 채널 개수와 입력 이미지의 배치를 생각하면 입력 텐서의 차원은 batchsize x 28 x 28 x 1이 된다.
    • 입력 데이터 5×5 크기의 커널을 가진 두 개의 합성곱 층을 지난다. 첫 번째 합성곱은 32개의 특성 맵을 출력하고 두 번째는 64개의 특성 맵을 출력한다. 각 합성곱 층 다음에는 서브샘플링으로 최대 풀링 연산이 뒤따른다.
    • 그 다음 완전 연결 층의 출력이 최종 소프트맥스 층인 두 번째 완전 연결 층으로 전달된다.

  • 각 층의 텐서 차원은 다음과 같다.
    • 입력: batchsize x 28 x 28 x 1
    • 합성곱_1: batchsize x 24 x 24 x 32
    • 풀링_1: batchsize x 12 x 12 x 32
    • 합성곱_2: batchsize x 8 x 8 x 64
    • 풀링_2: batchsize x 4 x 4 x 64
    • 완전 연결_1: batchsize x 1024
    • 완전 연결과 소프트맥스 층: batchsize x 10

데이터 적재와 전처리

  • 13장에서 load_mnist 함수를 사용하여 MNIST 손글씨 데이터셋을 읽었는데, 여기서도 다음과 같은 과정을 반복하겠다.
X_data, y_data = mn.load_mnist('./mnist/', kind='train')
X_test, y_test = mn.load_mnist('./mnist/', kind='t10k')

count = 50000
X_train, y_train = X_data[:count,:], y_data[:count]
X_valid, y_valid = X_data[count:, :], y_data[count:]
  • 훈련 성능을 높이고 최적 값에 잘 수렴하려면 데이터를 정규화해야 한다.
    • 훈련 데이터의 특성마다 평균을 계산하고 모든 특성에 걸쳐 표준 편차를 계산한다.
    • 각 특성 별로 표준 편차를 계산하지 않는 이유는 MNIST 같은 이미지 데이터셋에 있는 일부 특성(픽셀) 값은 모든 이미지에서 동일하게 255이기 때문이다.
    • 모든 샘플에서 고정된 값이면 변동이 없고 표준 편차가 0이 되므로 0-나눗셈 에러가 발생한다. 이런 이유로 X_train 전체의 표준 편차를 계산하기 위해 np.std 함수의 axis 매개변수를 지정하지 않았다.
mean_vals = np.mean(X_train, axis=0)
std_val = np.std(X_train)

X_train_centered = (X_train - mean_vals) / std_val
X_valid_centered = (X_valid - mean_vals) / std_val
X_test_centered = (X_test - mean_vals) / std_val
  • 여기서는 이미지를 2차우너 배열로 읽어 들였다. 샘플마다 하나의 행을 차지하며 784개의 픽셀에 해당하는 ㅇ려이 있다.
    • 합성곱 신경망에 데이터를 주입하려면 784개의 행을 원본 이미지의 차원과 동일한 28 x 28 x 1 크기로 바꾸어야 한다.
    • MNIST 이미지는 흑백 이미지이기 때문에 마지막 컬러 채널이 의미가 없지만 합성곱 연산에서는 마지막 채널 차원이 필요하다.
  • 넘파이의 reshape 메서드를 사용하여 훈련 데이터, 검증 데이터, 테스트 데이터의 차원을 다음과 같이 변경하겠다.
    • 첫 번째 차원은 샘플 차원이므로 변경하지 않고 나머지 차원에 따라 자동으로 결정된다.
X_train_centered = X_train_centered.reshape((-1, 28, 28, 1))
X_valid_centered = X_valid_centered.reshape((-1, 28, 28, 1))
X_test_centered = X_test_centered.reshape((-1, 28, 28, 1))
  • 그 다음 13장에서 했던 것처럼 클래스 레이블을 원-핫 인코딩으로 변경하겠다. to_categorical 함수를 사용하여 변환한다.
from tensorflow.keras.utils import to_categorical

y_train_onehot = to_categorical(y_train)
y_valid_onehot = to_categorical(y_valid)
y_test_onehot = to_categorical(y_test)
  • 훈련 데이터를 원하는 형태로 변환했기 때문에 CNN을 구현할 준비가 되었다.

텐서플로 tf.keras API로 CNN 구성

  • 텐서플로에서 CNN을 구현하기 위해 tf.keras API로 합성곱 네트워크를 구현해 보겠다.
    • 먼저 tf.keras의 하위 모듈 중 layers, models를 임포트한다.
    • 그리고 13장에서 만들었던 것처럼 Sequential 모델을 만든다. 이전에는 완전 연결 층만 추가했지만, 이 예제에서는 합성곱을 위한 층을 추가한다.
from tensorflow.keras import layers, models

model = models.Sequential()
  • layers 모듈 아래에는 Dense 층 외에 다양한 층이 이미 구현되어 있다.
    • 대표적으로 2차원 합성곱을 위한 Conv2D 클래스가 있다. 또 드롭아웃을 위한 Dropout 클래스와 최대 풀링을 위한 MaxPool2D, 평균 풀링을 위한 AveragePool2D 클래스를 제공한다.
  • 먼저 Conv2D 클래스를 모델에 추가해 보겠다. 이전 장에서 Dense 층을 추가했던 것과 비슷하게 Conv2D 클래스의 객체를 모델의 add 메서드에 전달한다.
model.add(layers.Conv2D(32, (5, 5), padding='valid', activation='relu', input_shape=(28, 28, 1)))
  • Conv2D 클래스의 첫 번째 매개변수는 필터 개수이고 두 번째는 필터 크기이다. 그림 15-10에 나타난 CNN 네트워크 구조처럼 첫 번째 합성곱 층은 5×5 크기의 필터를 32개 가진다.
  • padding 매개변수에는 ‘valid’ 패딩을 지정한다. 세임 패딩을 선택하려면 ‘same’으로 지정한다. 대소문자는 구분하지 않는다.
    • padding 매개변수의 기본값이 ‘valid’이므로 설정하지 않아도 된다.
  • 스트라이드를 설정하는 strides 매개변수는 정수 또는 정수 두 개로 이루어진 튜플로 지정한다. 튜플일 경우 높이와 너비 방향의 스트라이드를 각각 다르게 지정할 수 있다.
    • 기본값은 1로 높이와 너비 방향으로 1칸씩 필터를 이동시킨다. 여기서는 strides를 지정하지 않았으므로 기본값을 사용한다.
  • 활성화 함수는 Dense 층과 마찬가지로 activation 매개변수에서 지정한다. 여기서는 최근 이미지 분야에서 자주 사용되는 렐루(ReLu) 활성화 함수를 선택했다.
  • kernel_initializer와 bias_initializer 매개변수는 따로 지정하지 않았으므로 기본값으로 설정도니다.
    • kernel_initializer 매개변수는 세이비어(또는 글로럿) 초기화 방식인 ‘glorot_uniform’이 되고 bias_initializer는 ‘zeros’가 사용된다.
  • 마지막으로 모델에 추가되는 첫 번째 층이므로 입력 크기를 input_shape 매개변수에 지정한다. 여기서도 첫 번째 배치 차원을 제외하고 28 x 28 x 1 크기를 지정했다.
  • 앞서 합성곱의 출력을 계산하는 공식을 사용하여 출력 크기를 계산해 보겠다. 입력 크기는 28, 필터 크기는 5, 패딩은 0이고 스트라이드는 1이다.

o = {n + 2p - m \over s} + 1 = {28 + 0 - 5 \over 1} + 1 = 24

  • 이미지를 하나 생각해 보자. 28 x 28 x 1 크기의 이미지가 첫 번째 합성곱 연산을 거쳐 24 x 24 x 1 크기로 바뀐다. 첫 번째 층의 필터 개수가 32개이므로 최종적으로 출려되는 특성 맵의 크기는 24 x 24 x 32가 된다.
    • 이 층의 전체 가중치 개수는 5 x 5 x 1 크기의 필터가 32개 있고 절편이 32개 있으므로 5 x 5 x 32 + 32 = 832개 이다.
  • 그 다음 추가할 층은 최대 풀링 층이다. 코드는 다음과 같다.
model.add(layers.MaxPool2D((2, 2)))
  • 풀링 층의 첫 번째 매개변수(pool_size)는 풀링 크기로 높이와 너비를 튜플로 지정한다.
    • 풀링 크기의 기본값은 (2, 2) 이다.
  • 두 번째 매개변수는 스트라이드(strides)로 기본값은 None이다.
    • 스트라이드가 none이면 풀링 크기를 사용하여 겹치지 않도록 풀링된다.
    • 보통 풀링에서 스트라이드를 지정하는 경우는 드물다. 여기서도 스트라이드는 따로 지정하지 않았다.
  • (2, 2) 크기로 풀링했기 때문에 풀링 층을 통과한 특성 맵의 크기는 높이와 너비가 절반으로 줄어든다. 하지만 특성 맵의 개수는 변화가 없다. 따라서 최종적으로 출력되는 특성 맵의 차원은 12 x 12 x 32가 된다.
    • 또 풀링 층은 가중치가 없다는 점도 잊지 말자
  • 두 번째 합성곱 층을 추가할 차례이다. 필터 개수만 제외하고 첫 번째 합성곱의 매개변수와 동일하다. 여기서는 64개의 필터를 사용하겠다.
model.add(layers.Conv2D(64, (5, 5), padding='valid', activation='relu'))
  • 두 번째 합성곱 층의 출력 크기를 계산해 보자. 입력 크기는 12, 필터 크기는 5, 패딩은 0이고 스트라이드는 1이다.

o = {n + 2p - m \over s} + 1 = {12 + 0 - 5 \over 1} + 1 = 8

  • 12 x 12 x 32 크기의 이미지가 두 번째 합성곱 연산을 거쳐 8 x 8 x 1 크기로 바뀐다. 두 번째 층의 필터 개수가 64개이므로 최종적으로 출력되는 특성 맵의 크기는 8 x 8 x 64가 된다.
  • 두 번째 합성곱 층의 필터 크기는 5 x 5 x 1이 아니라 5 x 5 x 32이다. 측, 채널 방향으로는 필터가 이동하지 않고 전체 채널이 한 번에 합성곱에 참여한다.
    • Conv2D의 필터 크기를 (5, 5)로 지정했지만, tf.keras API는 똑똑하게 이전 층의 출력 채널에 맞추어 필터를 생성한다.
  • 그럼 두 번째 층의 가중치 개수는 얼마일까? 5 x 5 x32 크기의 필터가 64개 있고 절편이 64개 있다. 따라서 5 x 5 x 32 x 64 + 54 = 51,264개이다.
  • 이제 두 번째 풀링 층을 추가해보자. 코드는 첫 번째 풀링 층과 동일하다.
model.add(layers.MaxPool2D((2, 2)))
  • 여기서도 (2, 2) 크기로 풀링했기 때문에 특성 맵의 크기는 높이와 너비가 절반으로 줄어든다. 특성 맵의 개수는 변화가 없으므로 최종적으로 출력되는 특성 맵의 차원은 4 x 4 x 64가 된다.
  • 다음으로 완전 연결 층인 Dense 층에 연결하기 위해 4 x 4 x 64 차원의 텐서를 일렬로 펼쳐야 한다. 케라스 API는 이런 작업을 위해 Flatten 클래스를 제공한다.
    • 이 클래스는 매개변수가 필요하지 않다. 모델에 추가하면 이전 층의 출력을 일렬로 펼치는 작업을 한다. 당연하게 학습되는 가중치도 없다.
model.add(layers.Flatten())
  • 4 x 4 x 64 크기의 텐서를 펼쳤으므로 1,024차원의 텐서가 되었다. 이를 1,024개의 유닛을 가진 완전 연결 층에 연결하겠다. 이 층의 활성화 함수도 렐루 함수를 사용한다.
model.add(layers.Dense(1024, activation='relu'))
  • Dense 층의 kernel_initializer와 bias_initializer도 지정하지 않으면 기본값인 ‘glorot_uniform’과 ‘zeros’로 설정된다.
  • 이 층의 가중치 개수는 1,024 텐서를 1,024개의 유닛에 완전 연결했으므로 1,024 x 1,024개와 절편 1,024개를 더하면 1,049,600개가 된다. 두 개의 합성곱 층에서 사용한 가중치를 합한 것보다 훨씬 많다.
  • 마지막 층에 연결하기 전에 드롭아웃 층을 추가하겠다. 케라스의 Dropout 클래스도 가중치를 가지지 않는다.
model.add(layers.Dropout(0.5))
  • Dropout 클래스에는 유닛을 끌 확률을 매개변수로 지정한다. 편리하게도 fit 메서드에서만 드롭아웃이 적용된다. 테스트나 평가를 위해 따로 모델을 구성할 필요는 없다.
  • 마지막 층은 열 개의 손글씨 숫자에 대한 확률을 출력해야 하므로 열 개의 유닛을 가진 완전 연결 층이다. 다중 분류 문제를 위한 활성화 함수는 소프트맥스 함수이므로 activation 매개변수를 ‘softmax’로 설정한다.
model.add(layers.Dense(10, activation='softmax'))
  • 이 층의 가중치 개수는 1,024개의 이전 Dense 층 출력을 열 개의 유닛에 연결했으므로 절편과 합쳐서 1,024 x 10 + 10 = 10,250이 된다.
  • 합성곱 신경망은 전형적으로 이렇게 마지막에 한 개 이상의 완전 연결 층으로 연결된다. 최종 출력 층의 유닛 개수는 클래스 레이블의 개수와 맞추어야 한다.
  • 모델의 summary 메서드를 호출하여 합성곱 신경망의 구성을 확인해 보자.
model.summary()

### 결과
# Model: "sequential"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# conv2d (Conv2D) (None, 24, 24, 32) 832
# _________________________________________________________________
# max_pooling2d (MaxPooling2D) (None, 12, 12, 32) 0
# _________________________________________________________________
# conv2d_1 (Conv2D) (None, 8, 8, 64) 51264
# _________________________________________________________________
# max_pooling2d_1 (MaxPooling2 (None, 4, 4, 64) 0
# _________________________________________________________________
# flatten (Flatten) (None, 1024) 0
# _________________________________________________________________
# dense (Dense) (None, 1024) 1049600
# _________________________________________________________________
# dropout (Dropout) (None, 1024) 0
# _________________________________________________________________
# dense_1 (Dense) (None, 10) 10250
# =================================================================
# Total params: 1,111,946
# Trainable params: 1,111,946
# Non-trainable params: 0

합성곱 신경망 모델 훈련

  • 이제 모델을 컴파일할 차례이다.
    • 다중 분류 작업이므로 손실 함수는 이전 장에서 사용했던 것처럼 categorical_crossentropy를 사용한다.
    • 옵티마이저는 adam을 사용하겠다. 또 손실 점수와 더불어 정확도 값을 계산하기 위해 metrics 매개변수에 ‘acc’를 추가했다.
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])
  • 13장에서 보았던 것처럼 모델을 훈련할 때 최선의 가중치를 저장하기 위해 ModelCheckpoint 콜백을 사용하겠다. 또 텐서보드를 사용하여 시각화하기 위해 TensorBoard 콜백도 추가하겠다.
import time
from tensorflow.keras.callbacks import ModelCheckpoint, TensorBoard

callback_list = [ModelCheckpoint(filepath='cnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard(log_dir='logs/{}'.format(time.asctime()))]

# 위 코드에서 tensorboard가 폴더를 못 만든다면 아래처럼 시간을 빼고 돌리면 일단 실행은 된다. 윈도우에서 폴더 만드는 게 잘 안되는 것이라 생각 됨.
# callback_list = [ModelCheckpoint(filepath='cnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard()]
  • 체크포인트 콜백은 검증 손실(val_loss)을 모니터링하고 최상의 가중치를 cnn_checkpoint.h5 파일에 저장한다.
  • 텐서보드 콜백은 logs 디렉터리 하위에 서브디렉터리를 만들어 통계를 저장한다.
    • 모델을 여러 번 훈련하는 경우 같은 디렉터리에 데이터가 저장되면 텐서보드에서 그래프를 보기가 불편하므로 실행할 때마다 다른 하위 디렉터리에 저장할 수 있게 time 모듈의 asctime 함수를 사용했다.
  • 이 두 콜백을 연결하여 callback_list를 만들었다. 모델의 fit 메서드를 호출할 때 callbacks 매개변수로 전달하겠다.
history = model.fit(X_train_centered, y_train_onehot, batch_size=64, epochs=20, validation_data=(X_valid_centered, y_valid_onehot), callbacks=callback_list)
  • 훈련 데이터와 검증 데이터는 앞서 준비했던 X_train_centered, y_train_onehot, X_valid_centered, y_valid_onehot을 사용한다.
    • fit 메서드의 batch_size 기본값은 32이다. 즉 32개씩 미니배치를 만들어 네트워크를 훈련한다. 여기서는 64개로 늘렸다.
    • 훈련 세트를 반복하여 학습하는 횟수는 20번으로 지정했다.
  • fit 메서드의 훈련 결과는 다음과 같다.
# Train on 50000 samples, validate on 10000 samples
# Epoch 1/20
# 2020-05-03 11:18:07.989105: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cublas64_100.dll
# 2020-05-03 11:18:08.209138: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cudnn64_7.dll
# 2020-05-03 11:18:09.156252: W tensorflow/stream_executor/cuda/redzone_allocator.cc:312] Internal: Invoking ptxas not supported on Windows
# Relying on driver to perform ptx compilation. This message will be only logged once.
# 2020-05-03 11:18:09.217162: I tensorflow/core/profiler/lib/profiler_session.cc:184] Profiler session started.
# 2020-05-03 11:18:09.221872: W tensorflow/stream_executor/platform/default/dso_loader.cc:55] Could not load dynamic library 'cupti64_100.dll'; dlerror: cupti64_100.dll not found
# 2020-05-03 11:18:09.228706: W tensorflow/core/profiler/lib/profiler_session.cc:192] Encountered error while starting profiler: Unavailable: CUPTI error: CUPTI could not be loaded or symbol could not be found.
# 64/50000 [..............................] - ETA: 22:04 - loss: 2.3739 - acc: 0.07812020-05-03 11:18:09.244288: I tensorflow/core/platform/default/device_tracer.cc:588] Collecting 0 kernel records, 0 memcpy records.
# 2020-05-03 11:18:09.249098: E tensorflow/core/platform/default/device_tracer.cc:70] CUPTI error: CUPTI could not be loaded or symbol could not be found.
# 50000/50000 [==============================] - 4s 87us/sample - loss: 0.1382 - acc: 0.9576 - val_loss: 0.0558 - val_acc: 0.9824
# Epoch 2/20
# 50000/50000 [==============================] - 3s 51us/sample - loss: 0.0516 - acc: 0.9840 - val_loss: 0.0520 - val_acc: 0.9848
# Epoch 3/20
# 50000/50000 [==============================] - 3s 51us/sample - loss: 0.0353 - acc: 0.9891 - val_loss: 0.0417 - val_acc: 0.9883
# ...
# Epoch 20/20
# 50000/50000 [==============================] - 2s 49us/sample - loss: 0.0106 - acc: 0.9972 - val_loss: 0.0793 - val_acc: 0.9909
  • fit 메서드에서 반환된 history 객체를 사용하여 훈련 세트와 테스트 세트에 대한 손실 그래프를 그리면 다음과 같다.
import matplotlib.pyplot as plt

epochs = np.arange(1, 21)

plt.plot(epochs, history.history['loss'])
plt.plot(epochs, history.history['val_loss'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 정확도 그래프는 다음과 같다.
plt.plot(epochs, history.history['acc'])
plt.plot(epochs, history.history['val_acc'])
plt.xlabel('epochs')
plt.ylabel('accuracy')
plt.show()

  • 텐서보드를 실행하고 브라우저에 http://localhost:6006/에 접속하면 훈련 과정의 손실과 정확도 그래프를 볼 수 있다.
    • 그래프 탭에 타나난 합성곱 신경망의 구조는 아래 그림과 같다.
tensorboard --logdir logs/

  • 훈련된 모델을 사용해서 테스트 세트를 평가하기 전에 모델과 가중치를 저장하자.
model.save('cnn_model.h5')
  • load_model 함수를 사용하여 저장된 모델을 불러 새로운 모델 객체를 만들고 이전에 훈련 과정에서 가장 높은 성능의 가중치가 저장된 체크포인트 파일을 복원한다.
from tensorflow.keras.models import load_model

restored_model = load_model('cnn_model.h5')
restored_model.load_weights('cnn_checkpoint.h5')
  • 복원된 restored_model을 사용하여 테스트 세트에서 평가해 보자.
restored_model.evaluate(X_test_centered, y_test_onehot)

### 결과
# =========] - 1s 62us/sample - loss: 0.0141 - acc: 0.9911
  • 99%가 넘는 예측 정확도가 나오는데 이는 13장에서 피드포워드 신경망으로 얻은 결과보다 훨씬 뛰어난 정확도이다.
  • 테스트 샘플 중 처음 열 개의 예측을 직접 확인해 보면 다음과 같다.
print(np.argmax(restored_model.predict(X_test_centered[:10]), axis=1))

### 결과
# [7 2 1 0 4 1 4 9 5 9]
  • 손글씨 숫자의 예제는 레이블 인덱스와 레이블 값이 같다. 일반적으로 두 값은 다르기 때문에 argmax 함수에서 반환된 인덱스를 사용해서 진짜 클래스 레이블을 구해야 한다.
    • 비교를 위해 처음 열 개의 테스트 레이블을 확인해 보자
print(y_test[:10])

### 결과
# [7 2 1 0 4 1 4 9 5 9]
  • 예측 결과와 모두 동일하다. 이 열 개의 숫자가 어떤 모습인지 확인하기 위해 샘플 이미지를 그려보자.
    • X_test_centered는 행을 따라 샘플이 놓여 있는 2차원 배열이다. 784개의 열을 28 x 28 배열로 바꾸어 그려보자.
fig = plt.figure(figsize=(10,5))

for i in range(10):
   fig.add_subplot(2, 5, i+1)
    plt.imshow(X_test_centered[i].reshape(28, 28))

  • 출력된 결과를 보면 이 모델의 성능이 매우 뛰어나다는 것을 알 수 있다.

활성화 출력과 필터 시각화

  • 첫 번째 합성곱 층의 출력을 이미지로 시각화해 보자.
    • model 객체에 추가한 층은 layers 속성으로 참조할 수 있다. 첫 번째 층의 객체를 추출해서 출력해 보자.
first_layer = model.layers[0]
print(first_layer)

### 결과
# <tensorflow.python.keras.layers.convolutional.Conv2D object at 0x0000021BB186F988>
  • first_layer가 Conv2D 임을 알 수 있다. first_layer의 output 속성을 함수형 API의 출력으로 사용하면 첫 번째 층의 활성화 출력을 얻을 수 있다.
  • 이제 함수형 API를 사용하기 위한 입력이 필요하다. 사실 Sequential 객체에 첫 번째 층을 추가하면 자동으로 model 객체 안에 input 속성이 정의된다. 이를 출력해서 확인해 보자.
print(model.input)

### 결과
# Tensor("conv2d_input:0", shape=(None, 28, 28, 1), dtype=float32)
  • 첫 번째 합성곱 층을 추가할 때 input_shape 매개변수에 입력 크기를 (28, 28, 1)로 지정했다. 이 때문에 model.input의 크기는 배치 차원이 추가되어 (None, 28, 28, 1)이다.
    • 입력과 출력 텐서가 모두 준비되었으므로 이 둘을 연결할 새로운 모델을 만든다. 그 다음 테스트 세트에서 처음 열 개의 샘플을 주입하여 출력을 구하자.
first_activation = models.Model(inputs=model.input, outputs=first_layer.output)
activation = first_activation.predict(X_test_centered[:10])

print(activation.shape)

### 결과
# (10, 24, 24, 32)
  • predict 메서드에서 계산에 사용한 가중치는 앞서 fit 메서드로 훈련한 값이다.
  • 열 개의 테스트 샘플을 입력했으므로 첫 번째 배치 차원은 10이고, 합성곱을 통과하며 높이와 너비가 각각 24 x 24로 줄었다. 첫 번째 합성곱 층의 필터가 32개이므로 마지막 차원이 32가 된다.
  • 열 개의 샘플 중 첫 번째 샘플의 특성 맵 32개를 모두 그리면 아래와 같다.
fig = plt.figure(figsize=(10, 15))

for i in range(32):
   fig.add_subplot(7, 5, i+1)
    plt.imshow(activation[0, :, :, i])

  • 숫자 7의 윤곽을 특성으로 잘 추출한 것으로 보인다.
  • 이번에는 네 번째 숫자의 특성 맵을 그려보자.
fig = plt.figure(figsize=(10, 15))

for i in range(32):
   fig.add_subplot(7, 5, i+1)
  plt.imshow(activation[3, :, :, i])

  • 특성 맵마다 조금씩 다른 숫자 0의 윤곽을 추출하고 있다. 특성 맵의 차이는 필터가 서로 다른 부분을 학습하기 때문이다.
  • 이번 에는 첫 번째 층의 필터를 출력해 보겠다.
    • 합성곱 필터는 합성곱 층의 kernel 속성에 저장되어 있다. 필터의 차원은 (높이, 너비, 입력 채널, 출력 채널)이다.
fig = plt.figure(figsize=(10, 15))

for i in range(32):
   fig.add_subplot(7, 5, i+1)
    plt.imshow(first_layer.kernel[:, :, 0, i])

  • 필터의 밝은 부분이 높은 값을 의미한다. 예컨대 아홉 번째 필터는 수평 에지를 학습하는 것으로 보인다.
    • 이 필터를 사용하여 숫자 7에서 추출된 특성은 수평 부분이 잘 나타나 있다. 반면 0은 수평 부분이 많지 않으므로 추출된 특성에 정보가 많이 담겨 있지 않다.
  • 합성곱 활성화 출력과 필터를 분석하면 중요한 통찰을 얻을 수 이는 경우가 많다. 층이 깊어질수록 합성곱의 활성화 출력 의미를 이해하기 어렵다는 것을 기억하라.

머신 러닝 교과서/ 텐서플로의 구조 자세히 알아보기

텐서플로의 주요 특징

  • 텐서플로는 머신 러닝 알고리즘을 구현하고 실행하기 위한 프로그래밍 인터페이스이며, 확장이 용이하고 다양한 플랫폼을 지원한다.
  • 텐서플로의 주요 특징 중 하나는 여러 개의 GPU를 사용할 수 있는 기능이다. 덕분에 대규모 시스템에서 머신 러닝 모델을 매우 효율적으로 훈련할 수 있다.
  • 텐서플로는 모바일 환경을 지원하기 때문에 모바일 애플리케이션 개발에도 적합하다.

텐서플로의 랭크와 텐서

  • 텐서플로 라이브러리를 사용하여 텐서에 대한 연산과 함수를 계산 그래프로 정의할 수 있다.
    • 텐서는 데이터를 담고 있는 다차원 배열에 대한 일반화된 수학적 용어로 텐서 차원을 일반적으로 랭크(rank)라고 한다.
  • 지금까지는 대부분 랭크 0에서 랭크 2사이의 텐서를 다루었다. 텐서는 더 고차원으로 일반화 될 수 있는데, 여러 개의 컬러 채널을 가진 이미지를 다루기 위해 랭크 3인 입력과 랭크 4인 가중치 텐서를 사용한다.
  • 텐서의 개념을 직관적으로 이해하기 위해 아래 그림을 참고 하자.

텐서의 랭크와 크기를 확인하는 방법

  • tf.rank 함수를 사용하여 텐서 랭크를 확인할 수 있다. tf.rank는 출력으로 텐서를 반환한다.
  • 텐서 랭크 외에도 텐서플로 텐서의 크기를 얻을 수도 있다(넘파이 배열의 크기와 비슷하다)
    • 예컨대 X가 텐서이면 X.get_shape()를 사용하여 크기를 구한다. 이 메서드는 TensorShape이라는 특별한 클래스의 객체를 반환한다.
    • tf.rank 함수와 get_shape 메서드를 사용하는 것은 아래 코드를 참고하라
import tensorflow as tf
import numpy as np

t1 = tf.constant(np.pi)
t2 = tf.constant([1, 2, 3, 4])
t3 = tf.constant([[1, 2], [3, 4]])

r1 = tf.rank(t1)
r2 = tf.rank(t2)
r3 = tf.rank(t3)

s1 = t1.get_shape()
s2 = t2.get_shape()
s3 = t3.get_shape()

print('크기:', s1, s2, s3)
print('랭크:', r1.numpy(), r2.numpy(), r3.numpy())

### 결과
# 크기: () (4,) (2, 2)
# 랭크: 0 1 2
  • t1 텐서는 단순한 스칼라이므로 랭크가 0이다. 크기가 ()
  • t2 벡터는 네 개의 원소를 가지므로 랭크가 1이다. 크기는 원소 하나로 이루어진 튜플 (4, )
  • 2×2 행렬인 t3의 랭크는 2이고 크기는 (2, 2) 튜플이다.

텐서를 다차원 배열로 변환

  • 텐서 변환에 사용되는 여러 연산을 살펴보겠다. 일부 연산들은 넘파이 배열 연산과 매우 비슷하게 작동한다.
    • 랭크 2 이상인 텐서를 다룰 때 전치(tanspose) 같은 변환은 주의를 기울여야 한다.
  • 넘파이는 arr.shape 속성으로 배열 크기를 얻을 수 있다.
import tensorflow as tf
import numpy as np

arr = np.array([[1., 2., 3., 3.5], [4., 5., 6., 6.5], [7., 8., 9., 9.5]])
T1 = tf.constant(arr)
print(T1)

s = T1.get_shape()
print('T1의 크기:', s)
print('T1의 크기:', T1.shape)

T2 = tf.Variable(np.random.normal(size=s))
print(T2)

T3 = tf.Variable(np.random.normal(size=s[0]))
print(T3)

### 결과
# tf.Tensor(
# [[1. 2. 3. 3.5]
# [4. 5. 6. 6.5]
# [7. 8. 9. 9.5]], shape=(3, 4), dtype=float64)
# T1의 크기: (3, 4)
# T1의 크기: (3, 4)
# <tf.Variable 'Variable:0' shape=(3, 4) dtype=float64, numpy=
# array([[ 1.24591289, 0.42771636, -0.23259698, -0.31242843],
# [-0.51809936, -0.89199958, -0.49494362, 0.55808144],
# [-0.11522273, -0.24953113, -0.20146965, 1.40888624]])>
# <tf.Variable 'Variable:0' shape=(3,) dtype=float64, numpy=array([-0.56337014, 0.80102639, 0.84823994])>
  • T2를 만들기 위해 s를 사용했고 T3를 만들 때는 s의 인덱스를 참조했다.
    • get_shape() 메서드는 TensorShape 객체를 반환한다.
  • 텐서플로 1.x에서는 차원 크기를 얻기 위해 TensorShape 객체의 인덱스를 참조하면 Dimension 클래스의 객체가 반환된다.
    • 따라서 다른 텐서를 만들 때 바로 사용할 수 없었고 s.as_list()처럼 TensorShape 객체를 파이썬 리스트로 변환한 후 인덱스를 참조해야 했었다.
    • 텐서플로 2.x에서는 편리하게 TensorShape 객체의 인덱스를 참조하면 스칼라 값이 반환된다.
  • 이제 텐서 크기를 바꾸는 방법을 알아보자. 넘파이에서는 np.reshape이나 arr.reshape을 사용하는데, 텐서플로에서는 tf.reshape 함수를 사용하여 텐서 크기를 바꾼다.
    • 넘파이에서처럼 차원 하나를 -1로 지정할 수 있다. 이 차원 크기는 다른 차원에 지정한 크기를 바탕으로 결정된다.
    • 다음 코드에서 텐서 T1을 랭크 3인 T4와 T5로 변환했다.
T4 = tf.reshape(T1, shape=[1, 1, -1])
print(T4)

T5 = tf.reshape(T1, shape=[1, 3, -1])
print(T5)

### 결과
# tf.Tensor([[[1. 2. 3. 3.5 4. 5. 6. 6.5 7. 8. 9. 9.5]]], shape=(1, 1, 12), dtype=float64)
# tf.Tensor(
# [[[1. 2. 3. 3.5]
# [4. 5. 6. 6.5]
# [7. 8. 9. 9.5]]], shape=(1, 3, 4), dtype=float64)
  • 넘파이에는 배열을 전치할 수 있는 방법이 세 가지가 있다. arr.T, arr.transpose(), np.transpose(arr)이다. 텐서플로에서는 tf.transpose 함수를 사용한다.
    • 일반적인 전치 연산 외에 perm=[…]에 원하는 순서대로 차원을 지정하여 바꿀 수 있다.
T6 = tf.transpose(T5, perm=[2, 1, 0])
print(T6)

T7 = tf.transpose(T5, perm=[0, 2, 1])
print(T7)

### 결과
# tf.Tensor(
# [[[1. ]
# [4. ]
# [7. ]]

# [[2. ]
# [5. ]
# [8. ]]

# [[3. ]
# [6. ]
# [9. ]]

# [[3.5]
# [6.5]
# [9.5]]], shape=(4, 3, 1), dtype=float64)

# tf.Tensor(
# [[[1. 4. 7. ]
# [2. 5. 8. ]
# [3. 6. 9. ]
# [3.5 6.5 9.5]]], shape=(1, 4, 3), dtype=float64)
  • tf.split 함수를 사용하여 다음과 같이 텐서를 더 작은 텐서의 리스트로 나눌 수도 있다.
t5_split = tf.split(T5, num_or_size_splits=2, axis=2)
print(t5_split)

### 결과
# [<tf.Tensor: id=30, shape=(1, 3, 2), dtype=float64, numpy=
# array([[[1., 2.],
# [4., 5.],
# [7., 8.]]])>, <tf.Tensor: id=31, shape=(1, 3, 2), dtype=float64, numpy=
# array([[[3. , 3.5],
# [6. , 6.5],
# [9. , 9.5]]])>]
  • 출력 결과는 더 이상 하나의 텐서가 아니라 텐서의 리스트이다.
  • 마지막으로 또 다른 유용한 변환은 텐서 연결이다. 크기와 dtype이 같은 텐서 리스트가 있다면 tf.concat 함수로 연결하여 하나의 큰 텐서를 만들 수 있다.
t1 = tf.ones(shape=(5, 1), dtype=tf.float32)
t2 = tf.zeros(shape=(5, 1), dtype=tf.float32)
print(t1)
print(t2)

t3 = tf.concat([t1, t2], axis=0)
print(t3)

t4 = tf.concat([t1, t2], axis=1)
print(t4)

### 결과
# tf.Tensor(
# [[1.]
# [1.]
# [1.]
# [1.]
# [1.]], shape=(5, 1), dtype=float32)
# tf.Tensor(
# [[0.]
# [0.]
# [0.]
# [0.]
# [0.]], shape=(5, 1), dtype=float32)
# tf.Tensor(
# [[1.]
# [1.]
# [1.]
# [1.]
# [1.]
# [0.]
# [0.]
# [0.]
# [0.]
# [0.]], shape=(10, 1), dtype=float32)
# tf.Tensor(
# [[1. 0.]
# [1. 0.]
# [1. 0.]
# [1. 0.]
# [1. 0.]], shape=(5, 2), dtype=float32)

텐서플로의 계산 그래프 이해

  • 앞선 장에서 설명한 것처럼 텐서플로 2.x 버전에서는 즉시 실행 모드가 기본으로 활성화 되어 있기 때문에 계산 그래프를 만들지 않고 빠르게 개발과 테스트를 할 수 있다.
    • 모델을 다른 프레임워크와 공유하거나 실행 성능을 높이기 위해 계산 그래프를 만들려면 어떻게 해야 할까?
  • 간단한 예를 통해 텐서플로 2.x 버전에서 계산 그래프를 만든느 방법을 알아보자.
    • 랭크 0텐서 (스칼라) a, b, c 가 있을 때, z = 2 \times (a - b) + c 를 평가한다고 가정하자.
    • 이 계산은 아래 그림과 같은 계산 그래프로 표현할 수 있다.

  • 여기서 계산 그래프는 단순히 노드들의 네트워크이다. 각 노드는 한 개 이상의 입력 텐서를 받고 0개 이상의 출력 텐서를 반환하는 연산으로 표현할 수 있다.
  • 텐서플로 2.x에서는 계산 그래프를 만들지 않고 바로 텐서 z를 계산할 수 있다. a, b, c 는 스칼라이다. 텐서플로 상수로 이들을 정의하겠다.
import tensorflow as tf
import numpy as np

a = tf.constant(1)
b = tf.constant(2)
c = tf.constant(3)

z = 2 * (a - b) + c

print('2 * (a - b) + c => ', z.numpy())

### 결과
# 2 * (a - b) + c => 1
  • 이전 장에서 언급했듯이 텐서플로 1.x 버전은 계산 그래프를 만든 후 세션을 통해 그래프를 실행한다. 텐서플로 1.x 버전에서 계산 그래프를 만들고 실행하는 각 단계를 자세히 정리하면 다음과 같다.
    1. 비어 있는 새로운 계산 그래프를 만든다.
    2. 계산 그래프에 노드(텐서와 연산)을 추가한다.
    3. 그래프를 실행한다.
      1. 새로운 세션을 싲가한다.
      2. 그래프에 있는 변수를 초기화한다.
      3. 이 세션에서 계산 그래프를 실행한다.
  • 위 그림에서처럼 z = 2 \times (a - b) + c 를 평가하는 그래프를 만든다면 tf.graph()를 호출하여 그래프를 만들고 다음과 같이 노드를 추가한다.
## 텐서플로 1.x 방식
g = tf.Graph()

## 그래프에 노드를 추가한다
with g.as_default():
   a = tf.constant(1, name='a')
    b = tf.constant(2, name='b')
    c = tf.constant(3, name='c')
    z = 2 * (a - b) + c

## 그래프를 실행한다
with tf.compat.v1.Session(graph=g) as sess:
    print('2 * (a - b) + c => ', sess.run(z))
  • 이 코드에서 with g.as_default()를 사용하여 그래프 g에 노드를 추가했다.
    • 텐서플로 1.x에서는 명시적으로 그래프를 지정하지 않으면 항상 기본 그래프가 설정된다.
    • 그 다음 tf.Session을 호출하여 세션 객체를 만들고 tf.Session(graph=g)처럼 실행할 그래프를 매개변수로 전달한다.
    • 텐서플로 세션에서 그래프를 적재한 후에는 이 그래프에 있는 노드를 실행시킬 수 있다.
    • 여기서 텐서와 연산을 텐서플로의 계산 그래프 안에 정의했다는 것을 기억해야 한다. 텐서플로 세션은 그래프에 있는 연산을 실행한 후 결과를 평가하고 추출하기 위해 사용된다.
  • 그래프 g에 들어 있는 연산을 출력해 보자. tf.Graph() 객체의 get_operation() 메서드를 사용하면 된다.
print(g.get_operations())

### 결과
# [<tf.Operation 'a' type=Const>, <tf.Operation 'b' type=Const>, <tf.Operation 'c' type=Const>, <tf.Operation 'sub' type=Sub>, <tf.Operation 'mul/x' type=Const>, <tf.Operation 'mul' type=Mul>, <tf.Operation 'add' type=AddV2>]
  • 이번에는 그래프 g의 정의를 출력해 보자. as_graph_deg() 메서드를 호출하면 포맷팅된 문자열로 그래프 정의를 출력한다.
print(g.as_graph_def())

### 결과
# node {
# name: "a"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 1
# }
# }
# }
# }
# node {
# name: "b"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "c"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 3
# }
# }
# }
# }
# node {
# name: "sub"
# op: "Sub"
# input: "a"
# input: "b"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "mul/x"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "mul"
# op: "Mul"
# input: "mul/x"
# input: "sub"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "add"
# op: "AddV2"
# input: "mul"
# input: "c"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# versions {
# producer: 119
# }
  • 여기까지가 텐서플로 1.x 버전의 계산 그래프였다. 텐서플로 2.x 버전에서는 tf.function 데코레이터를 사용하여 일반 파이썬 함수를 호출 가능한 그래프 객체로 만든다. 마치 tf.Graph와 tf.Session을 합쳐 놓은 것처럼 생각할 수 있다.
    • 위 코드를 tf.function 데코레이터를 사용하여 다시 작성해 보겠다.
@tf.function
def simple_func():
   a = tf.constant(1)
    b = tf.constant(2)
   c = tf.constant(3)
   z = 2 * (a - b) + c
    return z

print('2 * (a - b) + c => ', simple_func().numpy())
  • simple_func() 함수에서 반환되는 텐서를 바로 numpy() 메서드로 변환하여 출력했다. simple_func() 함수는 보통 파이썬 함수처럼 호출할 수 있지만 데코레이터에 의해 객체가 바뀌었다.
print(simple_func.__class__)

### 결과
# <class 'tensorflow.python.eager.def_function.Function'>
  • 파이썬의 다른 데코레이터처럼 다음과 같이 스면 simple_func 객체를 좀 더 이해하기 쉽다.
import tensorflow as tf
import numpy as np

def simple_func():
   a = tf.constant(1, name='a')
    b = tf.constant(2, name='b')
   c = tf.constant(3, name='c')
    z = 2 * (a - b) + c
    return z

simple_func = tf.function(simple_func)
print('2 * (a - b) + c => ', simple_func().numpy())
  • tf.function으로 감싼 함수 안의 연산은 자동으로 텐서플로 그래프에 포함되어 실행된다. 이를 자동 그래프(AutoGraph) 기능이라고 한다.
  • simple_func가 만든 그래프에 있는 연산과 그래프 정의를 확인해 보자
    • 그래프의 정의를 얻으려면 1.x와 마찬가지로 Graph 객체의 as_graph_def() 메서드를 호출하면 된다.
con_func = simple_func.get_concrete_function()
print(con_func.graph.get_operations())
print(con_func.graph.as_graph_def())

### 결과
# [<tf.Operation 'a' type=Const>, <tf.Operation 'b' type=Const>, <tf.Operation 'c' type=Const>, <tf.Operation 'sub' type=Sub>, <tf.Operation 'mul/x' type=Const>, <tf.Operation 'mul' type=Mul>, <tf.Operation 'add' type=AddV2>, <tf.Operation 'Identity' type=Identity>]

# node {
# name: "a"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 1
# }
# }
# }
# }
# node {
# name: "b"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "c"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 3
# }
# }
# }
# }
# node {
# name: "sub"
# op: "Sub"
# input: "a"
# input: "b"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "mul/x"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "mul"
# op: "Mul"
# input: "mul/x"
# input: "sub"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "add"
# op: "AddV2"
# input: "mul"
# input: "c"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "Identity"
# op: "Identity"
# input: "add"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# versions {
# producer: 119
# }
  • Identity 노드가 추가된 것 외에는 전체 구조가 동일하다.

텐서플로의 변수

  • 텐서플로에서 변수는 특별한 종류의 텐서 객체이다. 훈련 기간 동안 모델 파라미터를 저장하고 업데이트할 수 있다.
    • 예컨대 신경망의 입력층, 은닉층, 출력층에 있는 가중치이다.
    • 변수를 정의할 때 초기 텐서값을 지정해야 한다.
  • 텐서플로 변수를 정의하는 방법은 다음과 같다.
tf.Variable(<initial-value>, name=<optional-name>)
  • tf.Variable에는 shape이나 dtype을 설정할 수 없다. 크기와 타입은 초깃값과 동일하게 설정된다.
  • 텐서플로 1.x에서 변수를 만들고 공유하기 위해 사용했던 tf.get_variable() 함수는 2.x 버전에서는 삭제되었다.
    • 하위 버전과 호환성을 위해 tensorflow.compat.v1 아래로 이동되었다.
    • 텐서플로 1.x의 실제 변수 값은 파이썬 프로그램 객체가 아니라 그래프 노드로 존재한다. 파이썬 객체는 변수가 평가된 결과를 얻기 위한 참조 역할을 할 뿐이다.
    • 따라서 그래프에서 변수 노드를 공유하기 위해 변수 범위(variable scope)를 복잡하게 다루어야 했다.
    • 텐서플로 2.x에서는 보통 파이썬 객체를 공유하듯 변수를 공유할 수 있다.
  • 다음 코드에서 두 버전의 차이를 살펴보겠다. 우선 텐서플로 1.x 형식의 변수이다. 그래프 컨텍스트 블록 안에서 변수를 그래프에 추가했다.
import tensorflow as tf
import numpy as np

g1 = tf.Graph()

with g1.as_default():
    w1 = tf.Variable(np.array([[1,2,3,4], [5,6,7,8]]), name='w1')

print(w1)

### 결과
# <tf.Variable 'w1:0' shape=(2, 4) dtype=int32>
  • 변수 w1은 실제 값이 포함되어 있지 않다. g1 그래프에 포함된 연산을 출력해 보면 변수 초깃값(initial_value)과 할당 연산(Assign)이 각각 존재한다.
print(g1.get_operations())

### 결과
# [<tf.Operation 'w1/Initializer/initial_value' type=Const>, <tf.Operation 'w1' type=VarHandleOp>, <tf.Operation 'w1/IsInitialized/VarIsInitializedOp' type=VarIsInitializedOp>, <tf.Operation 'w1/Assign' type=AssignVariableOp>, <tf.Operation 'w1/Read/ReadVariableOp' type=ReadVariableOp>]
  • 이 변수는 사용하기 전에 초기화를 해주어야 한다. 변수 하나씩 초기화 연산을 실행해도 되지만 변수가 많을 때는 번거롭다.
    • 앞서 보았던 global_variables_initializer() 함수의 역할이 여기에 있다. 이 함수는 그래프에 있는 변수 초기화 연산을 한꺼번에 모아 준다.
with g1.as_default():
   init = tf.compat.v1.global_variables_initializer()
    print(init.node_def)

### 결과
# name: "init"
# op: "NoOp"
# input: "^w1/Assign"
  • 텐서플로 1.x 버전에서 변수를 다룰 때는 초기화 외에 주의해야 할 점이 하나 더 있다.
    • 다음과 같이 일반적인 파이썬 프로그래밍으로 코드를 작성하면 w1은 텐서플로 변수가 아니라 덧셈 연산을 가리키는 텐서로 바뀐다.
    • w1을 여러번 실행하더라도 변수 값이 증가하지 않고 원래 초깃값에 1을 더한 결과만 반복된다.
with g1.as_default():
    w1 = w1 + 1
    print(w1)

### 결과
# Tensor("add:0", shape=(2, 4), dtype=int32)


with tf.compat.v1.Session(graph=g1) as sess:
   init = tf.compat.v1.global_variables_initializer()
    sess.run(init)
   print(sess.run(w1))
    print(sess.run(w1))

### 결과
# [[2 3 4 5]
# [6 7 8 9]]
# [[2 3 4 5]
# [6 7 8 9]]
  • 이런 결과는 w1이 그래프에 있는 노드를 참조하는 역할이라고 생각하면 이해하기 쉽다.
    • 이를 해결하려면 텐서플로 1.x에서는 assign() 메서드를 사용하여 변수 값을 증가시키는 연산을 만들어야 한다.
    • 그 다음 변수를 초기화하고 변수 값을 증기사키는 연산을 실행해야 한다.
g2 = tf.Graph()

with g2.as_default():
    w1 = tf.Variable(np.array([[1,2,3,4], [5,6,7,8]]), name='w1')
    w1 = w1.assign(w1+1)

with tf.compat.v1.Session(graph=g2) as sess:
   init = tf.compat.v1.global_variables_initializer()
    sess.run(init)
   print(sess.run(w1))
    print(sess.run(w1))

### 결과
# [[2 3 4 5]
# [6 7 8 9]]
# [[ 3 4 5 6]
# [ 7 8 9 10]]
  • 텐서플로 2.x에서는 텐서플로 변수가 파이썬 객체 자체이므로 훨씬 다루기 쉽다. 동일한 초깃값을 가진 변수 w2를 정의해보자.
w2 = tf.Variable(np.array([[1, 2, 3, 4], [5, 6, 7, 8]]), name='w2')
print(w2)

### 결과
# <tf.Variable 'w2:0' shape=(2, 4) dtype=int32, numpy=
# array([[1, 2, 3, 4],
# [5, 6, 7, 8]])>
  • 텐서플로 1.x와 달리 2.x에서는 w2+1처럼 계산이 덧셈 연산의 출력 텐서가 아니라 덧셈이 적용된 상수 텐서를 반환한다.
print(w2 + 1)

### 결과
# tf.Tensor(
# [[2 3 4 5]
# [6 7 8 9]], shape=(2, 4), dtype=int32)
  • 따라서 텐서플로 2.x에서는 변수 값을 증가시키려면 이 덧셈 결과를 assign() 메서드로 반복해서 전달하면 된다. 호출 결과는 변수에 즉각 반영된다.
w2.assign(w2 + 1)
print(w2.numpy())

### 결과
# [[2 3 4 5]
# [6 7 8 9]]

w2.assign(w2 + 1)
print(w2.numpy())

### 결과
# [[ 3 4 5 6]
# [ 7 8 9 10]]
  • w2 변수를 다시 출력해 보면 numpy 속성이 바뀐 것을 볼 수 있다.
print(w2)

### 결과
# <tf.Variable 'w2:0' shape=(2, 4) dtype=int32, numpy=
# array([[ 3, 4, 5, 6],
# [ 7, 8, 9, 10]])>

tf.keras API 자세히 배우기

Sequential 모델

  • Sequential 모델은 층을 순서대로 쌓은 모델을 만든다. 13장에서 완전 연결 층인 Dense 층을 쌓은 모델을 만들어 보았는데, 15, 16장에서 Sequential 모델에 합성곱 층과 순환층을 적용해 보겠다.
  • 여기서는 간단한 회귀 분석을 위한 모델을 만든다. \hat{y} = wx + b 인 선형 회귀 모델을 구현하는 것이 목표이다.
    • 먼저 make_random_data 함수를 이용하여 하나의 특성을 가진 랜덤한 회귀용 데이터를 만들고 시각화 해 보자
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(0)

def make_random_data():
   x = np.random.uniform(low=-2, high=2, size=200)
    y = []

   for t in x:
        r = np.random.normal(loc=0.0, scale=(0.5 + t*t/3), size=None)
       y.append(r)   

    return x, 1.726 * x - 0.84 + np.array(y)

x, y = make_random_data()

plt.plot(x, y, 'o')
plt.show()

  • 샘플 개수는 200개로 이중 150개를 훈련세트, 50개를 테스트 세트로 나누어 놓겠다.
    • 모델을 튜닝하기 위해서는 검증 세트가 필요한데, 친절하게도 tf.keras는 모델을 훈련할 때 간단한 설정만으로도 검증 세트의 점수를 자동으로 계산해 주기 때문에 훈련 세트와 테스트 세트로만 나눈다.
x_train, y_train = x[:150], y[:150]
x_test, y_test = x[150:], y[150:]
  • 예제 데이터가 준비되었으므로 모델을 만들어보겠다. 1차원 데이터셋이므로 출력 유닛 하나를 가진 간단한 완전 연결 층 하나를 추가하겠다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.summary()

### 결과
# Model: "sequential"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# dense (Dense) (None, 1) 2
# =================================================================
# Total params: 2
# Trainable params: 2
# Non-trainable params: 0
  • 유닛이 하나뿐이므로 모델 파라미터 개수는 가중치와 절편 두 개 뿐이다. 이 모델을 컴파일하고 훈련 세트에서 학습시켜보자.
model.compile(optimizer='sgd', loss='mse')

history = model.fit(x_train, y_train, epochs=500, validation_split=0.3)

### 결과
# Train on 105 samples, validate on 45 samples
# Epoch 1/500
# 2020-05-02 10:03:27.367560: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cublas64_100.dll
# 105/105 [==============================] - 1s 8ms/sample - loss: 5.2795 - val_loss: 3.1418
# Epoch 2/500
# 105/105 [==============================] - 0s 133us/sample - loss: 4.3258 - val_loss: 2.7091
# ...
# Epoch 500/500
# 105/105 [==============================] - 0s 114us/sample - loss: 0.7574 - val_loss: 0.9054
  • compile() 메서드의 loss 매개변수에 회귀 문제를 위한 평균 제곱 오차(mean squared error) 손실 함수 ‘mse’를 선택했다.
    • 회귀 문제 이외에도 평균 절댓값 오차(mean absolute error) 손실 함수인 ‘mae’를 지정할 수도 있다.
    • 13장에서 다중 클래스 분류 문제를 위한 범주형 크로스 엔트로피 ‘categorical_crossentropy’를 사용해 보았다. 이진 분류 문제에는 이진 크로스 엔트로피 ‘binary_crossentropy’를 선택한다.
  • optimizer 매개변수에는 경사 하강법 옵티마이저를 지정한다.
    • 13장에서는 SGD 클래스의 객체를 직접 만들어 전달했다. SGD 클래스의 기본값을 사용할 때 간편하게 ‘sgd’로 지정할 수 있다.
    • SGD 클래스의 기본값은 학습률 lr이 0.001이고 momentum과 decay는 0이다.
    • 이외에도 인기 있는 여러 종류의 옵티마이저를 선택할 수 있는데, RMSprop 알고리즘을 위한 ‘rmsprop’, Adagrad는 ‘adagrad’, Adam은 ‘adam’을 선택한다.
  • fit() 메서드에서 검증 세트의 크기를 30%로 설정했다.
    • 훈련 과정을 출력해주는 verbose 매개변수의 기본값은 1로 진행바와 함께 검증 점수를 출력해준다.
    • verbose 매개변수를 0으로 지정하면 아무것도 출력되지 않는다.
    • fit() 메서드에서 반환받은 History 객체의 history 딕셔너리에는 에포크마다 계산한 손실 함수 값이 저장되어 있다. 훈련 세트에 대한 손실 함수 값은 ‘loss’에 담겨 있다.
    • 이 값을 그래프로 그리면 다음과 같다.
epochs = np.arange(1, 500+1)

plt.plot(epochs, history.history['loss'], label='Training losss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

함수형 API

  • Sequential 모델은 층을 순서대로 쌓은 네트워크를 만든다. 이따금 이보다 더 복잡한 모델을 만들어야 할 때가 있다. tf.keras의 함수형 API는 다양한 토폴로지(topology)의 네트워크를 만들 수 있다.
    • 함수형 API에서는 입력과 출력 사이에 원한느 층을 자유롭게 조합할 수 있다.
    • 예제를 만들면서 살펴보자
import tensorflow as tf
from tensorflow.keras import Model, Input

input = tf.keras.Input(shape=(1,))
output = tf.keras.layers.Dense(1)(input)
  • 먼저 튜플로 입력 크기를 지정하여 Input 클래스의 객체를 만든다. 그 다음 앞서 보았던 Dense 클래스를 마치 함수처럼 호출한다. 이는 클래스 객체를 함수처럼 호출할 수 있는 파이썬의 특수한 __call__() 메서드를 사용한 것이다.
    • 마지막 코드는 다음과 같이 쓸 수 있다.
# 이렇게 쓰거나
dense = tf.keras.layers.Dense(1)
output = dense(input)

# 이렇게 쓸 수 있다
dense = tf.keras.layers.Dense(1)
output = dense.__call__(input)
  • Dense 층의 객체(dense)를 네트워크의 다른 부분에 재사용하지 않는다면(즉, 이 층의 가중치를 공유하지 않는다면) 객체 생성과 동시에 호출하는 것이 편리하다.
  • 그 다음 Model 클래스에 입력과 출력을 전달하여 신경망 모델을 만든다.
model = tf.keras.Model(input, output)
model.summary()

### 결과
# Model: "model"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# input_1 (InputLayer) [(None, 1)] 0
# _________________________________________________________________
# dense (Dense) (None, 1) 2
# =================================================================
# Total params: 2
# Trainable params: 2
# Non-trainable params: 0
  • 이전과 동일하게 두 개의 모델 파라미터를 가진 Dense 층 하나로 이루어져 있다.
    • Sequential 모델에서는 없었던 InputLayer는 입력 데이터를 위한 층이다. 이 층은 학습되는 파라미터를 가지고 있지 않다.
    • 사실 Sequential 모델은 summary() 메서드 출력에서 나타나지는 않지만 자동으로 InputLayer를 추가해 준다. Sequential 모델의 model._layers 속성을 출력하여 모델에 들어 있는 층을 모두 확인해 보면 좋다.
  • 함수형 API로 모델을 만든 후 컴파일과 훈련하는 단계는 Sequential 모델과 완전히 동일하다. 이전과 동일한 매개변수로 선형 회귀 모델을 훈련시키고 그래프로 그려 보자
    • 이번에는 훈련 손실과 함께 검증 세트에 대한 손실도 같이 그래프로 출력해 보겠다. 검증 세트에 대한 손실은 ‘val_loss’ 로 확인할 수 있다.
model.compile(optimizer='sgd', loss='mse')
history = model.fit(x_train, y_train, epochs=500, validation_split=0.3)

epochs = np.arange(1, 500+1)

plt.plot(epochs, history.history['loss'], label='Training loss')
plt.plot(epochs, history.history['val_loss'], label='Validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

tf.keras 모델의 저장과 복원

  • tf.keras API로 만든 모델을 저장하고 복원하는 것은 아주 쉽다. 우선 앞서 만든 모델 가중치를 save_weights() 메서드로 저장해 보자.
model.save_weights('simple_weights.h5')
  • 위 코드를 실행하면 현재 폴더에 ‘simple_weights.h5’ 파일을 생성하고 모든 층의 가중치를 저장한다.
    • save_weights 메서드는 기본적으로 텐서플로의 체크포인트(checkpoint) 포맷으로 가중치를 저장한다.
    • save_format 매개변수를 ‘h5’로 지정하여 HDF5 파일 포맷으로 저장할 수 있다. 이 메서드는 똑똑하게도 파일 이름의 확장자가 .h5이면 자동으로 HDF5 포맷으로 저장한다.
    • 저장된 가중치를 사용하려면 새로운 모델을 만들고 load_weights() 메서드를 사용하여 가중치를 로드할 수 있다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.compile(optimizer='sgd', loss='mse')

model.load_weights('simple_weights.h5')
  • 이 코드는 모델을 훈련시키지 않고 이전에 학습한 가중치를 사용한다. 가중치가 올바르게 로드되었는지 확인하기 위해 테스트 세트에 대한 손실 점수를 계산해 보자. 테스트 세트를 평가하려면 evaluate() 메서드를 사용한다.
model.evaluate(x_test, y_test)
  • 가중치 외에 모델 전체를 저장하기 위해 tf.keras 모델은 save() 메서드를 사용하여 가중치와 네트워크 구조까지 HDF5 포맷으로 저장할 수 있다. 모델을 저장하는 코드는 다음과 같다.
model.save('simple_model.h5')
  • 저장된 모델을 다시 로드하려면 load_model() 함수를 사용하면 된다. 저장된 모델 구조를 그대로 사용하기 때문에 다시 층을 추가할 필요가 없다.
model = tf.keras.models.load_model('simple_model.h5')
  • 모델을 훈련하는 동안 ModelCheckpoint 콜백을 사용하여 최고의 성능을 내는 가중치를 저장할 수 있다.
    • ModelCheckpoint 콜백은 더 이상 성능이 개선되지 않을 때 훈련을 멈추게 하는 EarlyStopping 콜백과 함께 사용하는 경우가 많다.
  • EarlyStopping 콜백 클래스는 기본적으로 검증 손실을 모니터링 한다. patience 매개변수가 지정한 에포크 횟수 동안 모니터링 지표가 개선되지 않으면 훈련을 중지한다.
  • 다음 코드는 ModelCheckpoint 콜백을 사용하는 예이다. 검증 손실을 모니터링하면서 최상의 모델 가중치를 저장한다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.compile(optimizer='sgd', loss='mse')

callback_list = [tf.keras.callbacks.ModelCheckpoint(filepath='my_model.h5', monitor='val_loss', save_best_only=True), tf.keras.callbacks.EarlyStopping(patience=5)]

history = model.fit(x_train, y_train, epochs=500, validation_split=0.2, callbacks=callback_list)
  • 손실 그래프를 그려 보면 검증 손실이 감소되지 않으므로 훈련이 일찍 멈춘 것을 확인할 수 있다.
epochs = np.arange(1, len(history.history['loss'])+1)

plt.plot(epochs, history.history['loss'], label='Training loss')
plt.plot(epochs, history.history['val_loss'], label='Validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

  • 저장된 모델을 로드한 후 ModelCheckpoint 콜백에서 저장한 가중치를 load_weights() 메서드로 적재한다. 이렇게 하면 모델 구조와 함께 최상의 모델 가중치를 유지할 수 있다.
model = tf.keras.models.load_model('simple_model.h5')
model.load_weights('my_model.h5')
model.evaluate(x_test, y_test)
  • 여기까지 학습한 선형 모델을 그래프로 그려보자. 짙은 원은 훈련 세트이고, 밝은 원은 테스트 세트이다.
x_arr = np.arange(-2, 2, 0.1)
y_arr = model.predict(x_arr)

plt.figure()
plt.plot(x_train, y_train, 'bo')
plt.plot(x_test, y_test, 'bo', alpha=0.3)
plt.plot(x_arr, y_arr, '-r', lw=3)
plt.show()

계산 그래프 시각화

  • 텐서플로의 놀라운 기능 중 하나는 텐서보드(TensorBoard)이다. 텐서보드는 모델의 학습 과정 뿐만 아니라 계산 그래프도 시각화할 수 있는 모듈이다.
    • 계산 그래프를 시각화 하면 노드 사이 연결을 보고 의존성을 확인하고 필요할 때 모델을 디버깅 할 수 있다.
  • 케라스 API에서는 콜백 함수를 통해 텐서 보드를 사용한다.
    • (예제는 앞선 절에서 했던 것을 그대로 사용한다)
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1, kernel_regularizer='l2'))

callback_list = [TensorBoard()]

model.compile(optimizer='sgd', loss='mse')

history = model.fit(x_train, y_train, epochs=500, validation_split=0.3, callbacks=callback_list)
  • TensorBoard 콜백은 기본적으로 현재 폴더 아래에 log 폴더를 만들고 그래프와 손실 값을 저장한다. 터미널에서 다음 명령을 실행해 보자.
tensorboard --logdir=logs/

[실행 결과]
TensorBoard 2.0.2 at http://localhost:6006/ (Press CTRL+C to quit)
  • 위 결과와 같이 URL 주소가 출력되는데, 이 주소를 브라우저 주소 표시줄에 붙여 넣으면 아래 그림과 같이 텐서보드에 접속할 수 있다.

  • 그래프 탭을 클릭하면 모델에 상응하는 그래프가 나타난다.

  • 큰 사각 상자는 서브 네트워크를 나타낸다. 서브 네트워크의 모든 컴포넌트는 이 사각 상자 안에 그룹지어 있고, 이 상자를 확장하여 상세 사항을 살펴 볼 수 있다.
    • 마우스로 상자를 더블 클릭하면 상자가 확장된다.
    • 아래 그림과 같이 dense 서브 네트워크의 상세 사항을 볼 수 있다.

  • 그래프를 살펴보면 dense 네트워크는 두 개의 가중치 켄서 kernel과 bias를 가지고 있다. kernel은 입력과 곱한(MatMul) 후에 bias와 더하는 (BiasAdd) 과정이 그래프에 잘 나타나 있다.

텐서보드 익숙하게 다루기

케라스의 층 그래프 그리기

  • 케라스는 층의 그래프를 그릴 수 있는 plot_model() 함수를 제공한다. 이 함수를 사용하려면 pydot 파이썬 패키지를 설치해야 한다.
    • 두 개의 은닉층을 가진 간단한 모델을 만들어 plot_model() 함수를 적용해 보겠다.
input = tf.keras.Input(shape=(784,))
hidden = tf.keras.layers.Dense(100)(input)
output = tf.keras.layers.Dense(10)(hidden)

model = tf.keras.Model(input, output)
  • plot_model() 함수를 호출한다. to_file 매개변수에는 그래프에 저장할 파일 이름을 지정한다.
tf.keras.utils.plot_model(model, to_file='model_1.png')
  • model_1.png 파일을 열어 보면 다음과 같다. 네모 상자 안에 층 이름과 클래스 이름을 보여준다.

  • plot_model 함수의 show_shapes 매개변수를 True로 지정하면 층 입력과 출력 크기를 함께 나타내 준다.

머신 러닝 교과서/ 텐서플로를 사용하여 신경망 훈련

고성능 머신 러닝 라이브러리 텐서플로

  • 텐서플로는 머신 러닝 작업 속도를 크게 높여준다.
    • 기본적으로 파이썬은 GIL(Global Interpreter Lock) 때문에 하나의 코어만 사용할 수 있다. 멀티프로세싱 라이브러릴 사용해서 여러 개의 코어에 연산을 분산할 수 있지만 최성능 데스크톱이라도 16개 이상의 코어를 가진 경우는 거의 없다.
    • 그러나 아주 간단한 다층 퍼셉트론이라도 하나의 은닉층이 100개의 유닛을 갖기 쉽기기 때문에, 단일 프로세스로는 수행하기가 어렵다.
    • 이 문제에 대한 해결책은 GPU를 사용하는 것이다.  최신 CPU 가격의 60% 정도로 코어 개수가 240배나 많고 초당 부동소수점 연산을 12배나 많이 할 수 있는 GPU를 구매할 수 있다.
    • 특정 GPU에 맞는 코드를 작성하는 일은 파이썬 인터프리터에서 코드를 실행하는 것처럼 간단하지 않다. CUDA나 OpenCL처럼 특정 GPU를 사용할 수 있도록 도와주는 패키지가 있지만 CUDA나 OpenCL에서 코드를 작성하는 것은 머신 러닝 알고리즘을 구현하고 실행하기에 편리한 환경이 아니다. 다행히 이런 이유 때문에 텐서플로가 개발되었다.

텐서플로란?

  • 텐서플로는 머신 러닝 알고리즘을 구현하고 실행하기 위한 프로그래밍 인터페이스로서 확장이 용이하고 다양한 플랫폼을 지원하며 딥러닝을 위한 간단한 인터페이스도 포함하고 있다.
    • 텐서플로는 구글 브레인 팀 연구자와 엔지니어들이 개발했는데, 초창기에는 구글 내부 용도로 개발되었지만 2015년 11월 오픈 소스 라이선스로 릴리스 되었다.
  • 머신 러닝 모델의 훈련 성능을 향상시키기 위해 텐서플로는 CPU와 GPU를 모두 활용할 수 있다. 그러나 GPU를 사용할 때 최대 성능을 이끌어낼 수 있다.
    • 텐서플로는 공식적으로 CUDA 기반의 GPU를 지원한다. OpenCL 기반의 GPU 지원은 아직 실험적이다.
    • 텐서플로는 여러 프로그래밍 언어를 지원하는데 파이썬 API는 완전히 성숙되어 있기 때문에 많은 머신 러닝, 딥러닝 기술자들에게 인기가 높다.
    • 텐서플로는 공식적인 C++ API도 가지고 있다. 자바, 하스켈, Node Js, Go 같은 언어를 위한 API는 아직 안정적이지 않다.
  • 텐서플로 연산은 데이터 흐름을 표현하는 유향 그래프(directed graph)를 구성하여 수행된다.

텐서플로 학습 방법

  • 먼저 텐서플로의 저수준 API를 배워보겠다. 이런 방식으로 모델을 만드는 것이 처음에는 조금 번거로울 수 있지만 프로그래머가 기본 연산을 연결하여 복잡한 모델을 개발할 수 있도록 자유도를 높여주는 장점이 있다.
    • 텐서플로 1.1.0 버전부터 저수준 API 위에 고수준 API가 추가되었다.
    • 텐서플로 1.4.0 버전부터는 케라스가 contrib 모듈에서 벗어나 핵심 모듈(tf.keras)이 되었고, 텐서플로 2.0에서는 tf.keras가 최상위 파이썬 API가 되었다. 이런 고수준 API를 사용하면 훨씬 빠르게 모델을 만들고 실험할 수 있다.
  • 우선 저수준 API를 배운 후에 고수준 API인 tf.keras를 살펴보겠다.

텐서플로 시작

  • 파이썬의 pip 인스톨러를 사용하여 PyPI로부터 텐서플로를 설치할 수 있다.
pip install tensorflow
  • GPU를 사용하려면 CUDA 툴킷과 NVIDIA cuDNN을 설치해야 한다. GPU용 텐서플로는 다음과 같이 설치할 수 있다.
pip install tensorflow-gpu
  • 텐서플로는 노드 집합으로 구성된 계산 그래프를 바탕으로 한다. 각 노드는 0개 이상의 입력이나 출력을 갖는 연산을 나타낸다. 계산 그래프의 에지를 따라 이동하는 값을 텐서(tensor)라고 한다.
  • 텐서는 스칼라(scalar), 벡터, 행렬이 일반화된 것으로 생각할 수 있다.
    • 구체적으로 말해 스칼라는 랭크 0 텐서이고, 백터는 랭크 1 텐서, 행렬은 랭크 2 텐서로 정의한다.
    • 행렬을 세 번째 차원으로 쌓아 올리면 랭크 3 텐서가 된다.
  • 텐서플로의 스칼라를 사용한 첫 번째 예제로 1차원 데이터셋 x 와 가중치 w , 절편 b 로부터 최종입력 z 를 계산해 보겠다.

z = w \times x + b

  • 아래 코드는 텐서플로 1.x 방식의 저수준 API를 사용하여 이 식을 구현한 것이다.
import tensorflow as tf

# 그래프를 생성한다
g = tf.Graph()

with g.as_default():
    x = tf.compat.v1.placeholder(dtype=tf.float32, shape=(None), name='x')
   w = tf.Variable(2.0, name='weight')
    b = tf.Variable(0.7, name='bias')
    z = w * x + b

    init = tf.compat.v1.global_variables_initializer()

# 세션을 만들고 그래프 g를 전달한다.
with tf.compat.v1.Session(graph=g) as sess:
    ## w와 b를 초기화한다.
    sess.run(init)

    ## z를 평가한다.
   for t in [1.0, 0.6, -1.8]:
        print('x=%4.1f --> z=%4.1f' % (t, sess.run(z, feed_dict={x:t})))
  • 보통 텐서플로 1.x 방식의 저수준 API로 개발할 때 입력 데이터(x, y 또는 튜닝이 필요한 다른 파라미터)를 위해 플레이스홀더(placeholder)를 정의한다.
    • 그 다음 가중치 행렬을 정의하고 입력에서부터 출력까지 연결된 모델을 만든다.
    • 최적화 문제인 경우에는 손실 함수 또는 비용 함수를 정의하고 어떤 최적화 알고리즘을 사용할지 결정한다.
    • 텐서플로의 그래프는 정의된 모든 요소를 노드로 포함시킨다.
  • 그 다음 세션을 만들고 변수를 초기화한다. 앞서 shape=(None) 으로 플레이스홀더 x를 만들었다. 입력 데이터 크기를 정의하지 않았으므로 다음과 같이 배치 데이터를 한 번에 전달하여 원소 하나씩 차례대로 모델에 주입할 수 있다.
with tf.compat.v1.Session(graph=g) as sess:
    sess.run(init)
    print(sess.run(z, feed_dict={x:[1., 2., 3.]}))
  • 이 코드에 있는 텐서 z를 출력해 보면 다음과 같다.
print(z)
# Tensor("add:0", dtype=float32)
  • 텐서 이름 “add:0″은 z가 덧셈 연산의 첫 번째 출력이라는 것을 알려준다.
    • 텐서 z에는 실제 어떤 값도 들어있지 않다. 세션을 열고 z를 평가해야 비로소 값을 얻을 수 있다.
    • 이렇게 텐서플로 1.x 방식에서는 그래프 정의 단계와 실행 단계로 크게 나누어져 있다.
    • z를 평가할 때 필요한 데이터가 있다면 feed_dict 매개변수를 통해 플레이스홀더에 데이터를 주입해야 한다.
  • 텐서플로 2.x 방식으로 위와 동일한 계산을 만들어 보겠다.
import tensorflow as tf

w = tf.Variable(2.0, name='weight')
b = tf.Variable(0.7, name='bias')

for x in [1.0, 0.6, -1.8]:
    z = w * x + b
    print('x=%4.1f --> z=%4.1f' % (x, z))
  • 훨씬 간단하게 수행할 수 있다. Session 객체를 만들어서 플레이스홀더에 데이터를 주입하는 대신 파이썬 리스트를 사용하여 직접 z 값을 계산했다.
    • 텐서플로 2.x 방식에서는 변수 초기화 과정도 불필요하다.
  • z 값을 출력하면 다음과 같다.
print(z)
tf.Tensor(-2.8999999, shape=(), dtype=float32)
  • 여기서 z는 덧셈 연산의 출력 텐서가 아니라 실제 값을 가진 텐서이다.
  • 또 다음과 같이 for 반복문을 사용하지 않고 리스트 데이터를 한 번에 계산할 수도 있다.
z = w * [1., 2., 3.] + b
print(z.numpy())
  • 위 코드처럼 텐서 값을 넘파이 배열로 출력하려면 numpy() 메서드를 사용하면 된다.

배열 구조 다루기

  • 텐서플로에서 배열 구조를 어떻게 다루는지 알아보자.
    • 다음 코드를 실행하면 3 x 2 x 3 크기의 간단한 랭크 3 텐서를 만든다. 그 다음 크기를 바꾸고 텐서플로의 최적화된 연산을 사용하여 각 열의 합을 계산한다.
import tensorflow as tf
import numpy as np

x_array = np.arange(18).reshape(3, 2, 3)
x2 = tf.reshape(x_array, shape=(-1, 6))

## 각 열의 합을 계산한다
xsum = tf.reduce_sum(x2, axis=0)

## 각 열의 평균을 계산한다
xmean = tf.reduce_mean(x2, axis=0)

print('입력 크기:', x_array.shape)
print('크기가 변경된 입력:\n', x2.numpy())
print('열의 합:\n', xsum.numpy())
print('열의 평균:\n', xmean.numpy())

# 결과
# 입력 크기: (3, 2, 3)
# 크기가 변경된 입력:
# [[ 0 1 2 3 4 5]
# [ 6 7 8 9 10 11]
# [12 13 14 15 16 17]]
# 열의 합:
# [18 21 24 27 30 33]
# 열의 평균:
# [ 6 7 8 9 10 11]
  • reshape 메서드의 첫 번째 차원으로 -1을 사용했는데, 텐서 크기를 바꿀 때 특정 차원에 -1을 사용하면 텐서 전체 크기와남은 차원에 따라 계산된다.
    • 예컨대 tf.reshape(tensor, shape=(-1,))은 일렬로 텐서를 펼친다.
  • tf.reduce_sum과 tf.reduce_mean은 각각 텐서의 합과 평균을 계산하는데, axis 매개변수는 축소될 차원을 지정한다.
    • axis=0은 행 차원을 축소시켜 각 열의 합이나 평균을 계산한다.
    • axis=1은 열 차원을 축소시켜 각 행의 합이나 평균을 계산한다.
    • axis 매개변수의 기본값은 None으로 모든 차원을 축소하여 스칼라 텐서를 반환한다.

텐서플로 저수준 API로 간단한 모델 개발

  • 텐서플로를 이용하여 최소 제곱법(Ordinary Least Squares, OLS) 회귀를 구현해보자
  • 먼저 10개의 훈련 샘플로 이루어진 작은 1차원 데이터셋을 만든다.
import tensorflow as tf
import numpy as np

X_train = np.arange(10).reshape((10, 1))
y_train = np.array([1.0, 1.3, 3.1, 2.0, 5.0, 6.3, 6.6, 7.4, 8.0, 9.0])
  • 이 데이터셋으로 입력 x 에서 출력 y 를 예측하는 선형 회귀 모델을 훈련하려고 한다. 이 모델을 TfLinreg라는 이름의 클래스로 구현해 보자.
    • 이 클래스에서 훈련되는 변수인 가중치 w 와 절편 b 를 정의하자.
    • z = w \times x + b 처럼 선형 회귀 모델을 정의한 후 평균 제곱 오차(Mean Squared Error, MSE)를 비용함수로 정의한다.
    • 모델 가중치를 학습하기 위해 경사 하강법 옵티마이저를 사용한다.
    • 전체 코드는 아래와 같다.
class TfLinreg(object):

   def __init__(self, learning_rate=0.01):
        ## 가중치와 절편을 정의한다
       self.w = tf.Variable(tf.zeros(shape=(1)))
        self.b = tf.Variable(tf.zeros(shape=(1)))

       ## 경사 하강법 옵티마지어를 설정한다
    self.optimizer = tf.keras.optimizers.SGD(lr=learning_rate)   

   def fit(self, X, y, num_epochs=10):
       ## 비용 함수의 값을 저장하기 위한 리스트를 정의한다
        training_costs = []

       for step in range(num_epochs):
           ## 자동 미분을 위해 연산 과정을 기록한다
            with tf.GradientTape() as tape:
               z_net = self.w * X + self.b
               z_net = tf.reshape(z_net, [-1])
               sqr_errors = tf.square(y - z_net)
               mean_cost = tf.reduce_mean(sqr_errors)           

           ## 비용함수에 대한 가중치의 그래디언트를 계산한다
            grads = tape.gradient(mean_cost, [self.w, self.b])

            ## 옵티마이저에 그래디언트를 반영한다
            self.optimizer.apply_gradients(zip(grads, [self.w, self.b]))

           ## 비용 함수의 값을 저장한다
           training_costs.append(mean_cost.numpy())       

        return training_costs
  • 저수준 API를 사용하여 신경망을 구현할 때 그리디언트를 가장 손쉽게 계산하는 방법은 tf.GradientTape() 컨텍스트(context)를 사용하는 것이다.
    • GradientTape()는 with 블록 안에 있는 텐서플로 변수를 자동으로 감시한다. 변수에 대한 그래디언트를 구하려면 GradientTape 객체의 gradient() 메서드를 사용한다.
    • 이 메서드의 첫 번째 매개변수는 미분 대상 텐서 또는 텐서 리스트고, 두 번째 매개변수는 그래디언트를 구하려는 변수의 리스트이다.
    • 똑똑하게도 텐서플로는 gradient(0 메서드가 호출된 후 사용한 자원을 자동으로 반납한다.
  • 그래디언트를 계산했다면 옵티마이저에 이를 전달하여 가중치를 업데이트해야 한다. 옵티마이저의 apply_gradient() 메서드는 그래디언트와 변수를 쌍으로 하는 튜플의 리스트를 입력받는다.
    • 위 코드에서는 파이썬의 zip 반복자를 사용하여 이를 간결하게 표현했다.
  • 모델을 정의했으니 매개변수 기본값으로 이 클래스의 인스턴스를 만들고 모델을 훈련해 보자.
lrmodel = TfLinreg()
training_costs = lrmodel.fit(X_train, y_train)
  •  열 번의 에포크 동안 계산된 훈련 비용을 그래프로 그려서 모델이 수렴하는지 확인해 보자
import matplotlib.pyplot as plt

plt.plot(range(1, len(training_costs) + 1), training_costs)
plt.tight_layout()
plt.xlabel('Epoch')
plt.ylabel('Training Cost')
plt.show()

  • 비용 함수 값을 보면 이 데이터셋에 잘 맞는 회귀 모델을 만든 것 같다. 입력 특성을 기반으로 예측을 만들기 위해 새로운 메서드를 추가해 보자. 이 메서드는 타깃 데이터 없이 특성 값만 사용한다.
def predict(self, X):
        return self.w * X + self.b
  • 이제 훈련 데이터에서 학습된 선형 회귀 곡선을 그려 보자
plt.scatter(X_train, y_train, marker='s', s=50, label='Training Data')
plt.plot(range(X_train.shape[0]), lrmodel.predict(X_train), color='gray', marker='o', markersize=6, linewidth=3, label='LinReg Model')
plt.xlabel('x')
plt.ylabel('y')
plt.legend()
plt.tight_layout()
plt.show()

tf.keras API로 다층 신경망 훈련

  • 케라스는 2015년 초에 개발되기 시작했고 현재 가장 인기 있고 널리 사용되는 딥러닝 라이브러리 중 하나이다. 케라스는 씨아노(Theano)와 텐서플로를 백엔드(backend)로 사용한다.
  • 텐서플로와 비슷하게 케라스는 GPU를 사용하여 신경망 훈련을 가속시킬 수 있다. 매우 직관적이고 사용하기 쉬운 API를 가진 것이 눈에 띄는 특징이다. 단 몇 줄의 코드만으로 신경망을 구현할 수 있다.
  • 케라스는 처음에 씨아노를 백엔드로 사용하는 독립된 API로 릴리스 되었고 나중에 추가로 텐서플로를 지원했다. 텐서플로 버전 1.1.0 부터는 케라스가 텐서플로에 통합되었다. 텐서플로 1.1.0 이상을 쓰고 있다면 케라스를 따로 설치할 필요가 없다.
  • 텐서플로 1.4.0 부터는 케라스가 contrib 서브모듈 밖으로 이동하여 텐서플로의 핵심 모듈(tf.keras)가 되었고, 텐서플로 2.0에서는 tf.keras가 표준 파이썬 API가 되었다.
  • 케라스를 사용하면 텐서플로를 사용하여 신경망 모델을 손쉽게 구현할 수 있다.

훈련 데이터 준비

  • tf.keras 고수준 API를 사용하여 어떻게 신경망을 훈련하는지 알아보자. 이를 위해 MNIST 데이터셋의 손글씨 숫자를 분류하는 다층 퍼셉트론을 만들어보자.
  • 파일을 내려받은 후 현재 작업 디렉터리 안에 mnist 폴더를 만들어 옮긴다. 그 다음 12장에서 구현한 load_mnist 함수를 사용하여 훈련 데이터셋과 테스트 데이터셋을 로드한다.
X_train, y_train = mn.load_mnist('mnist/', kind='train')
X_test, y_test = mn.load_mnist('mnist/', kind='t10k')

mean_vals = np.mean(X_train, axis=0)
std_val = np.std(X_train)

X_train_centered = (X_train - mean_vals) / std_val
X_test_centered = (X_test - mean_vals) / std_val

del X_train, X_test

print(X_train_centered.shape, y_train.shape)
print(X_test_centered.shape, y_test.shape)
  • 일관된 결과를 만들기 위해 넘파이 난수 초깃값을 설정하자
np.random.seed(123)
  • 훈련 데이터를 준비하기 위해 클래스 레이블(0-9 사이의 정수)을 원-핫 인코딩으로 변경하자. tf.keras에는 이를 위한 편리한 도구가 준비되어 있다.
y_train_onehot = tf.keras.utils.to_categorical(y_train)

print('처음 3개 레이블: ', y_train[:3])
print('\n처음 3개 레이블 (원-핫):\n ', y_train_onehot[:3])

### 결과
# 처음 3개 레이블: [5 0 4]
# 처음 3개 레이블 (원-핫):
# [[0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
# [1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 1. 0. 0. 0. 0. 0.]]
  • to_categorical 함수는 입력 텐서에서 가장 큰 정수를 찾아 원-핫 인코딩 크기를 결정한다. 앞 예에서처럼 정수 0은 첫 번째 원소가 1이 되는 식이다.
    • num_classes 매개변수를 사용하여 입력의 최댓값보다 더 큰 원-핫 인코딩을 만들 수도 있다.

피드포워드 신경망 구성

  • 이제 신경망을 구현하겠다. 간단하게 세 개의 완전 연결된 층을 만들어 보자.
    • 처음 두 개의 층은 하이퍼볼릭 탄젠트(tanh) 활성화 함수를 가진 50개의 은닉 유닛으로 이루어진다.
    • 마지막 층은 열 개의 클래스 레이블에 해당하는 열 개의 은닉 유닛을 가진다.
    • 마지막 층은 각 클래스의 확률을 계산하기 위해 소프트맥스(softmax) 함수를 사용한다.
    • 다음 코드처럼 tf.keras는 이런 작업을 매우 간단하게 처리할 수 있다
model = tf.keras.models.Sequential()

model.add(tf.keras.layers.Dense(units=50, input_dim=X_train_centered.shape[1], kernel_initializer='glorot_uniform', bias_initializer='zeros', activation='tanh'))

model.add(tf.keras.layers.Dense(units=50, input_dim=50, kernel_initializer='glorot_uniform', bias_initializer='zeros', activation='tanh'))

model.add(tf.keras.layers.Dense(units=y_train_onehot.shape[1], input_dim=50, kernel_initializer='glorot_uniform', bias_initializer='zeros', activation='softmax'))
  • 먼저 Sequential 클래스를 사용하여 피드포워드 신경망을 구현하는 새로운 모델을 초기화한다. 그 다음 원하는 만큼 층을 추가할 수 있다.
    • 처음 추가한 층은 입력층과 연결되기 떄문에 input_dim 속성이 훈련 세트에 있는 특성(열) 개수와 일치해야 한다.
    • 또 두 개의 연속된 층에서 출력 유닛(units)과 입력 유닛(input_dim)이 일치해야 한다. 위 코드에서 두 개의 은닉층은 50개의 은닉 유닛과 한 개의 절편 유닛을 가진다.
    • 출력층의 유닛 개수는 고유한 클래스 레이블의 개수와 같아야 한다. 원-핫 인코딩된 클래스 레이블 배열의 열 개수이다.
  • 지금까지 만든 모델 구조를 summary() 메서드를 사용하여 출력하면 다음과 같다.
    • 출력의 시작 부분이 신경망 입력에 가까운 층이고 끝부분이 신경망 출력에 가까운 층이다.
model.summary()

#### 결과
# Model: "sequential"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# dense (Dense) (None, 50) 39250
# _________________________________________________________________
# dense_1 (Dense) (None, 50) 2550
# _________________________________________________________________
# dense_2 (Dense) (None, 10) 510
# =================================================================
# Total params: 42,310
# Trainable params: 42,310
# Non-trainable params: 0

피드포워드 신경망 훈련

  • 모델 구성을 마치면 훈련을 수행하기 전에 모델을 컴파일 해야 한다. 이 단계에서 최적화할 손실 함수를 정의하고 최적화에 사용할 경사 하강법 옵티마이저를 선택한다.
    • 이전 장에서 사용해 보았던 확률적 경사 하강법 최적화를 선택하겠다.
    • 또 에포크마다 학습률을 조절하기 위한 학습률 감쇠 상수와 모멘텀 값을 지정한다.
    • 마지막으로 비용 함수(또는 손실 함수)를 categorical_crossentropy로 설정한다.
  • 이진 크로스 엔트로피는 로지스틱 손실 함수의 기술적인 표현이다.
    • 범주형 크로스 엔트로피는 소프트맥스를 사용하여 다중 클래스 예측으로 일반화한 것이다. 이에 대해서는 차후에 설명하겠다.
    • 옵티마이저 설정과 모델을 컴파일하는 코드는 다음과 같다.
sgd_optimizer = tf.keras.optimizers.SGD(lr=0.001, decay=1e-7, momentum=.9)
model.compile(optimizer=sgd_optimizer, loss='categorical_crossentropy')
  • 모델을 컴파일한 후 fit 메서드를 호출하여 훈련시킨다.
    • 여기서는 미니 배치 경사하강법을 사용하겠다. 배치마다 담긴 훈련 샘플 개수는 64개다.
    • 50번의 에포크 동안 MLP를 훈련시키겠다.
    • verbose=1로 설정하여 훈련하는 동안 비용 함수의 최적화 과정을 따라가보겠다.
    • validation_split 매개변수는 아주 유용한데, 0.1로 설정하면 훈련 데이터의 10%를 검증 데이터로 따로 떼어낸다. 에포크마다 이 데이터로 검증 점수를 계산하므로 모델이 과대적합되었는지 모니터링 할 수 있다.
history = model.fit(X_train_centered, y_train_onehot, batch_size=64, epochs=50, verbose=1, validation_split=0.1)

### 결과
# Train on 54000 samples, validate on 6000 samples
# Epoch 1/50
# 2020-05-01 10:08:46.207749: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cublas64_100.dll
# 54000/54000 [=========================] - 2s 43us/sample - loss: 0.6957 - val_loss: 0.3554
# Epoch 2/50
# 54000/54000 [=========================] - 1s 27us/sample - loss: 0.3662 - val_loss: 0.2747
# Epoch 3/50
# 54000/54000 [=========================] - 2s 28us/sample - loss: 0.3039 - val_loss: 0.2395
# ...
# Epoch 50/50
# 54000/54000 [=========================] - 2s 28us/sample - loss: 0.0479 - val_loss: 0.1110
  • 훈련하는 동안 비용 함수 값을 출력하는 기능은 아주 유용하다. 훈련 도중에 비용이 감소하는지 여부를 빨리 확인해서 감소하지 않는다면 하이퍼파라미터를 튜닝하기 위해 알고리즘을 일찍 멈출 수 있다.
  • 클래스 레이블을 예측하려면 predict_classes 메서드를 사용하여 정수로 된 클래스 레이블을 얻을 수 있다.
  • 클래스 레이블을 예측하려면 predict_classes 메서드를 사용하여 정수로된 클래스 레이블을 얻을 수 있다.
    • 마지막으로 훈련 세트와 테스트 세트에서 모델 정확도를 출력해 보자
y_train_pred = model.predict_classes(X_train_centered, verbose=0)
correct_preds = np.sum(y_train==y_train_pred, axis=0)
train_acc = correct_preds / y_train.shape[0]
print('처음 3개 예측: ', y_train_pred[:3])
print('훈련 정확도: %.2f%%' % (train_acc * 100))

### 결과
# 처음 3개 예측: [5 0 4]
# 훈련 정확도: 98.91%

y_test_pred = model.predict_classes(X_test_centered, verbose=0)
correct_preds = np.sum(y_test==y_test_pred, axis=0)
test_acc = correct_preds / y_test.shape[0]
print('처음 3개 예측: ', y_test_pred[:3])
print('훈련 정확도: %.2f%%' % (test_acc * 100))

### 결과
# 처음 3개 예측: [7 2 1]
# 훈련 정확도: 96.37%

다층 신경망의 활성화 함수 선택

  • 지금까지 다층 피드포워드 신경망을 쉽게 이해하기 위해 시그모이드 활성화 함수만 설명했다. 12장에서 다층 퍼셉트론을 구현할 때 출력층과 은닉층에 이 함수를 사용했다.
    • 이반적으로 다른 문헌에서 부르는 것처럼 이 활성화 함수를 시그모이드 함수라고 했는데, 좀 더 정확한 정의는 로지스틱(logistic) 함수이다.
  • 기술적으로는 미분 가능하다면 어떤 함수라도 다층 신경망의 활성화 함수로 사용할 수 있다.
    • 아달린에서처럼 선형 활성화 함수도 사용할 수 있지만, 실제로 은닉층이나 출력층에서 선형 활성화 함수를 사용하는 것이 그리 유용하지는 않다.
    • 복잡한 문제를 해결하기 위해서는 일반적인 인공 신경망에 비선형성이 필요하기 때문이다.
    • 선형 함수를 합치면 결국 하나의 선형 함수가 된다.
  • 12장에서 사용한 로지스틱 활성화 함수가 뉴런 개념을 가장 비슷하게 흉내 낸 함수이다. 이 함수 출력을 뉴런의 활성화 여부에 대한 확률로 생각할 수 있다.
  • 로지스틱 활성화 함수는 큰 음수 입력이 들어오면 문제가 된다.
    • 이 경우 시그모이드 함수의 출력이 0에 가까워지기 때문이다. 시그모이드 함수가 0에 가까운 출력을 내면 신경망이 매우 느리게 학습한다.
    • 또한 훈련 과정에서 지역 최솟값에 갇힐 가능성이 높다.
    • 이런 이유로 은닉층에 하이퍼볼릭 탄젠트 함수를 더 선호한다.

로지스틱 함수 요약

  • 종종 시그모이드 함수라고 불리는 로지스틱 함수는 시그모이드 함수의 특별한 경우이다.
    • 3장 로지스틱 회귀에 관한 절에서 로지스틱 함수를 사용하여 이진 분류 문제일 때 샘플 x 가 양성 클래스(클래스 1)에 속할 확률을 모델링 했다.
    • 최종 입력 z 는 다음 식으로 계산된다.

z = w_{0} x_{0} + w_{1} x_{1} + ... + w_{m} x_{m} = \sum_{i=0}^{m} w_{i} x_{i} = w^{T} x

  • 로지스틱 함수는 다음과 같이 계산한다.

\phi_{logistic}(z) = {1 \over 1 + e^{-z}}

  • w_{0} 는 절편 유닛이다 (y 축과의 교차점. 즉 x_{0} = 1 이다)
    • 구체적인 예를 들기 위해 2차원 데이터 포인트 x 와 다음과 같은 가중치 벡터 w 로 구성된 모델을 가정해 보자
import numpy as np

X = np.array([1, 1.4, 2.5])
w = np.array([0.4, 0.3, 0.5])

def net_input(X, w):
    return np.dot(X, w)

def logistic(z):
    return 1.0 / (1.0 + np.exp(-z))

def logistic_activation(X, w):
    z = net_input(X, w)
    return logistic(z)
  • 이 특성과 가중치 값을 사용하여 최종 입력을 계산하고 이것으로 로지스틱 뉴런의 활성화 출력을 구하면 0.888을 얻는다. 이 샘플 x가 양성 클래스에 속할 확률이 88.8%라고 해석할 수 있다.
  • 12장에서 여러 개의 로지스틱 활성화 유닛으로 구성된 출력층 값을 계산하기 위해 원-핫 인코딩 기법을 사용했다.
    • 다음 코드에서처럼 여러 개의 로지스틱 활성화 유닛으로 구성된 출력층은 의미 있게 해석할만한 확률 값을 만들지 못한다.
W = np.array([[1.1, 1.2, 0.8, 0.4], [0.2, 0.4, 1.0, 0.2], [0.6, 1.5, 1.2, 0.7]])
A = np.array([[1, 0.1, 0.4, 0.6]])
Z = np.dot(W, A[0])

y_probas = logistic(Z)

print('최종 입력: \n', Z)
print('출력 유닛: \n', y_probas)

### 결과
# 최종 입력:
# [1.78 0.76 1.65]
# 출력 유닛:
# [0.85569687 0.68135373 0.83889105]

  • 출력에서 볼 수 있듯이 클래스가 세 개일 때 결과 확률을 이해하기 어렵다. 그것은 세 개의 합이 1이 아니기 때문이다.
    • 사실 클래스 소속 확률을 구하는 것이 아니라 클래스 레이블을 예측하기 위해서만 사용한다면 큰 문제는 아니다. 앞의 출력 결과에서 클래스 레이블을 예측하는 방법은 가장 큰 값을 선택하는 것이다.
  • 어떤 경우에는 다중 클래스 예측 문제에서 의미 있는 클래스 확률을 계산할 필요가 있다. 다음 절에서는 이런 문제를 다루기 위해 로지스틱 함수를 일반화한 softmax 함수를 살펴보겠다.

소프트맥스 함수를 사용하여 다중 클래스 확률 예측

  • 이전 절에서 argmax 함수를 사용하여 클래스 레이블을 구현하는 방법을 보았는데, 사실 소프트맥스는 간접적인 argmax 함수이다.
    • 하나의 클래스 인덱스를 찾는 대신 각 클래스의 확률을 반환하므로 다중 클래스 환경(다중 로지스틱 회귀(multinomial logistic regression))에서 의미 있는 클래스 확률을 계산할 수 있다.
  • softmax 함수는 특정 샘플의 최종 입력이 z 일 때 i 번째 클래스에 속할 확률을 M 개의 선형 함수 합으로 나누어 정규화한 것이다.

P(y=i|z) = \phi (z) = {e^{Z_{i}} \over \sum_{j=1}^{M} e^{Z_{j}}}

  • softmax 함수의 동작을 확인해 보기 위해 직접 만들어 보자
def softmax(z):
    return np.exp(z) / np.sum(np.exp(z))

y_probas = softmax(Z)
print('확률:\n', y_probas)

# 결과
# 확률:
# [0.44668973 0.16107406 0.39223621]
  • 예측된 클래스 확률 합은 1이 되었다. 예측 클래스 레이블은 로지스틱 출력에 argmax 함수를 적용한 것과 같다. softmax 함수는 출력을 정규화하여 다중 클래스일 때 의미 있는 클래스 소속 확률을 만든다.

하이퍼볼릭 탄젠트로 출력 범위 넓히기

  • 인공 신경망의 은닉층에 많이 사용하는 또 다른 시그모이드 함수는 하이퍼볼릭 탄젠트(hyperbolic tangent) (보통 tanh 라고 함)이다. 이 함수는 스케일이 조정된 로지스틱 함수라고 생각할 수 있다.

\phi_{logistic}(z) = {1 \over 1 + e^{-z}}

\phi_{tanh}(z) = 2 \times \phi_{logistic} (2z) - 1 = {e^{z} - e^{-z} \over e^{z} + e^{-z}}

  • 로지스틱 함수에 비해 하이퍼볼릭 탄젠트 함수의 장점은 출력 범위를 (-1, 1) 사이로 넓혀서 역전파 알고리즘의 수렴을 향상시킬 수 있다는 것이다.
    • 로지스틱 함수는 (0, 1) 범위의 출력 신호를 반환한다.
  • 두 시그모이드 함수를 그래프로 그리면 아래와 같다.
def tanh(z):
   e_p = np.exp(z)
   e_m = np.exp(-z)
    return (e_p - e_m) / (e_p + e_m)

  • 두 시그모이드 곡선은 매우 비슷하지만 tanh 함수가 logistic 함수보다 2배 큰 범위 출력을 갖는다.
  • 위에서는 tanh 함수와 logistic 함수를 직접 구현했지만 실전에서는 넘파이의 tanh 함수와 사이파이의 special 모듈에서 두 함수를 사용할 수 있다.

렐루 활성화 함수

  • 렐루(Rectified Linear Unit, ReLU)는 심층 신경망에 자주 사용되는 또 다른 활성화 함수이다. 렐루를 알아보기 전에 하이퍼볼릭 탄젠트와 로지스틱 활성화 함수의 그래디언트 소실 문제(vanishing gradient problem)를 살펴보자.
  • 최종 입력이 z_{1} = 20 에서 z_{2} = 25 로 바뀐다고 가정하자. 하이퍼볼릭 탄젠트 활성화 함수를 계산하면 \phi (z_{1}) \approx 1.0 \phi (z_{2}) \approx 1.0 이므로 출력에 변화가 없다.
    • 이는 최종 입력에 대한 활성화 함수의 도함수가 w_{0} 가 커짐에 따라 줄어든다는 뜻이다.
    • 결국 그래디언트가 0에 아주 가까워지기 때문에 훈련 과정 동안 가중치가 매우 느리게 학습된다.
    • 렐루 활성화 문제는 이런 문제를 해결한다. 렐루 함수의 수학적 정의는 다음과 같다.

\phi (z) = \max(0, z)

  • 렐루도 신경망이 복잡한 함수를 학습하기에 좋은 비선형 함수이다. 입력 값이 양수면 입력에 대한 렐루의 도함수는 항상 1이다.
    • 이것이 그래디언트 소실 문제를 해결해 주므로 심층 신경망에 적합하다.
    • 다음 장에서는 다층 합성곱 신경망을 위한 활성화 함수로 렐루 함수를 사용해 보겠다.

머신 러닝 교과서/ 다층 인공 신경망을 밑바닥부터 구현

인공 신경망으로 복잡한 함수 모델링

  • 2장에서 다루었던 인공 뉴런은 다층 인공 신경망의 구성 요소이다. 인공 신경망 이면에 있는 기본 개념은 사람의 뇌가 어떻게 복잡한 문제를 푸는지에 대한 가설과 모델을 기반으로 한다.
    • 인공 신경망이 최근 몇 년간 인기를 끌고 있지만 신경망에 대한 초기 연구는 1940년대 워렌 맥컬록(Warren McCulloch)과 월터 피츠(Walter Pitts)가 처음 뉴런 작동 방식을 기술했던 때로 올라간다.
  • 맥컬록-피츠 뉴런 모델인 로젠블라트의 퍼셉트론이 1950년대 처음 구현된 이후 수시년 동안 많은 연구자와 머신러닝 기술자는 신경망에 대한 관심을 잃었는데, 다층 신경망을 훈련하기 위한 좋은 방법이 없었기 때문이다.
    • 마침내 루멜하트(D. E. Rumelhart), 힌튼(G. E. Hinton), 윌리엄스(R. J. Wiliams)가 1986년 신경망을 효과적으로 훈련시키는 역전파 알고리즘을 재발견하면서 신경망에 대한 관심이 다시 살아났다.
  • 신경망이 요즘처럼 인기를 끌었던 때는 없었는데, 딥러닝 알고리즘과 여러 개의 층으로 이루어진 신경망 구조는 지난 10여년 간 일어난 많은 혁신의 결과물이다.

단일층 신경망 요약

  • 다층 신경망 구조를 본격적으로 배우기 전에 단일층 신경망 네트워크 개념을 되새겨보자.
  • 이는 2장에서 소개한 아달린(ADAptive LInear NEuron, Adaline) 알고리즘으로 아래 그림과 같다.

  • 2장에서 이진 분류를 수행하는 아달린 알고리즘을 구현했는데, 경사 하강법 최적화 알고리즘을 사용하여 모델 가중치를 학습했다.
  • 훈련 세트를 한 번 순회하는 에포크마다 가중치 벡터 w 를 업데이트하기 위해 다음 공식을 사용한다.

w := w + \Delta w

\Delta w = - \eta \nabla J(w)

  • 다른 말로 하면 전체 훈련 세트에 대한 그래디언트를 계산하고 그래디언트 \nabla J(w) 의 반대 방향으로 진행하도록 모델 가중치를 업데이트 했다.
    • 최적의 모델 가중치를 찾기 위해 제곱 오차합(Sum of Squared Errors, SSE) 비용 함수 J(w) 로 정의된 목적 함수를 최적화 한다.
    • 또 학습률 \eta 를 그래디언트에 곱한다.
    • 학습률은 비용 함수의 전역 최솟값을 지나치지 않도록 학습 속도를 조절하기 위해 신중하게 선택해야 한다.
  • 경사 하강법 최적화에서는 에포크마다 모든 가중치를 동시에 업데이트 한다. 가중치 벡터 w 에 있는 각각의 가중치 w_{j} 에 대한 편도 함수는 다음과 같이 정의한다.

{\partial \over \partial w_{j}} J(w) = - \sum_{i} (y^{(i)} - a^{(i)}) x_{j}^{(i)}  

  • 여기서 y^{(i)} 는 특정 샘플 x^{(i)} 의 타깃 클래스 레이블이다.
    • a^{(i)} 는 뉴런의 활성화 출력이다.
    • 아달린은 선형 함수를 사용하므로 활성화 함수 \phi (\cdot) 는 다음과 같이 정의한다.

\phi (z) = z = a  

  • 여기서 최종 입력 z 는 입력층과 출력층을 연결하는 가중치의 선형 결합이다.

z = \sum_{j} w_{j} x_{j} = w^{T} x  

  • 업데이트할 그래디언트를 계산하기 위해 활성화 함수를 사용했지만, 예측을 위해 임계 함수를 구현하여 연속적인 출력 값을 이진 클래스 레이블로 압축했다.

\hat{y} = \begin{cases} 1 & g(z) \geq 0 \\ -1 & else \end{cases}

  • 또 모델의 학습을 가속시키기 위해 확률적 경사 하강법(stochastic gradient descent) 최적화 기법도 배웠다.
    • 확률적 경사 하강법은 하나의 훈련 샘플(온라인 학습) 또는 적은 수의 훈련 샘플(미니 배치 학습)을 사용해서 비용을 근사한다.
    • 차후에 이 장에서 다층 퍼셉트론을 구현하고 훈련 시킬 때 이 개념을 사용하겠다. 경사 하강법에 비해 더 자주 가중치를 업데이트하기 때문에 학습이 빠르다.
    • 이에 더해 들쭉날쭉한 학습 특성이 비선형 활성화 함수를 사용한 다층 신경망을 훈련 시킬 때 장점이 될 수 있다. 이런 신경망의 비용 함수는 하나의 볼록 함수가 아니기 때문이다.
    • 확률적 경사하강법에서 생기는 잡음은 지역 최솟값을 탈출하는데 도움이 된다.

다층 신경망 구조

  • 이 절에서는 여러 개의 단일 뉴런을 연결하여 다층 피드포워드(feedforward) 신경망을 만드는 방법을 배워보겠다. 완전 연결 네트워크의 특별한 경우로 다층 퍼셉트론(Multilayer Perceptron, MLP)라고도 한다.
  • 다음 그림은 세 개의 층으로 구성된 MLP 개념을 나타낸다.

  • 위 그림에 나타난 MLP는 입력층 하나, 은닉층 하나, 출력층 하나를 가진다.
    • 은닉층의 유닛은 입력층과 완전 연결되어 있고, 출력층은 은닉층과 완전 연결되어 있다.
    • 하나 이상의 은닉층을 가진 네트워크를 심층 인공 신경망(deep artificial nerual network)라고 한다.
  • 위 그림과 같이 l 번째 층에 있는 i 번째 유닛의 활성화 출력을 a_{i}^{(l)} 이라고 하겠다.
    • 수식과 코드를 간단하게 만들기 위해 층을 나타내는 인덱스는 사용하지 않겠다. 그 대신 입력층에 대해서는 in 위첨자를 사용하고 은닉층은 h 위첨자, 출력층은 out 위 첨자를 사용하겠다.
    • 예컨대 a_{i}^{(in)} 는 입력층의 i 번째 유닛이고 a_{i}^{(h)} 는 은닉층의 i 번째 유닛이고 a_{i}^{(out)} 는 출력층의 i 번째 유닛을 나타낸다.
    • 여기서 활성화 출력 z z 는 절편을 위해 추가한 특성으로 1이 된다.
    • 입력층의 유닛 활성화는 입력 값에 절편을 더한 것과 같다.

a^{(in)} = \left[ \begin{array}{rrrr} a_{0}^{(in)} \\ a_{1}^{(in)} \\ ... \\ a_{m}^{(in)} \end{array} \right] = \left[ \begin{array}{rrrr} 1 \\ x_{1}^{(in)} \\ ... \\ x_{m}^{(in)} \end{array} \right]

  • l 에 있는 각 유닛이 층 l + 1 에 있는 모든 유닛과 연결되어 있다. 예컨대 층 l 에 있는 k 번째 유닛과 층 l + 1 에 있는 j 번째 유닛 사이의 연결은 w_{k, j}^{(l+1)} 이라고 쓴다.
  • 위 그림을 다시 보면 입력층과 은닉층을 연결하는 가중치 행렬을 W^{(h)} 로 표시할 수 있다.
    • 은닉층과 출력층을 연결하는 가중치 행렬은 W^{(out)} 으로 나타낼 수 있다.
  • 이진 분류 작업에서는 출력층의 유닛이 하나여도 충분하지만 위 그림은 OvA(One-versus-All) 기법을 적용하여 다중 분류를 수행할 수 있는 일반적인 신경망 형태이다.
  • 입력층과 은닉층을 연결하는 가중치를 행렬 W^{(h)} \in \mathbb{R}^{m \times d} 로 나타낸다.
    • 여기서 d 는 은닉 유닛의 개수고, m 는 절편을 포함한 입력 유닛의 개수이다.

정방향 계산으로 신경망 활성화 출력 계산

  • MLP 모델의 출력을 계산하는 정방향 계산(forward propagation) 과정을 설명해 보겠다. MLP 모델 학습과 어떻게 관련되는지 이해하기 위해 세 단계로 MLP 학습 과정을 요약해 보자.
    1. 입력층에서 시작해서 정방향으로 훈련 데이터의 패턴을 네트워크에 전파하여 출력을 만든다.
    2. 네트워크 출력을 기반으로 비용 함수를 이용하여 최소화해야 할 오차를 계산한다.
    3. 네트워크에 있는 모든 가중치에 대한 도함수를 찾아 오차를 역전파하고 모델을 업데이트 한다.
  • 이 세 단계를 여러 에포크 동안 반복하고 MLP 가중치를 학습한다. 그런 다음 클래스 레이블을 예측하기 위해 정방향 계산으로 네트워크 출력을 만들고 임계 함수를 적용한다.
    • 이 클래스 레이블은 앞서 설명했던 원-핫 인코딩으로 표현된다.
  • 이제 훈련 데이터에 있는 패턴으로부터 출력을 만들기 위해 정방향 계산 과정을 따라가보자.
    • 은닉층에 있는 모든 유닛은 입력층에 있는 모든 유닛과 연결되어 있기 때문에 다음과 같이 은닉층 a_{1}^{(h)} 의 활성화 출력을 계산한다.

z_{1}^{(h)} = a_{0}^{(in)} w_{0, 1}^{(h)} + a_{1}^{(in)} w_{1, 1}^{(h)} + ... + a_{m}^{(in)} w_{m, 1}^{(h)}

a_{1}^{(h)} = \phi (z_{1}^{(h)})

  • 여기서 z_{1}^{(h)} 는 최종 입력이고 \phi(\cdot) 는 활성화 함수이다. 이 함수는 그래디언트 기반 방식을 사용하여 뉴런과 연결된 가중치를 학습하기 위해 미분 가능해야 한다.
  • 이미지 분류 같은 복잡한 문제를 해결하기 위해 MLP 모델에 비선형 활성화 함수를 사용해야 한다. 예컨대 시그모이드(로지스틱) 활성화 함수가 있다.

\phi (z) = {1 \over 1 + e^{-z}}

  • 기억을 떠올려 보면 시그모이드 함수는 S자 모양의 그래프로 최종 입력 z 를 0과 1사이 로지스틱 분포로 매핑한다. 이 그래프는 아래 그림과 같이 z = 0 에서 y축을 지난다.

  • MLP는 대표적인 피드포워드 인공 신경망의 하나이다. 피드 포워드(feed forward)란 용어는 각 층에서 입력을 순환시키지 않고 다음 층으로 전달한다는 의미이다. 이는 순환 신경망과 다르다.
    • MLP 네트워크에서 사용한 인공 뉴런이 퍼셉트론이 아니고 시그모이드이기 때문에 다층 퍼셉트론이란 용어가 혼동될 수 있다.
    • 간단히 말해 MLP 뉴런을 0과 1사이의 연속적인 값을 반환하는 로지스틱 회귀 유닛이라 생각할 수 있다.
  • 효율적이고 읽기 쉽게 기초적인 선형대수를 사용하여 활성화 출력을 좀 더 간단히 써보자.
    • 이렇게 하면 for 반복문을 중복하여 사용하지 않고 넘파이를 사용하여 벡터화된 구현을 만들 수 있다.

z^{(h)} = a^{(in)} W^{(h)}

a^{(h)} = \phi (z^{(h)})

  • 여기서 a^{(in)} 는 샘플 x^{(in)} 에 절편을 더한 1 \times m 차원 특성 벡터이다.
    • W^{(h)} m \times d 차원의 가중치 행렬이다.
    • d 는 은닉층의 유닛 개수이다.
  • 행렬-벡터 곱셉을 하면 1 \times d 차원의 최종 입력 벡터 z^{(h)} 를 얻어 활성화 출력 a^{(h)} 를 계산할 수 있다. (여기서 a^{(h)} \in \mathbb{R}^{1 \times d} )
    • 또 훈련 세트에 있는 모든 n 개의 샘플에 이 계산을 일반화 시킬 수 있다.

Z^{(h)} = A^{(in)} W^{(h)}

  • 여기서 A^{(in)} n \times m 행렬이다.
    • 행렬-행렬 곱셉을 하면 n \times d 차원의 최종 입력 행렬 Z^{(h)} 가 얻어진다.
  • 마지막으로 최종 입력 행렬의 각 값에 활성화 함수 \phi (\cdot) 를 적용하여 다음 층에 전달할 n \times d 차원의 활성화 행렬 A^{(h)} 를 얻는다.

A(h) = \phi(Z(h))

  • 비슷하게 출력층의 활성화도 여러 샘플에 대한 벡터 표현으로 쓸 수 있다.

Z^{(out)} = A^{(h)} W^{(out)}

  • 여기서 n \times d 차원 행렬 A^{(h)} d \times t 차원 (t 는 출력 뉴런 개수) 행렬 W^{(out)} 를 곱해 n \times t 차원 행렬 Z^{(out)} (이 행렬의 열은 각 샘플 출력 값)을 얻는다.
  • 마지막으로 시그모이드 활성화 함수를 적용하여 실수로 된 네트워크 출력을 얻는다.

A^{(out)} = \phi (Z^{(out)}), A^{(out)} \in \mathbb{R}^{n \times t}

손글씨 숫자 분류

MNIST 데이터셋 구하기

  • MNIST 데이터셋은 미국 NIST에서 만든 두 개의 데이터셋으로 구성되어 있다.
    • 훈련 세트는 250명의 사람이 쓴 솔글씨 숫자인데, 50%는 고등학생이고 50%는 인구 조사국직원이다.
  • MNIST 데이터셋은 http://yann.lecun.com/exdb/mnist/에 공개되어 있으며 다음 네 부분으로 구성되어 있다.
    • 훈련 세트 이미지: train-images-idx3-ubyte.gz
    • 훈련 세트 레이블: train-labels-idx1-ubyte.gz
    • 테스트 세트 이미지: t10k-images-idx3-ubyte.gz
    • 테스트 세트 레이블: t10k-labels-idx1-ubyte.gz
  • 내려 받은 MNIST 데이터셋을 불러오는 코드는 다음과 같다.
import os
import struct
import numpy as np

def load_mnist(path, kind='train'):
   """'path'에서 MNIST 데이터 불러오기"""
   labels_path = os.path.join(path, '%s-labels.idx1-ubyte' % kind)
   images_path = os.path.join(path, '%s-images.idx3-ubyte' % kind)

   with open(labels_path, 'rb') as lbpath:
       magic, n = struct.unpack('>II', lbpath.read(8))
       labels = np.fromfile(lbpath, dtype=np.uint8)   

    with open(images_path, 'rb') as imgpath:
        magic, num, rows, cols = struct.unpack(">IIII", imgpath.read(16))
       images = np.fromfile(imgpath, dtype=np.uint8).reshape(len(labels), 784)
       images = ((images/255.) - .5) & 2   

    return images, labels
  • 위 코드는 두 개의 배열을 반환하는데, 첫 번째는 n \times m 차원의 넘파이 배열(images)이다.
    • 여기서 n 은 샘플 개수이고 m 은 특성(여기서는 픽셀) 개수이다.
    • 두번째 배열(labels)는 이미지에 해당하는 타깃 값을 갖고 있다. 이 값은 손글씨 숫자의 클래스 레이블 (0-9까지의 정수)이다.
  • 코드의 마지막 부분에서 MNIST 픽셀 값을 -1에서 1 사이로 정규화하는데, 이는 그래디언트 기반의 최적화가 이런 조건하에서 훨씬 안정적이기 때문이다.
  • 다음 코드를 이용하면 데이터를 불러와서 결과를 확인할 수 있다.
import matplotlib.pyplot as plt

X_train, y_train = load_mnist('', kind='train')
print('Train 행: %d, 열: %d' % (X_train.shape[0], X_train.shape[1]))

X_test, y_test = load_mnist('', kind='t10k')
print('Test 행: %d, 열: %d' % (X_test.shape[0], X_test.shape[1]))

fig, ax = plt.subplots(nrows=2, ncols=5, sharex=True, sharey=True)
ax = ax.flatten()

for i in range(10):
   img = X_train[y_train==i][0].reshape(28, 28)
    ax[i].imshow(img, cmap='Greys')

ax[0].set_xticks([])
ax[0].set_yticks([])

plt.tight_layout()
plt.show()

  • (7번만 출력하는 예 생략)
  • 이전 단계를 모두 진행한 후 스케일된 이미지를 새로운 파이썬 세션에서 빠르게 읽을 수 있는 포맷으로 저장하는 것이 좋다. 이렇게 하면 데이터를 읽고 전처리하는 오버헤드를 피할 수 있다.
    • 넘파이 배열을 사용할 때 다차원 배열을 디스크에 저장하는 효율적이고 가장 간편한 방법은 넘파이 savez 함수이다. (아래 코드에서는 savez_compressed)
  • 다음 코드를 이용하면 훈련 세트와 테스트 세트를 파일로 저장하고 다시 읽을 수 있다.
import numpy as np
np.savez_compressed('mnist_scaled.npz', X_train=X_train, y_train=y_train, X_test=X_test, y_test=y_test)

minst = np.load('mnist_scaled.npz')
  • savez_compressed 함수에 전달한 키워드 매개변수를 이용하여 mnist 객체에 있는 네 개의 데이터 배열을 참조할 수 있다.
# savez_compressed 할 때 전달했던 키워드로 배열에 접근
X_train = mnist['X_train']
  • 다음 코드 처럼 리스트 내포 구문을 사용하면 네 개의 데이터 배열을 변수에 할당할 수 있다.
X_train, y_train, X_test, y_test = [mnist[f] for f in mnist.files]

다층 퍼셉트론 구현

  • 이제 입력층, 은닉층, 출력층이 각각 하나씩 있는 MLP 구현을 작성하고 MNIST 데이터셋 이미지를 분류해 보겠다.
  • 상세한 설명은 뒤에서 하고 우선 다음 코드를 작성하자
import numpy as np
import sys

class NeuralNetMLP(object):
    """ 피드포워드 신경망 / 다층 퍼셉트론 분류기

    매개변수
   -----------------
   n_hidden : int (기본값: 30)
      은닉 유닛 개수
   l2 : float (기본값: 0)
      L2 규제의 람다 값
     l2=0이면 규제 없음
   epochs : int (기본값: 100)
      훈련 세트를 반복할 횟수
   eta : float (기본값: 0.001)
      학습률
   shuffle : bool (기본값: True)
     에포크마다 훈련 세트를 섞을지 여부
     True면 데이터를 섞어 순서를 바꾼다
    minibatch_size : int (기본값: 1)
      미니 배치의 훈련 샘플 개수
    seed : int (기본값: None)
     가중치와 데이터 셔플링을 위한 난수 초깃값   

    속성
   ------------------
    eval_ : dict
      훈련 에포크마다 비용, 훈련 정확도, 검증 정확도를 수집하기 위한 딕셔너리
    """

   def __init__(self, n_hidden=30, l2=0., epochs=100, eta=0.001, shuffle=True, minibatch_size=1, seed=None):
       self.random = np.random.RandomState(seed)
        self.n_hidden = n_hidden
        self.l2 = l2
       self.epochs = epochs
        self.eta = eta
       self.shuffle = shuffle
       self.minibatch_size = minibatch_size   

    def _onehot(self, y, n_classes):
       """ 레이블을 원-핫 방식으로 인코딩

        매개변수
       ---------------
       y : 배열, 크기 = [n_samples]
         타깃 값       

        반환값
       ----------------
       onehot : 배열, 크기 = (n_samples, n_labels)
        """

        onehot = np.zeros((n_classes, y.shape[0]))

        for idx, val in enumerate(y.astype(int)):
           onehot[val, idx] = 1.       

       return onehot.T   

   def _sigmoid(self, z):
       """ 로지스킥 함수(시그모이드)를 계산 """
       return 1. / (1. + np.exp(-np.clip(z, -250, 250)))   

    def _forward(self, X):
       """ 정방향 계산을 수행"""

       # 단계 1: 은닉층의 최종 입력
        # [n_samples, n_features] dot [n_features, n_hidden] -> [n_samples, n_hidden]
        z_h = np.dot(X, self.w_h) + self.b_h

       # 단계 2: 은닉층의 활성화 출력
        a_h = self._sigmoid(z_h)

       # 단계 3: 출력층의 최종 입력
        # [n_samples, n_hidden] dot [n_hidden, n_classlabels] -> [n_samples, n_classlabels]
        z_out = np.dot(a_h, self.w_out) + self.b_out

       # 단계 4: 출력층의 활성화 출력
        a_out = self._sigmoid(z_out)

       return z_h, a_h, z_out, a_out   

   def _compute_cost(self, y_enc, output):
        """ 비용 함수를 계산

        매개변수
        -------------------
       y_enc : 배열, 크기 = (n_samples, n_labels)
         원-핫 인코딩된 클래스 레이블
       output : 배열, 크기 = [n_samples, n_output_units]
         출력층의 활성화 출력 (정방향 계산)       

        반환값
       ---------------
        cost : float
         규제가 포함된 비용
        """

        L2_term = (self.l2 * (np.sum(self.w_h ** 2.) + np.sum(self.w_out ** 2.)))
       term1 = -y_enc * (np.log(output))
       term2 = (1. - y_enc) * np.log(1. - output)
        cost = np.sum(term1 - term2) + L2_term

       return cost   

   def predict(self, X):
        """ 클래스 레이블을 예측

        매개변수
       ------------
       X : 배열, 크기 = [n_samples, n_features]
         원본 특성의 입력층       

        반환값
       ------------
       y_pred : 배열, 크기 = [n_samples]
          예측된 클래스 레이블
        """

       z_h, a_h, z_out, a_out = self._forward(X)
        y_pred = np.argmax(z_out, axis=1)

       return y_pred   

   def fit(self, X_train, y_train, X_valid, y_valid):
        """ 훈련 데이터에서 가중치를 학습합니다.

        매개변수
       --------------
       X_train : 배열, 크기 = [n_samples, n_features]
          원본 특성의 입력층
       y_train : 배열, 크기 = [n_samples]
          타깃 클래스 레이블
       X_valid : 배열, 크기 = [n_samples, n_features]
          훈련하는 동안 검증에 사용할 샘플 특성
        y_vaild : 배열, 크기 = [n_samples]
          훈련하는 동안 검증에 사용할 샘플 레이블

        반환값
       ------------
        self

        """

       n_output = np.unique(y_train).shape[0] # 클래스 레이블 개수
        n_features = X_train.shape[1]

       ##########################
        # 가중치 초기화
        ##########################

       # 입력층 -> 은닉층 사이의 가중치
       self.b_h = np.zeros(self.n_hidden)
        self.w_h = self.random.normal(loc=0.0, scale=0.1, size=(n_features, self.n_hidden))

       # 은닉층 -> 출력층 사이의 가중치
       self.b_out = np.zeros(n_output)
        self.w_out = self.random.normal(loc=0.0, scale=0.1, size=(self.n_hidden, n_output))
       epoch_strlen = len(str(self.epochs)) # 출력 포맷을 위해
        self.eval_ = {'cost':[], 'train_acc':[], 'valid_acc':[]}
        y_train_enc = self._onehot(y_train, n_output)

        # 훈련 에포크를 반복한다.
        for i in range(self.epochs):

            # 미니 배치로 반복한다.
            indices = np.arange(X_train.shape[0])

            if self.shuffle:
               self.random.shuffle(indices)           

            for start_idx in range(0, indices.shape[0] - self.minibatch_size + 1, self.minibatch_size):
                batch_idx = indices[start_idx: start_idx + self.minibatch_size]

               # 정방향 계산
                z_h, a_h, z_out, a_out = self._forward(X_train[batch_idx])

               ########################
                # 역전파
                ########################

               # [n_samples, n_classlabels]
                sigma_out = a_out - y_train_enc[batch_idx]

                # [n_samples, n_hidden]
                sigmoid_derivative_h = a_h * (1. - a_h)

                # [n_samples, n_classlabels] dot [n_classlabels, n_hidden] -> [n_samples, n_hidden]
                sigma_h = (np.dot(sigma_out, self.w_out.T) * sigmoid_derivative_h)

                # [n_features, n_samples] dot [n_samples, n_hidden] -> [n_features, n_hidden]
                grad_w_h = np.dot(X_train[batch_idx].T, sigma_h)
                grad_b_h = np.sum(sigma_h, axis=0)

               # [n_hidden, n_samples] dot [n_samples, n_classlabels] -> [n_hidden, n_classlabels]
               grad_w_out = np.dot(a_h.T, sigma_out)
                grad_b_out = np.sum(sigma_out, axis=0)

                # 규제와 가중치 업데이트
               delta_w_h = (grad_w_h + self.l2 * self.w_h)
               delta_b_h = grad_b_h # 편향은 규제하지 않는다.
                self.w_h -= self.eta * delta_w_h
                self.b_h -= self.eta * delta_b_h
                delta_w_out = (grad_w_out + self.l2 * self.w_out)
               delta_b_out = grad_b_out # 편향은 규제하지 않는다.
               self.w_out -= self.eta * delta_w_out
               self.b_out -= self.eta * delta_b_out           

            ####################
            # 평가
            ####################

           # 훈련하는 동안 에포크마다 평가한다.
           z_h, a_h, z_out, a_out = self._forward(X_train)
            cost = self._compute_cost(y_enc = y_train_enc, output=a_out)
           y_train_pred = self.predict(X_train)
            y_valid_pred = self.predict(X_valid)

            train_acc = ((np.sum(y_train == y_train_pred)).astype(np.float) / X_train.shape[0])
            valid_acc = ((np.sum(y_valid == y_valid_pred)).astype(np.float) / X_valid.shape[0])

            sys.stderr.write('\r%0*d/%d | 비용: %.2f | 훈련/검증 정확도: %.2f%%/%.2f%% ' % (epoch_strlen, i+1, self.epochs, cost, train_acc*100, valid_acc*100))
            sys.stderr.flush()

           self.eval_['cost'].append(cost)
           self.eval_['train_acc'].append(train_acc)
           self.eval_['valid_acc'].append(valid_acc)       

       return self
  • 위의 코드는 다음과 같이 사용할 수 있다.
nn = NeuralNetMLP(n_hidden=100, l2=0.01, epochs=200, eta=0.0005, minibatch_size=100, shuffle=True, seed=1)
  • 각 매개변수는 다음과 같다.
    • l2: 과대적합을 중리기 위한 L2 규제의 \lambda 파라미터
    • epochs: 훈련 세트를 반복할 횟수
    • eta: 학습률 \eta
    • shuffle: 알고리즘이 순환 고리에 갇히지 않도록 에포크를 싲가하기 전에 훈련 세트를 섞을지 여부
    • seed: 셔플과 가중치 초기화를 위한 난수 초깃값
    • minibatch_size: 확률적 경사 하강법에서 에포크마다 훈련 세트를 나눈 미니 배치에 들어갈 훈련 샘플 개수. 전체 훈련 세트에서 그래디언트를 계산하지 않고 학습 속도를 높이기 위해 미니 배치마다 계산한다.
  • 그 다음 이 MLP를 뒤섞어 놓은 55,000개의 MNIST 훈련 세트와 검증 용도인 5,000개의 샘플로 훈련시킨다.
    • 이 신경망을 훈련하는데 표준적인 컴퓨터 사양에서 약 5분정도 소요된다.
  • fit 메서드는 훈련 이미지, 훈련 레이블, 검증 이미지, 검증 레이블에 해당하는 네 개의 매개변수를 받고록 구현되어 있다.
    • 신경망 훈련에서는 훈련 정확도와 검증 정확도를 비교하는 것이 아주 중요하다. 신경망 모델이 주어진 구조와 하이퍼파라미터에서 잘 동작하는지 판단하는데 도움이 된다.
  • 일반적으로 (심층) 신경망을 훈련하는 것은 지금까지 배운 다른 모델에 비해 비교적 비용이 많이 든다.
    • 따라서 어떤 조건이 되면 일찍 중지하고 다른 하이퍼파라미터 설정을 시험하는 것이 좋다.
    • 또 훈련 데이터에 점차 과대적합되는 경향을 발견했다면 역시 훈련을 일찍 멈추는 것이 좋다.
  • 다음 코드를 이용하여 훈련을 할 수 있다.
nn.fit(X_train=X_train[:55000], y_train=y_train[:55000], X_valid=X_train[55000:], y_valid=y_train[55000:])
# 200/200 | 비용: 5065.78 | 훈련/검증 정확도: 99.28%/97.98%
  • NeuralNetMPL 구현에서 eval_ 속성을 정의하여 에포크마다 비용, 훈련 정확도, 검증 정확도를 수집했다. 이 결과를 그래프로 그려보자.

import matplotlib.pyplot as plt

plt.plot(range(nn.epochs), nn.eval_['cost'])
plt.ylabel('Cost')
plt.xlabel('Epochs')
plt.show()
  • 그림에서 볼 수 있듯 비용은 100번의 에포크 동안 많이 감소하고  그 이후는 천천히 수렴한다.
    • 하지만 175번째와 200번째 에포크 사이에 약간 경사가 있어서 에포크를 추가하여 훈련하면 비용은 더 감소할 것이다.
  • 훈련 정확도와 검증 정확도를 살펴보자

plt.plot(range(nn.epochs), nn.eval_['train_acc'], label='training')
plt.plot(range(nn.epochs), nn.eval_['valid_acc'], label='validation', linestyle='--')
plt.ylabel('Accuracy')
plt.xlabel('Epochs')
plt.legend()
plt.show()
  • 위 그래프는 네트워크 훈련 에포크가 늘어날수록 훈련 정확도와 검증 정확도 사이 간격이 증가한다는 것을 보여준다.
    • 약 50번째 에포크에서 훈련 정확도와 검증 정확도 값이 동일하고 그 이후에 네트워크는 훈련 세트에 과대적합되기 시작한다.
  • 과대적합 영향을 줄이는 한 가지 방법은 규제 강도를 높이는 것이다. 예컨대 l2를 0.1로 설정한다.
    • 신경망에서 과대적합을 해결하기 위해 사용하는 다른 방법은 15장에서 다룰 드롭아웃(dropout)이다.
  • 마지막으로 테스트 세트에서 예측 정확도를 계산하여 모델 일반화 성능을 평가해 보자.
y_test_pred = nn.predict(X_test)
acc = (np.sum(y_test == y_test_pred).astype(np.float) / X_test.shape[0])
print('테스트 정확도: %.2f%%' % (acc * 100))
# 테스트 정확도: 97.54%
  • 훈련 세트에 조금 과대적합되었지만 하나의 은닉층을 가진 간단한 이 신경망은 비교적 테스트 세트에서 좋은 성능을 달성했다.
    • 이 모델을 더 세밀하게 튜닝하면 은닉 유닛 개수나 규제 매개변수의 값, 학습률을 바꿀 수 있다. 최근 수년간 개발된 여러 다양한 기법이 있지만 이는 이 책의 범위를 넘어선다.
  • 끝으로 이 MLP 구현이 샘플 이미지를 어떻게 판단하는지 알아보자
miscl_img = X_test[y_test != y_test_pred][:25]
correct_lab = y_test[y_test != y_test_pred][:25]
miscl_lab = y_test_pred[y_test != y_test_pred][:25]

fit, ax = plt.subplots(nrows=5, ncols=5, sharex=True, sharey=True)
ax = ax.flatten()

for i in range(25):
   img = miscl_img[i].reshape(28, 28)
   ax[i].imshow(img, cmap='Greys', interpolation='nearest')
    ax[i].set_title('%d) t: %d p: %d' % (i+1, correct_lab[i], miscl_lab[i]))

ax[0].set_xticks([])
ax[0].set_yticks([])

plt.tight_layout()
plt.show()

  • 5×5 격자 그래프를 볼 수 있다. 각 그래프 제목에 나타난 첫 번째 숫자는 그래프 번호이고 두 번째 숫자는 클래스 레이블, 세 번째 숫자는 예측된 클래스 레이블을 나타낸다.

인경 신경망 훈련

로지스틱 비용 함수 계산

  • _compute_cost 메서드에 구현한 로지스틱 비용 함수는 3장에서 설명한 비용 함수와 같다.

J(w) = - \sum_{i=1}^{n} y^{[i]} \log (a^{[i]}) + (1 - y^{[i]}) \log (1 - a^{[i]})

  • 여기서 a^{[i]} 는 데이터셋 n 번째 샘플의 시그모이드 활성화 출력이다. 다음과 같이 정방향 계산을 통해서 구한다.

a^{[i]} = \phi (z^{[i]})

  • 여기서 위첨자 [i] 는 층이 아니라 훈련 샘플의 인덱스이다.
  • 과대적합의 정도를 줄여 주는 규제 항을 추가해보자. 앞서 배운대로 L2 규제 항은 다음과 같이 정의 된다.

L2 = \lambda \| w \|_{2}^{2} = \lambda \sum_{j = 1}^{m} w_{j}^{2}

  • L2 규제 항을 로지스틱 비용 함수에 추가하면 다음 식을 얻는다.

J(w) = - [\sum_{i=1}^{n} y^{[i]} \log (a^{[i]}) + (1 - y^{[i]}) \log (1 - a^{[i]})] + {\lambda \over 2} \|w\|_{2}^{2}

  • t 개의 원소를 가진 출력 벡터를 반환하는 다중 분류 MLP를 구현했다. 원-핫 인코딩 표현의 t \times 1 차원의 타깃 벡터와 비교해야 한다.
    • 예컨대 특정 샘플에 대한 세 번째 층의 활성화 출력과 타깃 클래스(여기서는 클래스 2)는 다음과 같다.

a^{(out)} = \left[ \begin{array}{rrrr} 0.1 \\ 0.9 \\ ... \\ 0.3 \end{array} \right], y = \left[ \begin{array}{rrrr} 0 \\ 1 \\ ... \\ 0 \end{array} \right]

  • 따라서 네트워크에 있는 t 개의 활성화 유닛 전체에 대해 로지스틱 비용 함수를 일반화 해야 한다. 결국 비용 함수는 다음과 같다. (규제 항 제외)

J(w) = - \sum_{i=1}^{n} \sum_{j=1}^{t} y_{j}^{[i]} \log (a_{j}^{[i]}) + (1 - y_{j}^{[i]}) \log (1 - a_{j}^{[i]})

  • 다음 일반화된 규제항은 l 층의 모든 가중치(절편 제외) 합을 더해 첫 번째 항에 추가한 것 뿐이다.

J(w) = - [\sum_{i=1}^{n} \sum_{j=1}^{t} y_{j}^{[i]} \log (a_{j}^{[i]}) + (1 - y_{j}^{[i]}) \log (1 - a_{j}^{[i]})] + {\lambda \over 2} \sum_{l=1}^{L-1} \sum_{i=1}^{u_{l}} \sum_{j=1}^{u_{l+1}} (w_{j, i}^{(l)})^{2}

  • 여기서 u_{l} l 층에 있는 유닛 개수를 나타낸다.
  • 결국 다음 식이 페널티 항을 나타낸다.

{\lambda \over 2} \sum_{l=1}^{L-1} \sum_{i=1}^{u_{l}} \sum_{j=1}^{u_{l+1}} (w_{j, i}^{(l)})^{2}

  • 비용 함수 J(W) 를 최소화하는 것이 목적이므로 네트워크의 모든 가중치에 대해 파라미터 W 의 편도 함수를 계산해야 한다.

{\partial \over \partial w_{j, i}^{(l)}} J(W)

  • 다음 절에서 비용 함수를 최소화하기 위한 편도 함수를 계산해 주는 역전파 알고리즘을 이야기해 보겠다.
  • W 는 여러 행렬로 구성되어 있다. 하나의 은닉층을 가진 다층 퍼셉트론에서는 W^{(h)} 가 입력층과 은닉층을 연결하는 가중치 행렬이고, W^{(out)} 이 은닉층과 출력층을 연결하는 가중치 행렬이다.
  • 3차원 텐서 W 를 이해하기 쉬운 그림으로 보면 다음과 같다.

  • 위 그림은 간단한 예로 MLP의 은닉 유닛, 출력 유닛, 입력 특성의 개수가 같지 않다면 W^{(h)} W^{(out)} 은 같은 행과 열을 갖고 있지 않다.

역전파 알고리즘 이해

  • 역전파 알고리즘이 재발견되어 널리 알려진지 30년이 넘었지만 효과적인 인공 신경망 훈련을 위해 가장 많이 사용되는 알고리즘 중 하나로 남아 있다.
  • 이 절에서는 간단하고 직관적으로 요약해 보겠다. 수학적으로 상세히 알아보기 전에 이 알고리즘의 큰 그림을 그려보겠다.
    • 핵심적으로 말해서 역전파 알고리즘은 다층 신경망에서 복잡한 비용 함수의 편미분을 효율적으로 계산하기 위한 방법으로 생각할 수 있다. 이 편미분을 사용하여 다층 인공 신경망의 가중치 파라미터를 학습한다.
    • 신경망은 전형적으로 고차원 특성 공간에서 비롯된 대규모 가중치를 다루어야 하기 때문에 학습하기 어렵다. 아달린이나 로지스틱 회귀처럼 단일층 신경망의 비용 함수와 달리 일반적인 신경망의 비용 함수 곡면은 볼록 함수가 아니거나 파라미터에 대해 매끄럽지 않다. 고차원 비용 함수의 곡면에는 전역 최솟값을 찾기 위해 넘어야 할 굴곡(지역 최솟값)이 많다.
  • 수학 시간에 배운 연쇄 법칙을 기억해 보자. 연쇄 법칙은 f(g(x)) 처럼 복잡하고 중첩된 함수의 도함수를 계산하는 방법이다. 예컨대 다음과 같다.

{d \over dx} [f(g(x))] = {df \over dg} \cdot {dg \over dx}

  • 비슷하게 임의의 긴 합성 함수에 연쇄 법칙을 사용할 수 있다. 예컨대 다섯 개의 다른 함수 f(x), g(x), h(x), u(x), v(x) 가 있다고 가정하자. F 는 함성함수로 F(x) = f(g(h(u(v(x))))) 이다.
    • 연쇄 법칙을 적용하면 다음과 같이 이 함수의 도함수를 계산할 수 있다.

{dF \over dx} = {d \over dx} F(x) = {d \over dx} f(g(h(u(v(x))))) = {df \over dg} \cdot {dg \over dh} \cdot {dh \over du} \cdot {du \over dv} \cdot {dv \over dx}

  • 컴퓨터 대수학(computer algebra)에서는 이런 문제를 효율적으로 풀기 위한 여러 기법을 개발했다. 이를 자동 미분(automatic differenctiation)
    • 자동 미분은 정방향과 역방향 두 가지 모드가 있다. 역전파는 역방향 자동 미분의 특별한 경우이다.
    • 핵심은 정방향 모드로 연쇄 법칙을 적용하면 계산 비용이 많이 들 수 있다는 것이다. 각 층마다 큰 행렬(야코비 행렬)을 곱한 후 마지막에 벡터를 곱해 출력을 얻기 때문이다.
    • 역방향 모드는 오른쪽에서 왼쪽으로 진행한다. 행렬과 벡터를 곱하여 또 다른 벡터를 얻은 후 다음 행렬을 곱하는 식이다.
    • 행렬-벡터 곱셉은 행렬-행렬 곱셈보다 훨씬 계산 비용이 적게 든다. 신경망을 훈련할 때 역전파 알고리즘이 가장 인기 있는 알고리즘이 된 이유이다.

역전파 알고리즘으로 신경망 훈련

  • 이전 절에서 마지막 층의 활성화 출력과 타깃 클래스 레이블 사이 차이인 비용을 계산하는 방법을 보았다. 이제 수학적 측면에서 역전파 알고리즘이 MLP 가중치를 업데이트하는 방법을 알아보자.
    • 이 부분은 fit 메서드의 ‘# 역전파’ 주석 아래에 구현되어 있다.
  • 먼저 출력층 활성화를 얻기 위해 정방향 계산을 수행해야 한다. 공식은 다음과 같다.
    • 은닉층의 최종 입력
      • Z^{(h)} = A^{(in)} W^{(h)}
    • 은닉층의 활성화 출력
      • A^{(h)} = \phi (Z^{(h)})
    • 출력층의 최종 입력
      • Z^{(out)} = A^{(h)} W^{(out)}
    • 출력층의 활성화
      • A^{(out)} = \phi (Z^{(out)})
  • 간단히 표현해서 아래 그림에 나타난 것처럼 입력 특성을 연결된 네트워크를 통해 앞으로 전파하는 것이다.

  • 역전파에서는 오차를 오른쪽에서 왼쪽으로 전파시킨다. 먼저 출력층의 오차 벡터를 계산한다.

\delta^{(out)} = a^{(out)} - y

  • 여기서 y 는 정답 클래스 레이블 벡터이다 (NeutralNetMLP 코드에서 출력층의 오차 벡터에 대응하는 변수는 sigma_out이다)
  • 다음으로 은닉층의 오차 항을 구해보자

\delta^{(h)} = \delta^{(out)} (W^{(out)})^{T} \odot {\partial \phi (z^{(h)}) \over \partial z^{(h)}}

  • \odot 는 원소별 곱셈을 의미한다.
  • 여기서 {\partial \phi (z^{(h)}) \over \partial z^{(h)}} 는 시그모이드 활성화 함수의 도함수이다.
    • NeuralNetMLP의 fit 메서드에서 sigmoid_derivative_h = a_h * (1. – a_h)로 계산한다.

{\partial \phi (z) \over \partial z} = (a^{(h)} \odot (1 - a^{(h)}))

  • 이제 은닉층의 오차행렬 \delta^{(h)} (sigma_h)는 다음과 같이 계산한다.

\delta^{(h)} = \delta^{(out)} (W^{(out)})^{T} \odot (a^{(h)} \odot (1 - a^{(h)}))

  • \delta^{(h)} 항을 어떻게 계산하는지 이해하기 위해 좀 더 자세히 살펴보자.
    • 앞 공식에서 h \times t 차원의 행렬 W^{(out)} 의 전치행렬 (W^{(out)})^{T} 를 사용했다. 여기서 h 는 은닉 유닛 개수고 t 는 출력 클래스 레이블 개수이다.
    • n \times t 차원 행렬 \delta^{(out)} t \times h 차원 행렬 (W^{(out)})^{T} 의 행렬 곱셉은 n \times h 차원의 행렬을 만든다.
    • 그 다음 동일 차원의 시그모이드 도함수를 원소별 곱셈하여 n \times h 차원 행렬 \delta^{(h)} 를 구한다.
    • 결국 \delta 를 구한 후에는 비용 함수의 도함수를 다음과 같이 쓸 수 있다.

{\partial \over \partial w_{i, j}^{(out)}} J(W) = a_{j}^{(h)} \delta_{i}^{(out)}

{\partial \over \partial w_{i, j}^{(h)}} J(W) = a_{j}^{(in)} \delta_{i}^{(h)}

  • 그 다음 각 층에 있는 모든 노드의 편도 함수와 다음 층의 노드 오차를 모아야 한다. 미니 배치에 있는 모든 샘플에 대해 \Delta_{i, j}^{(l)} 를 계산해야 한다는 것을 기억하자.
    • NeuralNetMLP 코드에 있는 것처럼 벡터화된 구현을 만든는 것이 좋다.

\Delta^{(h)} = (A^{(in)})^{T} \delta^{(h)}

\Delta^{(out)} = (A^{(h)})^{T} \delta^{(out)}

  • 편도 함수를 누적한 후 규제항을 추가한다.
    • 절편은 제외한다.

\Delta^{(l)} := \Delta^{(l)} + \lambda^{(l)} W

  • 앞의 두 수학 공식에 대응되는 NeuralNetMLP 코드의 변수는 delta_w_h, delta_b_h, delta_w_out, delta_b_out 이다.
  • 마지막으로 그래디언트를 계산하고 각 l 층에 대한 그래디언트의 반대 방향으로 가중치를 업데이트 한다.

W^{(l)} := W^{(l)} - \eta \Delta^{(l)}

  • 코드 구현은 다음과 같다.
self.w_h -= self.eta * delta_w_h
self.b_h -= self.eta * delta_b_h
self.w_out -= self.eta * delta_w_out
self.b_out -= self.eta * delta_b_out
  • 역전파 알고리즘 전체 과정을 그림으로 정리하면 다음과 같다.

신경망의 수렴

  • 손글씨 숫자 분류를 위해 신경망을 훈련시킬 때 기본 경사 하강법을 사용하지 않고 미니 배치 방식을 사용했는지 궁금할 것이다.
    • 온라인 학습에서는 한 번에 하나의 훈련 샘플(k = 1 )에 대해 그래디언트를 계산하여 가중치를 업데이트했다. 확률적이지만 매우 정확한 솔루션을 만들고 기본 경사 하강법보다 훨씬 빠르게 수렴한다.
    • 미니 배치 학습은 확률적 경사 하강법의 특별한 경우이다. n 개의 훈련 샘플 중 k 개의 부분 집합에서 그래디엍르르 계산한다. 1 < k < n
    • 미니 배치 학습은 벡터화된 구현을 만들어 계산 효율성을 높일 수 있다는 것이 온라인 학습보다 장점이다. 기본 경사 하강법보다 훨씬 빠르게 가중치가 업데이트 된다.
    • 직관적으로 보았을 때 미니 배치 학습을 대통령 선거의 투표율을 예측하기 위해 (실제 선거와 동일하게) 전체 인구가 아니라 일부 표본 집단에 설문하는 것으로 생각할 수 있다.
  • 다층 신경망은 아달린, 로지스틱 회귀, 서포트 벡터 머신 같은 알고리즘보다 훨씬 훈련하기 어렵다. 다층 신경망은 일반적으로 최적화해야 할 가중치가 수백 개, 수천 개, 심지어 수백만 개가 있다.
    • 게다가 아래 그림과 같이 손실 함수의 표면은 거칠어서 최적화 알고리즘이 쉽게 지역 최솟값에 갇힐 수 있다.

  • 신경망은 매우 많은 차원을 갖고 있어서 비용 함수의 곡면을 시각적으로 나타낼 수 없다.
    • 여기서는 하나의 가중치에 대한 비용 함수의 곡선을 x축에 타나냈다.
  • 이 그림은 알고리즘이 지역 최솟값에 갇혀서는 안된다는 것을 설명한다. 학습률을 크게 하면 지역 최솟값을 손쉽게 탈출 할 수 있지만, 거꾸로 전역 최솟값을 지나칠 수 있는 가능성도 높아진다.
    • 랜덤하게 가중치를 초기화하기 때문에 일반적으로 최적화 문제의 해는 잘못된 지점에서 출발하는 셈이다.

21세기 자본/ 두 개의 세계

간단한 사례: 20세기 프랑스에서의 불평등 감소

  • 아래 도표 9.1은 국민소득과 임금에서 상위 10%가 차지하는 비율을 역사적 시기별로 보여주는데 여기서 3가지 사실이 눈에 띈다.

  • 첫째, 벨 에포크 시대 이후 프랑스에서는 소득불평등이 크게 줄었다.
    • 소득 상위 10%가 국민소득에서 차지하는 비율은 1차대전 직전 45-50%에서 오늘날에는 30-35%로 감소했다.
    • 국민소득에서 차지하는 비율이 15%나 하락한 것은 상당한 수준이라 할 수 있는데, 이는 매년 생산량에서 인구의 가장 부유한 10%에게 가는 비율이 1/3 정도 줄고 나머지 90%에게 가는 비율이 1/3정도 늘어났음을 의미한다.
    • 이 수치는 벨 에포크 시대에 하위 50%가 받던 몫의 절반을 웃도는 수준이다.
  • 둘째, 20세기 소득불평등이 상당히 축소된 것은 전적으로 자본소득에서 최상위 소득이 줄어든데 기인한 것이다.
    • 자본소득을 무시하고 임금 불평등에만 집중해서 보면 분배가 장기간에 걸쳐 상당히 안정적으로 유지되었음을 알 수 있다.
    • 지난 세기에 분명 임금 수준이 크게 변했고 노동력의 구성과 기술도 완전히 바뀌었지만 임금의 계층 구조는 비슷하게 유지되었다.
    • 자본소득에서 최상위 소득이 줄어들지 않았다면 20세기 소득불평등은 줄어들지 않았을 것이다.

  • 요약하자면 20세기 프랑스에서 불평등의 감소는 주로 자본소득의 몰락과 최상위 자본소득의 급감으로 설명되는데, 이것이 바로 부의 분배의 역사적 동학이 주는 근본적인 교훈이다.
    • 약간의 차이가 있지만 실제 상황은 모든 선진국에서 동일하다.

불평등의 역사: 혼돈의 정치사

  • 세 번째 중요한 사실은 불평등의 역사가 길고 평온한 강처럼 흘러오지 않았다는 점이다. 그 역사에는 많은 우여곡절이 있었고, ‘자연적’ 균형 상태를 향해 가는 거스를 수 없는 규칙적인 경향은 확실히 존재하지 않았다.
    • 프랑스든 다른 국가든 불평등의 역사는 항상 혼란스럽고 정치적이었으며, 급격한 사회 변동의 영향을 받았고 경제적 요인들뿐만 아니라 무수한 사회적, 정치적, 군사적, 문화적 요인들에 의해 추동되어 왔다.
    • 사회적 불평등은 –사회집단 간의 소득과 부의 격차– 언제나 다른 영역들에서 전개되는 다른 발전들의 원인이자 결과다. 이런 분석의 모든 차원은 서로 복잡하게 뒤얽혀 있다.
  • 20세기 불평등을 감소시킨 요인은 상당 부분 전쟁의 혼란과 그에 뒤따른 경제적, 정치적 충격이었다. 이때 갈등 없이 합의에 따라 평등의 제고를 향해 점진적으로 나아간 것은 아니었다. 20세기 과거를 지우고 사회가 새로 출발할 수 있도록 해주 것은 민주적, 경제적 합리성이 아니라 전쟁이었다.
  • 이 충격은 무엇이었을까? 바로 두 차례 세계대전으로 인한 파괴, 대공황이 불러온 파산, 그리고 무엇보다 이 시기에 시행된 모든 새로운 공공정책이다.
    • 이 모든 것이 1913-1945년 사이에 자본/소득 비율을 급격하게 떨어뜨렸고 국민소득에서 자본소득이 차지하는 비중을 크게 감소시켰다.
    • 그러나 노동보다 자본의 집중도가 훨씬 더 높기 때문에 소득계층의 상위 10%에서는 자본소득의 비중이 아주 높다. 따라서 1914-1945년에 자본, 특히 민간자본이 받은 충격들로 사우이 10%가 차지하는 몫이 줄고 궁극적으로 소득불평등이 상당히 축소되었다는 사실은 놀라운 일이 아니다.
  • 프랑스는 1914년에 처음 소득세를 부과했다.

자본소득자 사회에서 경영자 사회로

  • 1932년에는 소득분배에서 최상위 0.5%에게는 여전히 자본소득이 주된 소득원이었으나 오늘날에는 심대한 변화가 일어났다.
    • 오늘날에도 과거와 마찬가지로 소득계층 구조의 위쪽으로 갈수록 노동소득이 사라지고 상위 1%와 0.1% 사이에는 자본소득이 더욱 지배적이다. 이런 구조적 특징은 변하지 않았다.
    • 그러나 중요한 차이가 한 가지 있는데, 오늘날에는 사회계층의 훨씬 더 위쪽으로 올라가야 자본소득이 노동소득보다 커진다는 점이다.
    • 지금은 소득 상위 0.1%에서만 자본소득이 노동소득을 초과한다.
    • 1932년에는 자본소득이 노동소득을 초과하는 사회집단이 5배 컸고, 벨에포크 시대에는 10배나 컸다.

  • 이는 틀림없이 중요한 변화인데, 상위 1%는 어느 사회에서나 매우 중요한 위치를 차지하며, 경제적, 사회적 지형을 만들어내는 집단이다. 그러나 이는 상위 0.1%에는 해당되지 않는다.
    • 이는 정도의 문제긴 하지만 그럼에도 중요한 이유는 양적인 문제가 질적인 문제가 되는 순간이 존재하기 때문이다.
    • 이러한 변화는 오늘날 상위 1%가 소득에서 차지하는 몫이 총임금에서 차지하는 몫보다 가까스로 높은 이유도 설명해 준다.
    • 자본소득은 상위 0.1% 이상에서만 결정적인 중요성을 지닐 뿐, 상위 1% 전체에 미치는 영향력은 비교적 미미하다.
  • 상당한 정도로 우리는 자본소득자의 사회에서 경영자의 사회로, 즉 상위 1%가 대부분 자본소득자들이었던 사회에서 상위 1%에 해당되는 소득계층의 최상위층이 주로 높은 보수의 노동소득으로 생활하는 개인들로 구성되는 사회로 바뀌었다.
    • 좀더 정확히 말하면 초자본소득자 사회에서 일에 의한 성공과 자본에 의한 성공이 좀더 균형잡힌 덜 극단적인 형태의 자본소득자 사회로 이행했다고 할 수 있다.
    • 이런 변화는 전적으로 높은 자본소득의 감소로 인해 일어났다.
  • 2차대전 이후 부의 집중을 제한하고 1차대전 직전에 존재했던 초자본소득자 사회의 부활을 지금까지 막아온 구조적 요인의 하나로서 분명하게 말할 수 있는 것은 매우 누진적인 소득세와 상속세의 도입이다.

상위 10%의 서로 다른 세계들

  • 소득계층의 사우이 10%를 구성하는 사회집단에 관해 생각해 보자.
    • 시간이 지나면서 그 내부의 다양한 하위 집단 사이의 경계가 변했는데, 자본소득은 상위 1%에서 지배적인 역할을 했지만 오늘날에는 상위 0.1%에서만 큰 비중을 차지한다.
    • 모든 국가에서 모든 시기에 소득 최상위층의 소득 구성이 도표 8.3과 8.4에 나타난 1932년과 2005년의 프랑스처럼 교차 곡선의 특징을 띤다는 점이 특히 주목할만 하다.
    • 상위 10% 내에서 점점 더 위쪽으로 올라갈수록 노동소득이 차지하는 비율은 언제나 빠르게 감소하고 자본소득의 비율은 언제나 급격하게 상승한다.
  • 상위 10% 중 하위 5%는 소득의 80-90%를 노동에 대한 보상으로 얻는 진정한 경영자들의 세계다.
    • 그 위의 4%로 올라가면 노동소득의 비율이 약간 줄어들지만 오늘날뿐만 아니라 두 차례 세계대전 사이의 기간에도 노동소득이 총소득의 70-80%에 리를 정도로 뚜렷이 지배적인 비중을 차지한다.
    • 이 다수의 9% 집단에서는 민간부문의 경영자와 기술자 그리고 공공부문의 고위공무원이나 교사 등으 ㄹ모두 포함해 주로 노동소득으로 생활하는 개인들을 발견할 수 있다.
    • ㅇ 집단이 받는 보수는 일반적으로 사회 전체 평균 임금의 2-3배이다.
  • 이 수준에 요구되는 직업의 유형과 능력 수준은 시간이 지나면서 상당히 바뀌었다.
    • 두 차례 세계대전 사이의 기간에는 고등학교 교사, 경력 많은 초등학교 교사도 9%에 속했던 반면, 오늘날 이 집단에 속하려면 대학교수나 연구원은 되어야 하고 정부의 고위공무원이면 더 나을 것이다.
    • 과거에는 현장감독이나 숙련기술자도 이 집단에 들어갈 수준에 거의 근접했다. 하지만 오늘날 이 집단에 들어가려면 적어도 중간급 경영자는 되어야 한다.
    • 급여 체계의 아래쪽으로 가도 마찬가지다. 옛날에는 보수를 가장 적게 받는 노동자들이 농장 일꾼과 하인들이었다. 시간이 좀 더 흐른 뒤에는 기술력이 낮은 산업 노동자들이 그 자리를 차지했고, 그중 많은 사람들이 직물과 식품가공 산업에서 일하는 여성들이었다.
    • 이 집단은 오늘날에도 존재하지만, 현재 가장 적은 보수를 받는 노동자들은 식당의 웨이터나 웽트리스 혹은 상점 점원 등 서비스부문에 고용된 사람들이다. (역시 이중 많은 사람이 여성이다)
    • 따라서 지난 세기에 노동시장은 완전히 변화했지만, 시장에서 임금불평등의 구조는 오랫동안 거의 변하지 않았다.
    • 즉 최상위 바로 아래의 9%와 하위 50%가 노동소득에서 차지하는 몫은 상당히 오랜 기간 거의 변함이 없었다.
  • 9%내에서는 의사, 변호사, 상인, 식당 주인과 자영업자들도 발견할 수 있다. 혼합소득을 나타내는 곡선이 보여주는 것처럼 1%에 가까이 갈수록 이들의 숫자가 늘어난다.
    • 혼합소득은 상위 1% 경계부근에서는 총소득의 20-30%를 차지하지만 상위 1%로 올라갈수록 그 비중은 줄어들고 순수한 자본소득이 분명하게 지배적이다.
    • 9%에 들어가거나 심지어 1%의 하위층으로 올라가려면 다시 말해 평균보다 4-5배 높은 소득을 올리려면 의사, 법률가 혹은 성공한 식당 주인이 되는 것이 좋은 전략일 수 있다. 그것은 대기업의 고위경영자가 되기로 하는 것만큼이나 일반적인 선택이다.
    • 그러나 1%의 최고 단계에 들어가 평균의 수십 배에 이르는 소득을 얻으려면 그런 전략으로는 충분하지 않다. 상당한 자산을 소유한 사람이 소득계층 구조의 최상위층에 이를 가능성이 더 크다.
  • 이런 계층 구조가 역전되었던 때가 전쟁 직후 뿐이었다는 점은 흥미롭다. 당시 상위 1% 내의 상위층에서 혼합소득이 자본소득을 아주 잠깐 넘어섰다. 이는 분명 전후의 재건과 맞물려 새로운 재산이 빠르게 축적도니 현상을 반영하는 것이다.
  • 요약하자면 상위 10%에는 언제나 두 개의 매우 다른 세계가 존재한다. 노동소득이 분명히 우위를 차지하는 9%와 자본소득이 점점 더 중요해지는 1%가 그것이다.
    • 두 집단 간의 전환은 언제나 서서히 이루어지며, 물론 서로가 경계를 오갈 수 있지만 두 집단 간에는 분명하고 체계적인 차이가 존재한다.
    • 예컨대 9%의 소득에 자본소득이 전혀 없는 것은 아니지만 자본소득은 부수적인 역할만 한다. 반대로 1%에서는 노동소득이 부수적이 되는 한편 자본소득이 점점 더 주요한 소득의 원천이 된다.

소득세 신고의 한계

  • (자료로 사용된 세금 자료의 한계 설명 내용 생략)

전간기의 혼란

  • 1914-1945년 사이에 소득계층의 상위 1%가 차지하는 몫이 서서히 줄어들어 1914년의 20%에서 1945년에는 7%로 떨어졌다. 이런 꾸준한 감소는 이 시기 자본소득에 가해진 연이은 장기적 충격을 반영한다.
    • 반면 소득계층의 상위 10%가 차지하는 몫의 하락세는 지속성이 훨씬 덜 했다. 1차대전 동안에는 감소했지만 20년대 불규칙하게 회복되었고, 1929-1935년 사이에는 급격히 상승했다가 1936-1938에는 가파르게 줄어들었고 2차대전때는 격감했다.
    • 결국 상위 10%가 소득에서 차지하는 몫은 1914년에는 45%를 넘었지만 1944-45년에는 30% 이하로 떨어졌다.
  • 1014-1945년 기간 전체를 검토하면 두 계층이 차지하는 몫의 감소 양상은 매우 일관성을 보인다. 내 추정치에 따르면 상위 10%가 차지하는 몫은 거의 15%, 상위 1%가 차지하는 몫은 거의 14%가 감소했다.
    • 1% 계층이 1914-1945년 사이의 불평등 감소의 거의 3/4을 차지하고 나머지 9% 게층은 1/4을 차지한다.
    • 1%의 손에 자본이 고도로 집중되어 있는 점을 고려하면 놀라운 일은 아니다. 게다가 1%는 종종 위험성이 더 높은 자산을 소유한다.
  • 1%의 소득 대부분은 자본소득 형태, 특히 주식과 채권을 발행한 기업들이 지급한 이자와 배당금에서 나왔는데, 대공황 시기 경제가 붕괴하고 기업들이 도산함에 따라 상위 1%의 몫이 급락하게 되었다.
  • (이하 프랑스 상황에 따라 소득 계층 변화 내용 생략)

장기와 단기의 충돌

  • (장기-단기 관점 내용 생략)
  • 프랑스에서 1945-2010년의 불평등 역사를 살펴보면 세 가지의 뚜렷한 국면으로 나뉜다는 것을 알 수 있다.
    • 1945-1967년 동안에 소득불평등이 급격히 증가했다.
    • 그후 1968-1983년 동안 상당히 떨어졌다.
    • 1983년 이후 불평등이 꾸준히 증가하기 시작해서 2000-2010년에는 상위 10%의 몫이 33%까지 올라갔다.
  • 자본-노동 소득분배가 노동소득의 불평등과 같은 방향으로 변화해 중-단기적으로 서로를 강화하지만 장기적으로는 꼭 그렇지 않다는 점이 흥미롭다.
    • 예컨대 두 차례의 세계대전 동안 모두 국민소득에서 자본이 차지하는 몫이 감소했을 분 아니라 임금불평등도 축소되었다. 일반적으로 말해 불평등은 ‘경기순행적(procyclical)’으로 전개되는 경향을 보인다. –즉 경기숭환과 같은 방향으로 움직인다.
    • 경제가 호황일 때는 국민소득에서 기업 이윤이 차지하는 몫이 증가하는 경향이 있고 임금 체계의 최상위층이 받는 보수가 흔히 하위층과 중간읓의 임금보다 더 많이 증가한다.
    • 거꾸로 말하면 경기가 후퇴하거나 불황일 때는 다양한 비경제적 요인, 특히 정치적인 요인으로 인해 이러한 움직임들은 오로지 경기 변동에 의해서만 좌우되지 않는다.
  • (프랑스의 설명 생략)

1980년대 이후 프랑스에서의 불평등 증가

  • (생략)

더 복잡한 경우: 미국의 불평등 변화

  • 미국이 눈에 띄는 이유는 지난 수십 년 동안 ‘슈퍼경영자’ 라는 계급이 처음 등장한 곳이기 때문이다.
    • 프랑스와 미국의 궤적을 비교해보면 많은 유사점이 눈에 띄지만 몇몇 중요한 차이점도 나타난다.
  • 먼저 상위 10%가 차지하는 소득의 몫이 전체적으로 어떤 변화 추이를 보였는지 살펴보자.
    • 가장 두드러진 사실은 20세기가 시작될 무렵에는 미국이 더 평등했지만 20세기가 접어든 이래 미국이 프랑스보다 (그리고 전체적으로 유럽보다) 현저히 더 불평등해졌다는 것이다.
    • 미국의 사례를 복잡하게 만드는 것은 이 과정의 끝이 시작 당시 상황으로의 회귀가 아니었다는 것이다.
    • 2010년 미국의 불평등은 그 규모로 보면 20세기의 첫 10년 동안의 유럽만큼 심각하지만 그 구조는 다르다.

  • 20세기가 시작될 무렵에는 유럽의 소득불평등이 미국보다 훨씬 더 높았다.
    • 그러나 1920년대 미국에서는 유럽 국가들이 1914년 이후 이미 겪었던 커다란 충격의 결과로 소득불평등이 매우 급격하게 증가했다.
    • 소득불평등은 1929년 대공황 직전에 절정에 달해 상위 10%가 국민소득에서 차지하는 몫이 50%를 넘었다.
    • 대공황 시기와 2차대전 기간에 임금불평등은 상당히 축소되었다.
    • 1914-1945년 유럽에서는 자본소득자 사회가 자멸했지만 미국에서는 그런 현상이 전혀 나타나지 않았다.

1980년 이후 폭발한 미국의 불평등

  • 미국에서는 1950-1980년 사이에 불평등이 최저 수준으로 떨어져 소득계층의 상위 10%가 미국 국민소득의 30-35%를 차지했다. 이는 오늘날의 프랑스와 거의 같은 수준이다.
  • 그러나 1980년 이후에는 미국에서 소득불평등이 폭발적으로 증가해서 상위 10%의 몫이 1970년대 30-35%에서 2000년대에는 45-50%로 증가했다.
  • 증시 호황과 자본이득은 지난 30-40년간 나타난 상위 10%가 차지하는 몫의 구조적 증가의 일부분만 설명할 수 있다는 점에 주목해야 한다.
    • 자본이득을 무시해도 상위 10%가 차지하는 몫이 상당히 증가해 1970년대 국민소득의 약 32%에서 2010년에는 46% 이상으로 증가했다.
  • 자본이득을 제외한 추이를 살펴보면 미국의 불평등 증가의 구조적 특징을 더욱 명확히 알 수 있다. 실제로 1970년대에서 2010년까지 상위 10%가 차지하는 몫의 증가는 비교적 꾸준하고 연속적이었던 것으로 보인다.
    • 눈에 띄는 점은 2010년도의 불평등이 금융위기 직전인 2007년 수준보다 상당히 더 높다는 사실이다.
    • 주식 시장의 활황기에 불평등이 더 빨리 증가하는 것처럼 증시 대폭락 직후에 불평등의 증가가 둔화되는 것이 분명하다.
    • 하지만 이러한 단기적 변동이 다른 힘들의 지배를 받는 장기적 추세를 바꾸지는 않았다.

불평등의 증가가 금융위기를 불러왔을까?

  • 금융위기 자체는 불평등의 구조적 증가에 크게 영향을 미치지 않았던 것으로 보인다. 그렇다면 그 역은 성립할까? 미국의 불평등 증가가 2008년 금융위기를 촉발하는데 기여했을까?
  • 미국에서 불평등의 증가가 미국의 금융 불안정에 기여했다는 사실은 의심할 여지가 없어보인다.
    • 불평등 증가의 한 결과로 하류층과 중산층의 구매력이 거의 정체되었고, 평범한 가구가 빚을 질 가능성이 더 높아졌다. 특히 규제에서 자유로워진 그리고 부유층이 금융시스템에 투입한 거대한 저축으로부터 높은 수익률을 얻고자 갈망했던 비양심적인 은행과 금융기관들이 점점 더 관대한 조건으로 신용을 제공했기 때문에 더욱 그러했다.
  • 불평등의 증가가 미국 경제의 예외적으로 강한 성장과 동반되었더라면 상황은 꽤 달라졌을 것이지만, 유감스럽게도 그렇지 않았다.
    • 미국 경제는 이전 10년보다 더 느리게 성장했고 그리하여 불평등의 증가는 저소득층과 중산층의 소득이 거의 정체되는 결과로 이어졌다.
  • 미국에서 불평등의 증가가 2008년 금융위기나 세계 금융시스템의 만성적 불안정의 유일한, 혹은 주된 요인이라고 주장하는 것은 지나칠 것이다.
    • 내가 보기에 불안정을 일으킨 잠재적으로 더 중요한 요인은 자본/소득 비율의 구조적 증가가 국제적 자산 포지션의 엄청난 총증가와 결합된 것이다.

슈퍼 연봉의 부상

  • 미국의 불평등 증가는 주로 전례 없는 임금불평등의 증가와 특히 임금계층의 꼭대기층, 그중에서도 대기업 최고위 경영진의 보수가 극도로 높아진 결과다.
  • 대체적으로 말해 미국의 임금불평등은 지난 세기에 중대한 변화를 겪었다.
    • 1920년대 확대되었다가 1930년대 비교적 안정화 되었고, 2차대전 동안 극심하게 축소되었다.
    • 이 ‘대압축’ 국면에 대해 많은 연구가 이루어졌다. 이 과정에서 1941-1945년 사이에 미국의 모든 임금 인상안을 심사하고 일반적으로 가장 낮은 보수를 받는 노동자들의 임금 인상만을 승인했던 국가전시노동위원회가 중요한 역할을 했다. 특히 경영자들의 급여는 물가 상승과 관게없이 체계적으로 동결되었고 전쟁이 끝날 무렵에도 완만하게 인상되었다.
    • 1950년대 미국의 임금불평등은 비교적 낮은 수준으로 안정화되었다가 1970년대 중반부터 상위 10% 더 나아가 상위 1%가 노동소득에서 차지하는 몫이 평균 임금보다 더 빠른 속도로 증가하기 싲가했다.
    • 결국 지금까지 통틀어 상위 10%가 차지하는 몫이 25%에서 35%로 증가했고 이러한 증가는 국민소득에서 상위 10%가 차지하는 몫의 증가분에서 2/3를 설명한다.

  • 우리는 장기간(10년, 20년, 30년)에 걸친 개인 수준에서 평균 임금을 계산할 수 있는데, 어떤 기간을 택하더라도 임금불평등의 증가가 모든 경우에 동일하다는 것을 발견했다.

소득 상위 10% 내의 동거체제

  • 임금불평등의 전례 없는 증가가 미국의 소득불평등 증가의 대부분을 설명한다고 해서 자본소득이 아무 역할도 하지 않았다는 뜻은 아니다.
    • 실제로 1980년 이후 자본소득 불평등의 상당한 증가가 미국 소득불평등 증가의 1/3을 차지한다.
    • 유럽과 마찬가지로 과거뿐 아니라 현재에도 소득계층의 위로 올라갈수록 자본소득의 중요성이 언제나 더 커진다. 시간과 공간에 따른 차이는 정도의 차이일 뿐이며, 그 차이가 크다 해도 일반적인 원칙은 그대로다.
    • 상위 1%는 언제나 다른 여러 사회집단으로 구성되며 그중에는 자본소득이 매우 높은 사람도 있고 노동소득이 매우 높은 사람도 있다. 후자가 전자를 대체하지는 않는다.
  • 미국에서 오늘날 볼 수 있는 차이점은 소득계층의 훨씬 더 위쪽으로 올라가야 소득에서 자본소득이 우위를 차지한다는 것이다.
    • 프랑스도 마찬가지지만 미국은 정도가 훨씬 더 하다.
    • 자본이득을 제외하면 소득계층의 0.01%수준까지도 급여가 주요한 소득원이다.
  • 가장 중요한 요점은 매우 높은 급여의 증가는 주로 ‘슈퍼경영자’ 즉 노동의 대가로 전례가 없을 정도의 극히 높은 보수를 받는 최고위 경영자들의 등장을 반영한다는 것이다.
  • (이하 소득세 자료 설명 생략)