Логистическая регрессия

42 minute read

Перевод статьи "Basic Logistic Regression with Go".

Последние несколько лет довольно много внимания уделяется машинному обучению в самых различных его проявлениях. Так уж случилось, я потратил некоторое время на изучение ML(Machine Learning) во время подготовки к диплому и после окончания вуза.

Как оказалось, дорога от простой заинтересованности и очарованности к реальному коду очень непростая. Сколько знаний необходимо накопить, чтобы перейти от теории к практике? Необходимо ли знать и понимать все эти формулы и модели? Как нужно обрабатывать входные данные? На все эти вопросы нет 100% четких ответов, все зависит от того, сколько времени у вас есть, сколько энергии вы готовы потратить на изучение материала и какие у вас конечные цели.

В этой статья я постараюсь опустить ответы на эти вопросы и просто покажу пример кода на Go, который решает конкретный задачи с помощью машинного обучения. Я не буду рассказывать все закулисные нюансы используемых алгоритмов. Если вам интересно изучить принципы машинного обучения более подробно(а я вам это рекомендую), то вы можете найти много всего на Coursera.

И так, давайте начнем. Мы будем использовать логистическую регрессию для создания модели, которая поможет нам решить проблему классификации.

Проблема классификации, простым языком, заключается в определении на основании уже имеющихся наблюдений(данных) к какой категории подходит(лучше всего) следующее наблюдение. Например, можно представить, что мы рассматриваем родинки или родимые пятна в попытке классифицировать их как злокачественные или доброкачественные. Мы можем использовать изображения родинок и родимых пятен, которые мы видели в прошлом и про которые знаем, какие из них доброкачественные, а какие злокачественные для обучения нашей модели. После обучения наша модель сможет предсказывать класс родинок и родимых пятен.

Логистическая регрессия - это алгоритм который позволит нам создать эту самую модель. На википедии есть описание самого алгоритма, кроме того, я уверен что существует еще целая куча материала по этому алгоритму и его применению. А нам пока достаточно того, что этот алгоритм как нельзя лучше подходит для решения задач классификации. Чуть позже мы разберемся с некоторыми аспектами этого алгоритма, так как нам необходимо задать корректные параметры для его работы. Но пока нам достаточно знать что это инструмент для решения нашей задачи и он уже реализован в используемой нами библиотеке.

Наша основная задаче не в том, чтобы полностью разобраться как работает алгоритм логистической регрессии. Мы рассмотрим все основные шаги для решения определенной проблемы с помощью машинного обучения. И, к сожалению, мы не будем работать с по настоящему большими данными. У нас будет всего около сотни записей, чего конечно не достаточно для очень точного предсказания результатов в нашем небольшом примере. Тем не менее, при работе с реальными данными и реальными проблемами все методологии и шаги будут точно такими же.

Мы будем использовать Go, хоть это не самый распространенный в этой области язык. Тут передовые позицию удерживает R, Matlab, python и еще парочка неплохих языков. Часто Go используют в связке с этими языками для пред и пост обработки данных.

Тем не менее, есть несколько библиотек на Go для машинного обучения, хотя они не такие зрелые и совершенные как, например, scikit-learn, но их вполне достаточно для нашего примера. Я использовал goml и пробовал работать с golearn, который выглядит очень многообещающе.

В goml хорошо структурированное API и нормальная документация.

Думаю, теперь мы можем начать.Все исходники и данные к этой статье есть на github.

Данные

Прежде всего нам нужны данные. В нашем эксперименте мы будем использовать простой набор данных в csv формате:

exam1Score;exam2Score;accepted
45.3;38.2;1
99.1;88.1;0
...

В этих данных представлены результаты тестирования студентов за два экзамена и указано поступили ли они в институт по результатам экзаменов. Это очень простой пример без каких либо заморочек. На самом деле, в реальных проектах обработка данных и их анализ занимают большую часть работы и являются одной их самых важных частей.

Если мы будем тестировать нашу модель на тех же результатах, которые использовались для обучения, то результаты будут отличными, но совершенно не отражающими реальную действительность. По этой причине мы разделим все наши данные, часть будем использовать для тренировки, приблизительно 70%, а остальные 30% для проверки нашей модели.

При работы с реальными и большими наборами данных, мы могли бы рандомизировано сделать несколько выборок для тренировки, но это не очень целесообразно когда у вас набор данных всего из 100 строчек. Мы просто руками разделим все наши данные на два набора и сохраним их в разные файлы:

xTrain, yTrain, err := base.LoadDataFromCSV("./data/studentsTrain.csv")
if err != nil {
    return err
}
xTest, yTest, err := base.LoadDataFromCSV("./data/studentsTest.csv")
if err != nil {
    return err
}

