Введение в Kotlin-Statistics

Свободные операторы Data Science с Kotlin

Последние несколько лет я был заядлым пользователем Kotlin. Но моя склонность к Kotlin обусловлена ​​не просто языковой скукой или рвением к продуктам JetBrains (включая PyCharm, отличную среду разработки Python). Kotlin - это более прагматичная Scala, или Scala для чайников, как я слышал, как кто-то однажды ее описал. Он уникален тем, что старается не быть таким, делая упор на практичность и трудолюбие, а не на академические эксперименты. Он берет многие из наиболее полезных функций современных языков программирования (включая Java, Groovy, Scala, C # и Python) и интегрирует их в единый язык.

Мое использование Kotlin выросло из-за необходимости, и это то, о чем я говорил на KotlinConf в 2017 году:

Вы можете сказать: «Ну, Python прагматичен», и конечно же, я все еще использую Python, особенно когда мне нужны определенные библиотеки. Но может быть сложно управлять 10 000 строк кода Python в быстро развивающемся производственном приложении. Хотя некоторые люди могут делать это успешно и управлять целыми компаниями на Python, некоторые компании, такие как Khan Academy, открывают для себя преимущества Kotlin и его современный подход к статической типизации. Khan Academy написала о своем опыте перехода с экосистемы Python на экосистему Python / Kotlin:



Хан также написал документ для разработчиков Python, желающих изучить Kotlin:



Но я отвлекся. В этой статье я хочу представить библиотеку, над которой я работал некоторое время, под названием Kotlin-Statistics. Он начался как эксперимент по выражению значимого статистического анализа и анализа данных с помощью функционального и объектно-ориентированного программирования, при этом код был разборчивым и интуитивно понятным. Другими словами, я хотел доказать, что можно анализировать ООП / функциональные данные, не прибегая к фреймам данных и другим структурам науки о данных.



Возьмем, к примеру, этот код Kotlin ниже, где я объявляю тип Patient и включаю имя, фамилию, день рождения и количество лейкоцитов. У меня также есть enum под названием Gender, отражающая категорию МУЖЧИНЫ / ЖЕНЩИНЫ. Конечно, я мог бы импортировать эти данные из текстового файла, базы данных или другого источника, но пока я собираюсь объявить их в буквальном коде Kotlin:

import java.time.LocalDate

data class Patient(val firstName: String,
                   val lastName: String,
                   val gender: Gender,
                   val birthday: LocalDate,
                   val whiteBloodCellCount: Int) { 
    
    val age get() = 
         ChronoUnit.YEARS.between(birthday, LocalDate.now())
}
val patients = listOf(
        Patient(
                "John",
                "Simone",
                Gender.MALE,
                LocalDate.of(1989, 1, 7),
                4500
        ),
        Patient(
                "Sarah",
                "Marley",
                Gender.FEMALE,
                LocalDate.of(1970, 2, 5),
                6700
        ),
        Patient(
                "Jessica",
                "Arnold",
                Gender.FEMALE,
                LocalDate.of(1980, 3, 9),
                3400
        ),
        Patient(
                "Sam",
                "Beasley",
                Gender.MALE,
                LocalDate.of(1981, 4, 17),
                8800
        ),
        Patient(
                "Dan",
                "Forney",
                Gender.MALE,
                LocalDate.of(1985, 9, 13),
                5400
        ),
        Patient(
                "Lauren",
                "Michaels",
                Gender.FEMALE,
                LocalDate.of(1975, 8, 21),
                5000
        ),
        Patient(
                "Michael",
                "Erlich",
                Gender.MALE,
                LocalDate.of(1985, 12, 17),
                4100
        ),
        Patient(
                "Jason",
                "Miles",
                Gender.MALE,
                LocalDate.of(1991, 11, 1),
                3900
        ),
        Patient(
                "Rebekah",
                "Earley",
                Gender.FEMALE,
                LocalDate.of(1985, 2, 18),
                4600
        ),
        Patient(
                "James",
                "Larson",
                Gender.MALE,
                LocalDate.of(1974, 4, 10),
                5100
        ),
        Patient(
                "Dan",
                "Ulrech",
                Gender.MALE,
                LocalDate.of(1991, 7, 11),
                6000
        ),
        Patient(
                "Heather",
                "Eisner",
                Gender.FEMALE,
                LocalDate.of(1994, 3, 6),
                6000
        ),
        Patient(
                "Jasper",
                "Martin",
                Gender.MALE,
                LocalDate.of(1971, 7, 1),
                6000
        )
)

enum class Gender {
    MALE,
    FEMALE
}

Начнем с базового анализа: каково среднее и стандартное отклонение whiteBloodCellCount для всех пациентов? Мы можем использовать некоторые функции расширения в Kotlin Statistics, чтобы быстро это найти:

fun main() {

    val averageWbcc =
            patients.map { it.whiteBloodCellCount }.average()

    val standardDevWbcc = patients.map { it.whiteBloodCellCount }
                .standardDeviation()

    println("Average WBCC: $averageWbcc, 
               Std Dev WBCC: $standardDevWbcc")
    
    // PRINTS: 
    // Average WBCC: 5346.153846153846, 
         Std Dev WBCC: 1412.2177503341948
}

Мы также можем создать объект DescriptiveStatistics из коллекции элементов:

fun main() {

    val descriptives = patients
            .map { it.whiteBloodCellCount }
            .descriptiveStatistics

    println("Average: ${descriptives.mean} 
         STD DEV: ${descriptives.standardDeviation}")
    
    /* PRINTS
      Average: 5346.153846153846   STD DEV: 1412.2177503341948
     */
}

Однако иногда нам нужно разрезать наши данные не только для более детального анализа, но и для оценки нашей выборки. Например, получили ли мы репрезентативную выборку наших пациентов как для мужчин, так и для женщин? Мы можем использовать оператор countBy() в статистике Kotlin для подсчета Collection или Sequence элементов по keySelector, как показано здесь:

fun main() {

    val genderCounts = patients.countBy { it.gender }

    println(genderCounts)
    
    // PRINTS
    // {MALE=8, FEMALE=5}
}

Это возвращает Map<Gender,Int>, отражающее количество пациентов по полу, которое отображается при печати {MALE=8, FEMALE=5} .

Итак, наш образец немного МУЖСКОЙ, но давайте продолжим. Мы также можем найти среднее количество лейкоцитов по gender, используя averageBy(). Он принимает не только keySelector лямбда, но также intSelector для выбора целого числа для каждого Patient (мы также можем использовать doubleSelector, bigDecimalSelector и т. Д.). В этом случае мы выбираем whiteBloodCellCount для каждого Patient и усредняем его по Gender, как показано ниже. Это можно сделать двумя способами:

ПОДХОД 1.

fun main() {

    val averageWbccByGender = patients
            .groupBy { it.gender }
            .averageByInt { it.whiteBloodCellCount }

    println(averageWbccByGender)
    
    // PRINTS
    // {MALE=5475.0, FEMALE=5140.0}
}

ПОДХОД 2:

fun main() {

    val averageWbccByGender = patients.averageBy(
            keySelector = { it.gender },
            intSelector = { it.whiteBloodCellCount }
    )
    
    println(averageWbccByGender)

    // PRINTS
    // {MALE=5475.0, FEMALE=5140.0}
}

Таким образом, средний WBCC для МУЖЧИНЫ составляет 5475, а ЖЕНСКИЙ - 5140.

А как насчет возраста? Получили ли мы хорошую выборку пациентов младшего и старшего возраста? Если вы посмотрите на наш Patient класс, у нас есть только birthday, с которым мы будем работать, это Java 8 LocalDate. Но используя утилиты даты и времени Java 8, мы можем получить возраст в годах в keySelector следующим образом:

fun main() {

    val patientCountByAge = patients.countBy(
        keySelector = { it.age }
    )

    patientCountByAge.forEach { age, count ->
        println("AGE: $age COUNT: $count")
    }
    
    /* PRINTS: 
    AGE: 30 COUNT: 1
    AGE: 48 COUNT: 1
    AGE: 38 COUNT: 1
    AGE: 37 COUNT: 1
    AGE: 33 COUNT: 3
    AGE: 43 COUNT: 1
    AGE: 27 COUNT: 2
    AGE: 44 COUNT: 1
    AGE: 24 COUNT: 1
    AGE: 47 COUNT: 1
    */
}

Если вы посмотрите на наш вывод кода, подсчитывать по возрасту не имеет большого смысла. Было бы лучше, если бы мы могли считать по возрастным группам, например, 20–29, 30–39 и 40–49. Мы можем сделать это с помощью операторов binByXXX(). Если мы хотим разбить по значению Int, например по возрасту, мы можем определить BinModel, которое начинается с 20 и увеличивает каждое binSize на 10. Мы также предоставляем значение, которое мы объединяем, используя valueSelector, который является возрастом пациента, как показано ниже:

fun main() {

    val binnedPatients = patients.binByInt(
            valueSelector = { it.age },
            binSize = 10,
            rangeStart = 20
    )

    binnedPatients.forEach { bin ->
        println(bin.range)
        bin.value.forEach { patient ->
            println("    $patient")
        }
    }
}
/* PRINTS:
[20..29]
    Patient(firstName=Jason, lastName=Miles, gender=MALE... 
    Patient(firstName=Dan, lastName=Ulrech, gender=MALE...
    Patient(firstName=Heather, lastName=Eisner, gender=FEMALE...
[30..39]
    Patient(firstName=John, lastName=Simone, gender=MALE...
    Patient(firstName=Jessica, lastName=Arnold, gender=FEMALE...
    Patient(firstName=Sam, lastName=Beasley, gender=MALE...
    Patient(firstName=Dan, lastName=Forney, gender=MALE...
    Patient(firstName=Michael, lastName=Erlich, gender=MALE...
    Patient(firstName=Rebekah, lastName=Earley, gender=FEMALE...
[40..49]
    Patient(firstName=Sarah, lastName=Marley, gender=FEMALE...
    Patient(firstName=Lauren, lastName=Michaels, gender=FEMALE...
    Patient(firstName=James, lastName=Larson, gender=MALE...
    Patient(firstName=Jasper, lastName=Martin, gender=MALE...
*/

Мы можем найти корзину для данного возраста, используя синтаксис геттера. Например, мы можем получить Bin для возраста 25 следующим образом, и он вернет корзину 20-29:

fun main() {

    val binnedPatients = patients.binByInt(
            valueSelector = { it.age },
            binSize = 10,
            rangeStart = 20
    )

    println(binnedPatients[25])
}

Если мы хотим не собирать элементы в бункеры, а выполнять агрегирование для каждого из них, мы можем сделать это, также предоставив аргумент groupOp. Это позволяет вам использовать лямбда, определяющую, как уменьшить каждое List<Patient> для каждого Bin. Ниже приведено среднее количество лейкоцитов по возрастному диапазону:

val avgWbccByAgeRange = patients.binByInt(
        valueSelector = { it.age },
        binSize = 10,
        rangeStart = 20,
        groupOp = { it.map { it.whiteBloodCellCount }.average() }
)

println(avgWbccByAgeRange)
/* PRINTS:
BinModel(bins=[Bin(range=[20..29], value=5300.0), 
    Bin(range=[30..39], value=5133.333333333333), 
    Bin(range=[40..49], value=5700.0)]
)
*/

Иногда может потребоваться выполнить несколько агрегатов для создания отчетов с различными показателями. Обычно это достигается с помощью оператора let () Котлина. Допустим, вы хотите найти 1-й, 25-й, 50-й, 75-й и 100-й процентили по полу. Мы можем тактически использовать функцию расширения Kotlin под названием wbccPercentileByGender(), которая возьмет набор пациентов и разделит расчет процентилей по полу. Затем мы можем вызвать его для пяти желаемых процентилей и упаковать их в Map<Double,Map<Gender,Double>>, как показано ниже:

fun main() {

  fun Collection<Patient>.wbccPercentileByGender(
        percentile: Double) =
            percentileBy(
                percentile = percentile,
                keySelector = { it.gender },
                valueSelector = { 
                    it.whiteBloodCellCount.toDouble() 
                }
            )

    val percentileQuadrantsByGender = patients.let {
        mapOf(1.0 to it.wbccPercentileByGender(1.0),
                25.0 to it.wbccPercentileByGender(25.0),
                50.0 to it.wbccPercentileByGender(50.0),
                75.0 to it.wbccPercentileByGender(75.0),
                100.0 to it.wbccPercentileByGender(100.0)
        )
    }

    percentileQuadrantsByGender.forEach(::println)
}
/* PRINTS:
1.0={MALE=3900.0, FEMALE=3400.0}
25.0={MALE=4200.0, FEMALE=4000.0}
50.0={MALE=5250.0, FEMALE=5000.0}
75.0={MALE=6000.0, FEMALE=6350.0}
100.0={MALE=8800.0, FEMALE=6700.0}
*/

Это было довольно простое введение в Kotlin-Statistics. Обязательно прочитайте проект README, чтобы увидеть более полный набор операторов, доступных в библиотеке (в ней также есть некоторые разрозненные инструменты, такие как Наивный байесовский классификатор и стохастические операторы).

Я надеюсь, что это демонстрирует эффективность Kotlin в том, что он тактический, но надежный. Kotlin способен быстро обрабатывать данные для быстрого специального анализа, но вы можете взять этот статически типизированный код и развить его с помощью множества проверок во время компиляции. Хотя вы можете подумать, что у Kotlin нет экосистемы, которая есть у Python или R, на самом деле у него уже есть много библиотек и возможностей на JVM. По мере того, как Kotlin / Native набирает обороты, будет интересно посмотреть, какие числовые библиотеки могут возникнуть из экосистемы Kotlin.

Чтобы получить некоторые ресурсы по использованию Kotlin в целях науки о данных, я составил список здесь:



Вот еще несколько статей, которые я написал, демонстрируя Kotlin для математического моделирования: