선형 회귀 분석

선형 회귀는 두 개 이상의 변수 간 관계를 모델링하는 기법이다.

예를 들어, 차량을 판매하려고 한다고 가정해 보자. 얼마에 판매해야 할지 잘 모르겠다. 그래서 최근 광고를 살펴보며 다른 차량들의 판매 가격을 확인한다. 차량의 제조사, 모델, 엔진 크기 등 고려할 변수가 많다. 작업을 단순화하기 위해 차량의 연식과 가격만 수집한다.

연식(년) 가격(£)
10 500
8 400
3 7,000
3 8,500
2 11,000
1 10,500

우리 차량은 4년 차다. 이 표에 있는 데이터를 바탕으로 차량 가격을 어떻게 정할 수 있을까?

먼저 데이터를 그래프로 그려보자.

graph1

이 그래프의 점들을 지나는 직선을 상상할 수 있다. 모든 점을 정확히 지나지는 않지만, 가능한 한 모든 점에 가깝게 지나도록 직선을 그릴 수 있다.

다시 말해, 직선과 각 점 사이의 거리를 최소화하고 싶다. 일반적으로 직선과 점 사이 거리의 제곱을 최소화하는 방법을 사용한다.

직선은 두 가지 변수로 표현할 수 있다.

  1. y축과 교차하는 지점, 즉 새 차량의 예상 가격. 이를 절편이라 한다.
  2. 직선의 기울기, 즉 연식이 1년 증가할 때마다 가격이 얼마나 변하는지 나타낸다.

직선의 방정식은 다음과 같다.

carPrice = slope * carAge + intercept

절편과 기울기의 최적값을 어떻게 찾을 수 있을까? 두 가지 방법을 살펴보자.

반복적 접근 방식

한 가지 방법은 절편과 기울기에 임의의 값을 설정하는 것이다. 이 값을 조금씩 변경해 데이터 포인트에 더 가까운 직선을 만든다. 이 과정을 여러 번 반복하면 결국 최적의 위치에 도달한다.

먼저 데이터 구조를 설정한다. 자동차 연식과 가격을 저장하기 위해 두 개의 Swift 배열을 사용한다:

let carAge: [Double] = [10, 8, 3, 3, 2, 1]
let carPrice: [Double] = [500, 400, 7000, 8500, 11000, 10500]

직선을 다음과 같이 표현할 수 있다:

var intercept = 0.0
var slope = 0.0
func predictedCarPrice(_ carAge: Double) -> Double {
    return intercept + slope * carAge
}

반복을 수행하는 코드는 다음과 같다:

let numberOfCarAdvertsWeSaw = carPrice.count
let numberOfIterations = 100
let alpha = 0.0001

for _ in 1...numberOfIterations {
    for i in 0..<numberOfCarAdvertsWeSaw {
        let difference = carPrice[i] - predictedCarPrice(carAge[i])
        intercept += alpha * difference
        slope += alpha * difference * carAge[i]
    }
}

alpha는 각 반복에서 정확한 해법에 얼마나 가까워지는지를 결정하는 요소다. 이 값이 너무 크면 프로그램이 올바른 해법에 수렴하지 못한다.

프로그램은 각 데이터 포인트(자동차 연식과 가격)를 순회한다. 각 데이터 포인트에 대해 절편과 기울기를 조정해 올바른 값에 가깝게 만든다. 코드에서 사용된 방정식은 이 변수들의 최대 감소 방향으로 이동하는 것을 기반으로 한다. 이를 경사 하강법이라고 한다.

직선과 포인트 사이의 거리의 제곱을 최소화하려고 한다. 이 거리를 나타내는 함수 J를 정의한다. 간단히 하기 위해 하나의 포인트만 고려한다. 이 함수 J((slope * carAge + intercept) - carPrice)) ^ 2에 비례한다.

최대 감소 방향으로 이동하기 위해 이 함수의 기울기에 대한 편미분을 취하고, 절편에 대해서도 동일하게 수행한다. 이 미분값에 alpha를 곱한 후 각 반복에서 기울기와 절편을 조정하는 데 사용한다.

