Нейронные сети сегодня находятся на переднем крае машинного обучения (ML), и Python, несомненно, является языком программирования, подходящим для любой задачи ML, независимо от того, собирается ли кто-то использовать нейронные сети для ее решения или нет. Существует обширный набор библиотек Python, которые охватывают весь спектр задач машинного обучения, например NumPy, Pandas, Keras, TensorFlow, PyTorch и т. д. Эти библиотеки обычно полагаются на реализации алгоритмов и подходов машинного обучения на C или C++, поскольку Python для них слишком медленный. Однако Python — не единственный существующий язык программирования, и я не использую его в повседневной работе.
Эта статья не является руководством по написанию чего-либо на Swift; скорее, это скорее размышление о нынешнем мышлении многих разработчиков, которые рассматривают Python как мост к окончательному решению для библиотек ML, которое решит любую проблему или задачу, с которой они сталкиваются, независимо от используемого ими языка. Я готов поспорить, что большинство разработчиков предпочитают тратить свое время на поиск способов интеграции библиотек Python в свой язык/среду, а не рассматривать альтернативные решения без них. Хотя это само по себе не так уж и плохо — повторное использование было важным фактором прогресса в ИТ за последние несколько десятилетий — я начал чувствовать, что многие разработчики больше даже не рассматривают альтернативные решения. Этот образ мышления становится еще более укоренившимся в связи с текущим состоянием и достижениями в области моделей больших языков.
Баланс отсутствует; мы спешим обратиться к LLM с просьбой решить наши проблемы, получить некоторый код Python, скопировать его и наслаждаться продуктивностью с потенциально значительными накладными расходами из-за ненужных зависимостей.
Давайте рассмотрим альтернативный подход к решению поставленной задачи, используя только Swift, математику и никакие другие инструменты.
Когда люди начинают изучать нейронные сети, есть два классических примера Hello World, которые вы можете найти в большинстве руководств и вводных материалов по ним. Первый из них — распознавание рукописных цифр. Во-вторых, это классификация данных. В этой статье я сосредоточусь на втором, но решение, которое я рассмотрю, подойдет и для первого.
Очень хороший наглядный пример можно найти в TensorFlow Playground, где вы можете поиграть с различными структурами нейронной сети и визуально наблюдать, насколько хорошо полученная модель решает задачу.
Вы можете спросить, в чем практическое значение этих точек на изображении разных цветов? Дело в том, что это визуальное представление некоторых наборов данных. Вы можете представить множество различных типов данных совершенно одинаково или похожим образом, например, социальные группы людей, которые покупают определенные продукты или музыкальные предпочтения. Поскольку я в первую очередь занимаюсь мобильной iOS-разработкой, я также приведу пример решаемой мной реальной задачи, которую визуально можно представить аналогичным образом: поиск электрических проводов внутри стен с помощью гироскопа и магнитометра на мобильном телефоне. В этом конкретном примере у нас есть набор параметров, связанных с найденным проводом, и другой набор параметров, ни для чего не находящихся внутри стены.
Давайте посмотрим на данные, которые мы будем использовать.
Здесь у нас есть два типа данных: красные точки и синие точки. Как я описал выше, это может быть визуальное представление любых секретных данных. Например, возьмем красную область как ту, где у нас есть сигнал от магнитометра и гироскопа в случаях, когда у нас есть электрический провод в стене, и синюю область, если его нет.
Мы видим, что эти точки каким-то образом сгруппированы вместе и образуют своего рода красные и синие фигуры. Эти точки были созданы путем взятия случайных точек из следующего изображения:
Мы будем использовать это изображение в качестве случайной модели для нашего процесса обучения, взяв случайные точки для обучения модели и другие случайные точки для тестирования нашей обученной модели.
Исходное изображение имеет размер 300 х 300 пикселей и содержит 90 000 точек (точек). В целях обучения мы будем использовать только 0,2% этих точек, что составляет менее 100 баллов. Чтобы лучше понять работу модели, мы случайным образом выберем 3000 точек и нарисуем вокруг них кружки на изображении. Такое визуальное представление даст нам более полное представление о результатах. Мы также можем измерить процент точности, чтобы проверить эффективность модели.
Как мы будем делать модель? Если мы посмотрим на эти два изображения вместе и попытаемся упростить нашу задачу, то обнаружим, что задача, по сути, состоит в том, чтобы воссоздать исходную картинку по имеющимся у нас данным (пакет красных и синих точек). И чем ближе картина, которую мы получим от нашей модели к исходной, тем точнее будет работать наша модель. Мы также можем рассматривать наши тестовые данные как своего рода чрезвычайно сжатую версию нашего исходного изображения и поставить перед собой задачу распаковать их обратно.
Что мы собираемся сделать, так это преобразовать наши точки в математические функции, которые будут представлены в коде в виде массивов или векторов (здесь в тексте я буду использовать термин вектор только потому, что он находится между функцией из математического мира и массивом из разработки программного обеспечения). Затем мы будем использовать эти векторы, чтобы проанализировать каждую тестовую точку и определить, какому вектору она больше принадлежит.
Чтобы преобразовать наши данные, я попробую дискретное косинусное преобразование (DCT). Я не буду вдаваться в математические объяснения того, что это такое и как работает, поскольку при желании вы легко сможете найти эту информацию. Однако я могу объяснить простыми словами, как это может нам помочь и почему это полезно. DCT используется во многих областях, включая сжатие изображений (например, формат JPEG). Он преобразует данные в более компактный формат, сохраняя только важные части изображения и удаляя неважные детали. Если мы применим DCT к нашему изображению размером 300x300, содержащему только красные точки, мы получим матрицу значений 300x300, которую можно преобразовать в массив (или вектор), взяв каждую строку отдельно.
Давайте, наконец, напишем для него какой-нибудь код. Во-первых, нам нужно создать простой объект, который будет представлять нашу точку (точку).
enum Category { case red case blue case none } struct Point: Hashable { let x: Int let y: Int let category: Category }
Вы можете заметить, что у нас есть дополнительная категория под названием none
. В конечном итоге мы создадим три вектора: один для red
точек, второй для blue
точек и третий для всего остального, что представлено none
. Хотя мы могли бы иметь только два из них, наличие обученного вектора не для красного и не для синего немного упростит задачу.
У нас есть «Point», соответствующий протоколу Hashable
, позволяющий использовать Set
, чтобы избежать наличия точек с одинаковыми координатами в нашем тестовом векторе.
func randomPoints(from points: [Point], percentage: Double) -> [Point] { let count = Int(Double(points.count) * percentage) var result = Set<Point>() while result.count < count { let index = Int.random(in: 0 ..< points.count) result.insert(points[index]) } return Array<Point>(result) }
Теперь мы можем использовать его, чтобы взять 0.2%
случайных точек из нашего исходного изображения для красных, синих точек и отсутствия точек.
redTrainPoints = randomPoints(from: redPoints, percentage: 0.002) blueTrainPoints = randomPoints(from: bluePoints, percentage: 0.002) noneTrainPoints = randomPoints(from: nonePoints, percentage: 0.002)
Мы готовы преобразовать эти обучающие данные с помощью DCT. Вот реализация этого:
final class CosTransform { private var sqrtWidthFactorForZero: Double = 0 private var sqrtWidthFactorForNotZero: Double = 0 private var sqrtHeightFactorForZero: Double = 0 private var sqrtHeightFactorForNotZero: Double = 0 private let cosLimit: Int init(cosLimit: Int) { self.cosLimit = cosLimit } func discreteCosTransform(for points: [Point], width: Int, height: Int) -> [[Double]] { if sqrtWidthFactorForZero == 0 { prepareSupportData(width: width, height: height) } var result = Array(repeating: Array(repeating: Double(0), count: width), count: height) for y in 0..<height { for x in 0..<width { let cos = cosSum( points: points, width: width, height: height, x: x, y: y ) result[y][x] = cFactorHeight(index: y) * cFactorWidth(index: x) * cos } } return result } func shortArray(matrix: [[Double]]) -> [Double] { let height = matrix.count guard let width = matrix.first?.count else { return [] } var array: [Double] = [] for y in 0..<height { for x in 0..<width { if y + x <= cosLimit { array.append(matrix[y][x]) } } } return array } private func prepareSupportData(width: Int, height: Int) { sqrtWidthFactorForZero = Double(sqrt(1 / CGFloat(width))) sqrtWidthFactorForNotZero = Double(sqrt(2 / CGFloat(width))) sqrtHeightFactorForZero = Double(sqrt(1 / CGFloat(height))) sqrtHeightFactorForNotZero = Double(sqrt(2 / CGFloat(height))) } private func cFactorWidth(index: Int) -> Double { return index == 0 ? sqrtWidthFactorForZero : sqrtWidthFactorForNotZero } private func cFactorHeight(index: Int) -> Double { return index == 0 ? sqrtHeightFactorForZero : sqrtHeightFactorForNotZero } private func cosSum( points: [Point], width: Int, height: Int, x: Int, y: Int ) -> Double { var result: Double = 0 for point in points { result += cosItem(point.x, x, height) * cosItem(point.y, y, width) } return result } private func cosItem( _ firstParam: Int, _ secondParam: Int, _ lenght: Int ) -> Double { return cos((Double(2 * firstParam + 1) * Double(secondParam) * Double.pi) / Double(2 * lenght)) } }
Давайте создадим экземпляр объекта CosTransform
и протестируем его.
let math = CosTransform(cosLimit: Int.max) ... redCosArray = cosFunction(points: redTrainPoints) blueCosArray = cosFunction(points: blueTrainPoints) noneCosArray = cosFunction(points: noneTrainPoints)
Здесь мы используем несколько простых вспомогательных функций:
func cosFunction(points: [Point]) -> [Double] { return math.shortArray( matrix: math.discreteCosTransform( for: points, width: 300, height: 300 ) ) }
В CosTransform
есть параметр cosLimit
, который используется внутри функции shortArray, я объясню его назначение позже, а сейчас давайте проигнорируем его и проверим результат 3000 случайных точек из исходного изображения с нашими созданными векторами redCosArray
, blueCosArray
и noneCosArray
. Чтобы это заработало, нам нужно создать еще один вектор DCT из одной точки, взятой из исходного изображения. Это мы делаем точно так же и используя те же функции, которые мы уже использовали для наших векторов Red
, Blue
и None
. Но как узнать, какому из них принадлежит этот новый вектор? Для этого существует очень простой математический подход: Dot Product
. Поскольку перед нами стоит задача сравнить два Вектора и найти наиболее похожую пару, скалярное произведение даст нам именно это. Если вы примените операцию скалярного произведения к двум одинаковым векторам, она даст вам некоторое положительное значение, которое будет больше, чем любой другой результат скалярного произведения, применимый к тому же вектору и любому другому вектору, имеющему разные значения. И если вы примените скалярное произведение к ортогональным векторам (векторам, которые не имеют ничего общего между собой), в результате вы получите 0. Учитывая это, можно придумать простой алгоритм:
redCosArray
, затем с помощью blueCosArray
, а затем с помощью noneCosArray
.Red
, Blue
, None
.
Единственная недостающая функциональность здесь — это скалярное произведение, давайте напишем для него простую функцию:
func dotProduct(_ first: [Double], _ second: [Double]) -> Double { guard first.count == second.count else { return 0 } var result: Double = 0 for i in 0..<first.count { result += first[i] * second[i] } return result }
А вот реализация алгоритма:
var count = 0 while count < 3000 { let index = Int.random(in: 0 ..< allPoints.count) let point = allPoints[index] count += 1 let testArray = math.shortArray( matrix: math.discreteCosTransform( for: [point], width: 300, height: 300 ) ) let redResult = dotProduct(redCosArray, testArray) let blueResult = dotProduct(blueCosArray, testArray) let noneResult = dotProduct(noneCosArray, testArray) var maxValue = redResult var result: Category = .red if blueResult > maxValue { maxValue = blueResult result = .blue } if noneResult > maxValue { maxValue = noneResult result = .none } fillPoints.append(Point(x: point.x, y: point.y, category: result)) }
Все, что нам нужно сделать сейчас, это нарисовать изображение из fillPoints
. Давайте посмотрим на точки поезда, которые мы использовали, векторы DCT, которые мы создали на основе данных о поездах, и конечный результат, который мы получили:
Ну, похоже на случайный шум. Но давайте посмотрим на визуальное представление векторов. Вы можете увидеть там несколько всплесков, это именно та информация, на которой нам нужно сосредоточиться и удалить большую часть шума из нашего результата DCT. Если мы посмотрим на простое визуальное представление матрицы DCT, то обнаружим, что самая полезная информация (та, которая описывает уникальные особенности изображения) сосредоточена в верхнем левом углу:
Теперь давайте сделаем шаг назад и еще раз проверим функцию shortArray
. Мы используем здесь параметр cosLimit
именно по той причине, что мы берем верхний левый угол матрицы DCT и используем только наиболее активные параметры, которые делают наш вектор уникальным.
func shortArray(matrix: [[Double]]) -> [Double] { let height = matrix.count guard let width = matrix.first?.count else { return [] } var array: [Double] = [] for y in 0..<height { for x in 0..<width { if y + x <= cosLimit { array.append(matrix[y][x]) } } } return array }
Давайте создадим наш math
объект с другим cosLimit
:
let math = CosTransform(cosLimit: 30)
Теперь вместо использования всех 90 000 значений мы будем использовать только 30 x 30 / 2 = 450
из них из верхнего левого угла матрицы DCT. Давайте посмотрим на результат, который мы получили:
Как видите, уже лучше. Мы также можем заметить, что большинство шипов, которые делают векторы уникальными, по-прежнему расположены в передней части (как выделено зеленым на рисунке). Давайте попробуем использовать CosTransform(cosLimit: 6)
что означает, что мы будем использовать только 6 x 6 / 2 = 18
значений из 90 000 и проверяем результат:
Теперь это намного лучше, очень близко к исходному изображению. Однако есть только одна маленькая проблема — эта реализация медленная. Вам не нужно быть экспертом по сложности алгоритмов, чтобы понять, что DCT — это трудоемкая операция, но даже скалярное произведение, имеющее линейную временную сложность, недостаточно быстро при работе с большими векторами с использованием массивов Swift. Хорошей новостью является то, что мы можем сделать это намного быстрее и проще в реализации, используя vDSP
из платформы Apple Accelerate
, которая у нас уже есть в качестве стандартной библиотеки. О vDSP
можно почитать здесь , а простыми словами, это набор методов для выполнения задач цифровой обработки сигналов очень быстрым способом. Он имеет множество низкоуровневых оптимизаций, которые идеально работают с большими наборами данных. Давайте реализуем наше скалярное произведение и DCT, используя vDSP
:
infix operator • public func •(left: [Double], right: [Double]) -> Double { return vDSP.dot(left, right) } prefix operator ->> public prefix func ->>(value: [Double]) -> [Double] { let setup = vDSP.DCT(count: value.count, transformType: .II) return setup!.transform(value.compactMap { Float($0) }).compactMap { Double($0) } }
Чтобы сделать его менее утомительным, я использовал несколько операторов, чтобы сделать его более читабельным. Теперь вы можете использовать эти функции следующим образом:
let cosRedArray = ->> redValues let redResult = redCosArray • testArray
В новой реализации DCT существует проблема, связанная с текущим размером матрицы. Это не будет работать с нашим изображением размером 300 x 300, поскольку оно оптимизировано для работы с определенными размерами, степенью 2. Поэтому нам нужно будет приложить некоторые усилия для масштабирования изображения, прежде чем передавать его новому методу.
Спасибо всем, кто успел дочитать этот текст до сих пор или поленился пролистать, не читая. Целью этой статьи было показать, что многие задачи, которые люди не считают возможным решить с помощью некоторых нативных инструментов, можно решить с минимальными усилиями. Приятно искать альтернативные решения, и не ограничиваться интеграцией библиотеки Python как единственного варианта решения подобных задач.