Как правило, данные нужно обрабатывать перед использованием, удалять невалидные значения, нормализовать данные, но в нашем примере данные уже нормализированны, почищены и готовы к использованию.

Для визуализации данных построим график распределения. Мы будем использовать библиотеку gonum для работы с графиками.

func plotData(xTest [][]float64, yTest []float64) error {
    p, err := plot.New()
    if err != nil {
        return err
    }
    p.Title.Text = "Exam Results"
    p.X.Label.Text = "X"
    p.Y.Label.Text = "Y"
    p.X.Max = 120
    p.Y.Max = 120

    positives := make(plotter.XYs, len(yTest))
    negatives := make(plotter.XYs, len(yTest))
    for i := range xTest {
        if yTest[i] == 1.0 {
            positives[i].X = xTest[i][0]
                positives[i].Y = xTest[i][1]
        }
        if yTest[i] == 0.0 {
            negatives[i].X = xTest[i][0]
                negatives[i].Y = xTest[i][1]
        }
    }

    err = plotutil.AddScatters(p, "Negatives", negatives, "Positives", positives)
    if err != nil {
        return err
    }
    if err := p.Save(10*vg.Inch, 10*vg.Inch, "exams.png"); err != nil {
        return err
    }
    return nil
}

По оси X у нас значения первого экзамена, а по оси Y значения второго. При этом точки различаются цветами в зависимости от того поступил ученик или нет(Negatives и Positives). Теперь можно посмотреть как распределены результаты экзаменов.

У вас получится такая картинка:

Модель

Обучение модели, в нашем случае, это проходит очень просто. Так как мы используем пакет goml, то нам достаточно вызвать метод linear.NewLogistic, который принимает набор определенных параметров:

  • Алгоритм оптимизации(используем base.BatchGA).
    • В gml есть два алгоритма оптимизации для логистической регрессии. Это стохастическое градиентное восхождение и дозированный градиентный спуск. Оба алгоритма, в принципе, основаны на алгоритме инвертированного градиентного спуска. Все это нужно для максимальной подгонки модели под наши обучающие данные и уменьшение ошибки(дистанции)
  • Шаг обучения и максимальное количество итераций(0.00001 и 1000)
    • Эти два параметра нужны для настройки градиентного спуска. Они определяют каким может быть минимальный шаг алгоритма и максимальное количество шагов необходимое для схождения алгоритма. Если мы укажем шаг обучения слишком большим, то мы никогда не окажемся рядом с минимумом, поэтому будем использовать маленькие шаги(хотя это влияет на производительность, так как нам понадобится больше шагов).
  • Регуляризация
    • Этот параметр используется для предотвращения переобучения модели(обучение слишком близко к тестовым данным), но в нашем примере мы можем просто проигнорировать этот параметр.
  • Входные данные (xTrain - оценки по экзаменам)
  • Значение класса (yTrain - результаты поступления, значения 0 или 1)

В будущем можно будет поиграться с этими параметрами чтобы добиться наилучших результатов, а пока обучим нашу модель без лишних заморочек:

model := linear.NewLogistic(base.BatchGA, 0.00001, 0, 1000, xTrain, yTrain)
err := model.Learn()
if err != nil {
    return nil, nil, err
}

Собственно, это все что нужно для обучения модели. Довольно просто. Теперь мы можем использовать эту модель для классификации всех новых данных. Для этого нужно использовать метод model.Predict(input []float). Чтобы было понятно как это работает, представьте что мы обучили нашу модель определять где на картинке кошка, а где нет. С помощью метода Predict мы можем проверить новую картинку и узнать есть ли на ней кошка(с некоторой вероятностью).

Кроме того, метод Predict можно использовать для оценки точности нашей модели на тестовом наборе. Для этого создадим матрицу неточностей(Confusion Matrix). Для этого нам понадобится структура такого вида:

// ConfusionMatrix describes a confusion matrix
type ConfusionMatrix struct {
    positive      int
    negative      int
    truePositive  int
    trueNegative  int
    falsePositive int
    falseNegative int
    accuracy      float64
}

Матрица неточностей - это по сути матрица ошибок. Она может использоваться для оценки нашей модели, насколько точно алгоритм справляется с классификацией данных. Для этого нам нужно сохранить некоторые значения в процессе выполнения алгоритма.

  • positive - количество позитивных результатов(удачное поступление).
  • negative - количество негативных результатов(отказано в поступлении).
  • truePositive - количество правильно предсказанных позитивных результатов.
  • trueNegative - количество правильно предсказанных негативных результатов.
  • falsePositive - количество неправильно предсказанных позитивных результатов.
  • falseNegative - количество неправильно предсказанных негативных результатов.
  • accuracy - рассчитанная точность для нашей модели, определяется как (truePositive + trueNegative) / (positive + negative)