코드를 보면 직관적으로 이해할 수 있다. 현재 예측된 자동차 가격과 실제 자동차 가격의 차이가 클수록, 그리고 alpha 값이 클수록 절편과 기울기에 대한 조정이 더 크다.

이상적인 값에 도달하려면 많은 반복이 필요할 수 있다. 반복 횟수를 늘리면서 절편과 기울기가 어떻게 변하는지 살펴보자:

반복 횟수 절편 기울기 4년 된 자동차 예측 가격
0 0 0 0
2000 4112 -113 3659
6000 8564 -764 5507
10000 10517 -1049 6318
14000 11374 -1175 6673
18000 11750 -1230 6829

이 데이터를 그래프로 나타내면 다음과 같다. 그래프의 파란 선은 위 표의 각 행을 나타낸다.

graph2

18,000번 반복 후 직선이 우리가 예상한 최적의 직선에 가까워진 것처럼 보인다. 또한, 추가로 2,000번 반복할 때마다 최종 결과에 미치는 영향이 점점 줄어든다. 절편과 기울기의 값이 올바른 값에 수렴하고 있다.

폐쇄형 해법

반복을 여러 번 하지 않고도 최적의 직선을 계산할 수 있는 다른 방법이 있다. 최소 제곱법을 설명하는 방정식을 풀어 절편과 기울기를 직접 계산할 수 있다.

먼저 몇 가지 헬퍼 함수가 필요하다. 이 함수는 Double 배열의 평균을 계산한다:

func average(_ input: [Double]) -> Double {
    return input.reduce(0, +) / Double(input.count)
}

Swift의 reduce 함수를 사용해 배열의 모든 요소를 합한 후, 요소의 개수로 나눈다. 이렇게 하면 평균값을 얻을 수 있다.

또한, 한 배열의 각 요소를 다른 배열의 해당 요소와 곱해 새로운 배열을 만들어야 한다. 이를 수행하는 함수는 다음과 같다:

func multiply(_ a: [Double], _ b: [Double]) -> [Double] {
    return zip(a,b).map(*)
}

map 함수를 사용해 각 요소를 곱한다.

마지막으로, 데이터에 직선을 맞추는 함수는 다음과 같다:

func linearRegression(_ xs: [Double], _ ys: [Double]) -> (Double) -> Double {
    let sum1 = average(multiply(ys, xs)) - average(xs) * average(ys)
    let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2)
    let slope = sum1 / sum2
    let intercept = average(ys) - slope * average(xs)
    return { x in intercept + slope * x }
}

이 함수는 두 개의 Double 배열을 인자로 받고, 최적의 직선을 나타내는 함수를 반환한다. 기울기와 절편을 계산하는 공식은 함수 J의 정의에서 유도할 수 있다. 이 직선이 데이터에 어떻게 맞는지 살펴보자:

graph3

이 직선을 사용하면 4년 된 자동차의 가격을 £6952로 예측할 수 있다.

요약

Swift에서 간단한 선형 회귀를 구현하는 두 가지 방법을 살펴봤다. 분명한 질문은: 왜 반복적 접근 방식을 사용할까?

직선이 데이터에 완벽하게 맞지 않기 때문이다. 예를 들어, 그래프에는 높은 자동차 연식에서 음수 값이 포함된다! 매우 오래된 자동차를 견인하는 데 돈을 지불해야 할 수도 있지만, 실제로 이 음수 값은 우리가 실제 상황을 정확히 모델링하지 못했음을 보여준다. 자동차 연식과 가격의 관계는 선형이 아니라 다른 함수다. 또한 자동차 가격은 연식뿐만 아니라 제조사, 모델, 엔진 크기와 같은 다른 요소와도 관련이 있다. 이러한 다른 요소를 설명하기 위해 추가 변수가 필요하다.

이러한 더 복잡한 모델에서는 반복적 접근 방식이 유일하거나 효율적인 방법일 수 있다. 데이터 배열이 매우 크고 데이터 값이 희소하게 분포된 경우에도 이 방식이 사용될 수 있다.

Swift Algorithm Club을 위해 James Harrop가 작성함