Существует целый ряд парамеров и способов, которые позволяют оценить результативность модели, например F1 тест, но в нашем случае, будет достаточно оценить только точность.

Для начала начнем с подсчета позитивных и негативных результатов в нашем тестовом наборе:

cm := ConfusionMatrix{}
for _, y := range yTest {
    if y == 1.0 {
        cm.positive++
    }
    if y == 0.0 {
        cm.negative++
    }
}

Теперь мы пройдемся по всем тестовым данным и запишем все случаи, когда у нас обнаруживается несоответствие между предсказанным значением и реальным результатом.

Небольшое замечание по переменной decisionBoundary. Метод Predict() возвращает значение вероятности в диапазоне от 0 до 1. А это значит, что нам нужно определится какие значения считать позитивным предсказанием, а какие негативным. Для начала будем использовать значения, в которых вероятность выше 50% для определения позитивных результатов, а потом посмотрим как это можно улучшить.

// Evaluate the Model on the Test data
for i := range xTest {
    prediction, err := model.Predict(xTest[i])
    if err != nil {
        return nil, nil, err
    }
    y := int(yTest[i])
    positive := prediction[0] >= decisionBoundary

   if y == 1 && positive {
       cm.truePositive++
   }
   if y == 1 && !positive {
       cm.falseNegative++
   }
   if y == 0 && positive {
       cm.falsePositive++
   }
   if y == 0 && !positive {
       cm.trueNegative++
   }
}

// Calculate Evaluation Metrics
cm.accuracy = (float64(cm.truePositive) + float64(cm.trueNegative)) /
    (float64(cm.positive) + float64(cm.negative))

Теперь проведем оценку нашей модели на тестовых данных. Нам придется немного повозится с подгонкой параметров модели, чтобы получить необходимую точность. На практике поиск наилучших параметров можно автоматизировать и довольно легко распараллелить.

Базовая реализация подбора удовлетворяющих параметров(без параллелизма и дополнительных усложнений) будет выглядеть так:

var maxAccuracy float64
var maxAccuracyCM *ConfusionMatrix
var maxAccuracyDb float64
var maxAccuracyModel *linear.Logistic

//Try different parameters to get the best model
for db := 0.05; db < 1.0; db += 0.01 {
    // Learn Model and Calculate ConfusionMatrix for given values
    cm, model, err := tryValues(0.0001, 0.0, 1000, db, xTrain, xTest, yTrain, yTest)
    if err != nil {
        return err
    }
    if cm.accuracy > maxAccuracy {
        maxAccuracy = cm.accuracy
        maxAccuracyCM = cm
        maxAccuracyDb = db
        maxAccuracyModel = model
    }
}

fmt.Printf("Maximum accuracy: %.2f\n\n", maxAccuracy)
fmt.Printf("with Model: %s\n\n", maxAccuracyModel)
fmt.Printf("with Confusion Matrix:\n%s\n\n", maxAccuracyCM)
fmt.Printf("with Decision Boundary: %.2f\n", maxAccuracyDb)
fmt.Printf("with Num Iterations: %d\n", maxAccuracyIter)

То же самое можно проделать со всеми параметрами модели, пока мы не начнем получать удовлетворяющие результаты. Конечно, брутфорс это не единственный способ улучшения модели. В мире машинного обучения существует множество различных подходов.

Тем не менее, у нас получилось добиться необходимых результатов. Пример вывода нашей программы:

Running Logistic Regression...
Maximum accuracy.91

with Model: h(θ,x) = 1 / (1 + exp(-θx))
θx = -1.286 + 0.04981(x[1]) + 0.01461(x[2])

with Confusion Matrix:
Positives: 24
Negatives: 11
True Positives: 23
True Negatives: 9
False Positives: 2
False Negatives: 1

Recall.96
Precision.92
Accuracy.91


with Decision Boundary.91
with Num Iterations: 2600

Заключение

В этой статье мы затронули только самые поверхностные моменты машинного обучения и языка Go. Но мы поигрались с параметрами модели и посмотрели как меняются результаты в зависимости от разных настроек. Вы можете попробовать использовать этот код как шаблон для других наборов данных.

Сейчас машинное обучение набирает обороты. Со временем потребность в нем будет расти. Так что изучение фундаментальных основ машинного обучения и погружение в эту тему никогда не помешает. Кроме того, это очень интересная тема, которая не оставит вас равнодушным.

Как было сказано выше, Go не самый удачный выбор для машинного обучения(пока?). Будет ли Go использоваться для машинного обучения более плотно покажет время. Но я уверен, что это вполне возможно.

Почитать:

comments powered by Disqus