Взгляд на Java через призму простого класса Vector
Когда я занимаюсь наукой о данных, мне очень легко принимать линейную алгебру как должное. Не обращаю ли я внимания на специфику собственного разложения в анализе главных компонентов, полностью забывая, что каждая взвешенная сумма на самом деле является скалярным произведением между вектором входных данных и вектором коэффициентов, или засовываю пальцы в уши, чтобы не слышать «тензор» в TensorFlow мне кажется, что я не получил диплом по математике из-за того, что не влезал в сорняки и не смотрел на реализации.
Хотя в определении векторного пространства нет ничего, что требовало бы представления векторов в виде списков чисел, мы сосредоточимся на векторах, которые действительно принимают эту форму. Мы также собираемся сосредоточиться только на векторных операциях с векторами и скалярами - матриц пока нет. Это банка червяков, которые не поместятся в этом посте.
Прежде чем мы начнем, несколько примечаний. Во-первых, чтобы код был как можно более доступным (я предполагаю, что многие люди, читающие это, пришли с Python), я вызвал статические методы, используя обозначение Vector.method()
. В этом нет строгой необходимости, но если вы новичок в Java, это может помочь вам различать статические методы (вызываемые из файла .class
) и методы экземпляра (вызываемые из объекта, существующего в памяти). Во-вторых, когда мы говорим о векторах как о математических объектах, я буду представлять их жирным шрифтом в нижнем регистре, например б. В-третьих, когда мы говорим об Vector
объектах в нашей программе, я помещаю имя вектора в monospace font
(чтобы он выглядел как код).
Наконец, я удалю документацию из примеров кода в этом посте, чтобы все было удобно для чтения. Если вы хотите просмотреть документацию или хотите сразу перейти к коду и избежать моих замечательных комментариев о том, чем Java отличается от Python, смело заходите в мой github, чтобы получить прямой доступ к коду.
Начало работы: какие данные нам нужно хранить?
Как обсуждалось выше, векторы, с которыми мы будем работать, представляют собой просто массивы действительных чисел, поэтому скелет нашего класса Vector можно увидеть ниже:
public class Vector { private double[] v; // methods not shown }
Несмотря на некоторое сходство синтаксиса для доступа к индексированным элементам, массивы в Java и списки в Python ведут себя по-разному. Массивы имеют фиксированную длину - чтобы добавлять и удалять элементы, вам нужно использовать что-то вроде ArrayList
. Это означает, что наши Vector
объекты привязаны к той длине, которой они инициализированы, если мы не заменим массив новым с другой длиной.
Этот код отличается от кода Python еще одним ключевым словом private
перед типом переменной. Есть способы скрыть данные в Python, например, использовать аннотацию @property
в методе с именем переменной, которую мы хотим скрыть), но скрытие переменных вашего экземпляра - это основная функция, которая делает Java, Java. Чтобы избежать непреднамеренного изменения кода массива чисел, набранных в недрах нашего Vector
объекта, мы делаем его private
, а это означает, что к v
можно получить доступ только способами, которые мы считаем приемлемыми.
Теперь, когда у нас есть каркас для хранения данных, нам нужно каким-то образом создать объект Vector
. Следующий конструктор поможет:
public Vector(double ... v) { this.v = new double[v.length]; for (int i = 0; i < v.length; i++) { this.v[i] = v[i]; } }
Я пользуюсь функцией Java Varargs, которая позволяет нам передавать либо массив двойников в конструктор, либо просто перечислять их в вызове конструктора:
double[] myArray = {1, 2, 3}; Vector u = new Vector(myArray); Vector w = new Vector(1, 2, 3);
Varargs - это отличная функция Java, о которой я хотел бы знать раньше! Они работают не так, как *args
в Python, но, если вы знакомы с этими обозначениями, вы достаточно близки для наших целей.
Чтобы упростить операторы вывода с нашими Vector
объектами, мы напишем методtoString()
, который позволит нам представлять наш Vector
как String
в любом желаемом формате.
@Override public String toString() { String str = "["; String sep = ",\n "; for (int i = 0; i < this.v.length; i++) { str += this.v[i]; if (i < (this.v.length - 1)) { str += sep; } } return str + "]"; }
Обратите внимание на аннотацию @Override
перед сигнатурой метода. В Java каждый класс расширяет класс Object, что означает, что при создании нового класса, такого как Vector
, вы наследуете несколько полезных методов от базового класса. Один из них - метод toString()
. По умолчанию этот метод возвращает String
представление адреса объекта в памяти, но, переопределив его, мы можем настроить способ отображения наших объектов - например, при передаче в оператор вывода. Если вы привыкли к объектно-ориентированному программированию на Python, это аналогично предоставлению вашему классу __str__
метода.
Я хотел представить свой вектор в виде столбца чисел, поэтому я написал этот метод, чтобы перебирать список, добавляя запись, запятую, разрыв строки и пробел (если это не последний объект в списке ) на String
, который изначально содержал только открывающую квадратную скобку. В результате получается аккуратное представление, подобное приведенному ниже:
[1.234, -7.654, 2.222]
Возможно, не лучшее представление для всех ситуаций, но оно хорошо работает для того, что мы здесь делаем.
Чтобы перейти к наиболее интересным моментам, вот краткое описание некоторых других методов, которые не связаны конкретно с линейной алгеброй:
get(int position)
возвращает элемент в позицииposition
length()
возвращаетv.length
getV()
делает копиюv
и возвращает ее (поэтому оригинал не может быть изменен напрямую)setV(double[] v)
заменяет содержимоеthis.v
аргументомv
set(int index, double value)
наборыthis.v[index] = value
А теперь самое интересное!
Два вспомогательных метода, которые нам нужно обсудить, прежде чем мы сможем сделать что-то слишком линейное, - это isZero()
, который возвращает true
, если все элементы равны 0, и false
, если в векторе есть какие-либо ненулевые элементы. Метод isZero
важен, потому что некоторые векторные операции, такие как нормализация, приведут к делению на ноль, если мы не проверим это сначала. Некоторые операции вполне допустимы с нулевым вектором, поэтому эта проверка выполняется только при определенных обстоятельствах.
Во-вторых, у нас есть checkLengths(Vector u, Vector v)
. Все важные векторные операции - например, сложение или скалярное произведение - определены только для векторов u и v, которые имеют одинаковое количество элементов. checklengths
сравнивает два вектора. Если они имеют одинаковую длину, ничего не происходит. Если они имеют разную длину, выдается IllegalArgumentException
:
public static void checkLengths(Vector u1, Vector u2) { if (u1.length() != u2.length()) { throw new IllegalArgumentException( "Vectors are different lengths"); } }
Основные операции
Известно, что векторы имеют две основные операции: скалярное умножение (умножение каждой записи на один и тот же скаляр) и сложение векторов (добавление соответствующих записей двух векторов), поэтому наш класс Vector
не поддержал бы их. Для каждого из них я создал static
метод, который можно вызывать непосредственно из класса, а затем метод экземпляра, который можно вызывать непосредственно в самом Vector
. Обратите внимание, что все эти операции создают новый объект Vector
, а не изменяют существующий.
public Vector add(Vector u) { return Vector.sum(this, u); } public static Vector sum(Vector u1, Vector u2) { Vector.checkLengths(u1, u2); // ** see comment double[] sums = new double[u1.length()]; for (int i = 0; i < sums.length; i++) { sums[i] = u1.get(i) + u2.get(i); } return new Vector(sums); }
static
метод sum
выполняет всю работу, а метод экземпляра add
просто передает this
, который относится к вызывающему объекту, и u
, переданный вектор, sum
. Питонисты узнают this
как Java-двоюродного брата self
. Есть некоторые важные различия между ними, например, как методы экземпляра принимают self
в качестве аргумента в Python, но просто требуют, чтобы мы отбросили static
в Java, но если вы понимаете одно, легко осознать другое.
Я обращаю внимание на this
, чтобы показать контраст между вызовом метода самого объекта (this.method()
) и вызовом статического метода класса Vector с Vector.sum()
.
Также обратите внимание, что в Vector.sum
самое первое, что мы должны сделать, это проверить, имеют ли векторы одинаковую длину, вызвав Vector.checkLengths(u1, u2)
. Поскольку мы добавляем первый элемент u1
к первому элементу u2
, затем к их вторым элементам, затем к их третьим элементам и так далее, важно, чтобы они фактически имели одинаковое количество элементов.
На самом деле в этот код встроена небольшая избыточность - поскольку checkLengths
по своей сути является методом класса Vector
, мы могли бы просто записать строку, помеченную // ** see comment
, как
checkLengths(u1, u2);
Чтобы прояснить, что мы не вызываем какой-либо метод, который конкретно требует данных экземпляра, я явно написал его как Vector.checkLengths(u1, u2);
Поскольку этот метод просто выполняет проверку и выдает IllegalArgumentException
, если условие не выполняется, у нас нет любое возвращаемое значение, и мы можем рассматривать его почти как оператор assert
.
Мы можем использовать аналогичный подход со скалярным умножением:
public Vector multiply(double scalar) { return Vector.product(this, scalar); } public static Vector product(Vector u, double scalar) { double[] products = new double[u.length()]; for (int i = 0; i < products.length; i++) { products[i] = scalar * u.get(i); } return new Vector(products); }
А также точечные продукты:
public double dot(Vector u) { return Vector.dotProduct(this, u); } public static double dotProduct(Vector u1, Vector u2) { Vector.checkLengths(u1, u2); double sum = 0; for (int i = 0; i < u1.length(); i++) { sum += (u1.get(i) * u2.get(i)); } return sum; }
Мы можем применить ту же логику к перекрестным произведениям, но нам нужно убедиться, что перекрестное произведение действительно определено первым - это означает проверку того, что оба вектора на самом деле имеют длину 3:
public Vector cross(Vector u) { return Vector.crossProduct(this, u); } public static Vector crossProduct(Vector a, Vector b) { // check to make sure both vectors are the right length if (a.length() != 3) { throw new IllegalArgumentException("Invalid vector length (first vector)"); } if (a.length() != 3) { throw new IllegalArgumentException("Invalid vector length (second vector)"); } Vector.checkLengths(a, b); // just in case double[] entries = new double[] { a.v[1] * b.v[2] - a.v[2] * b.v[1], a.v[2] * b.v[0] - a.v[0] * b.v[2], a.v[0] * b.v[1] - a.v[1] * b.v[0]}; return new Vector(entries); }
Я обращался к элементам массивов с немного отличающимся синтаксисом в этих операциях, как для того, чтобы сделать кросс-продукт более читабельным, так и для того, чтобы выделить особенность Java, которая может сбивать с толку при первом знакомстве с ней. Обратите внимание, как в dotProduct
я вызвал метод экземпляра u1.get(i)
, чтобы получить доступ к элементам массива, тогда как в crossProduct
мы обращались к элементам напрямую с помощью a.v[0]
. В Java любой код в классе Vector
имеет доступ к закрытым членам любого объекта Vector
, но мы также можем вызывать методы экземпляра этих членов.
Направление и величина
Часто нам нужно знать величину (длину) вектора, что на самом деле является частным случаем p-нормы, когда p = 2. Поскольку нормы L1 и L2 возникают в контексте машинного обучения (например, регрессии Лассо и Риджа), давайте продолжим и создадим обобщенную функцию для вычисления p-нормы, учитывая вектор и значение p:
// static method public static double pnorm(Vector u, double p) { if (p < 1) { throw new IllegalArgumentException("p must be >= 1"); } double sum = 0; for (int i = 0; i < u.length(); i++) { sum += Math.pow(Math.abs(u.get(i)), p); } return Math.pow(sum, 1/p); } // instance method public double pnorm(double p) { return Vector.pnorm(this, p); } // magnitude public double magnitude() { return Vector.pnorm(this, 2); }
Наличие как статического метода, так и метода экземпляра для pnorm
дает нам некоторую гибкость в том, как мы вычисляем норму вектора. Мы можем вызвать метод либо из самого класса Vector
(например, Vector.pnorm(u, 2)
), либо мы можем вызвать его непосредственно для существующего объекта Vector
(например, u.pnorm(2)
).
После того, как мы определили метод pnorm
, мы можем заключить его в метод magnitude
, чтобы получить величину вектора. И теперь, когда мы можем это вычислить, мы можем нормализовать вектор. Нормализация достигается путем деления элементов вектора на величину вектора, поэтому нам нужна проверка isZero
, прежде чем мы продолжим:
public static Vector normalize(Vector v) { if (v.isZero()) { throw new IllegalArgumentException(); } else { return v.multiply(1.0/v.magnitude()); } } public Vector normalize() { return Vector.normalize(this); }
Обратите внимание на разницу в том, как мы обрабатываем нулевые Vector
и Vector
объекты разной длины - каждый раз, когда мы пытаемся выполнить операцию с Vector
объектами разной длины, мы входим в неопределенную территорию, и нам нужно бросить IllegalArgumentException
для прерывания, но там - это некоторые операции (такие как pnorm
), которые вполне допустимы для записей, содержащих все нули, поэтому мы не хотели бы встраивать исключение в isZero()
.
Более сложные операции: вложенные углы и скалярные тройные произведения
Некоторые операции основаны на скалярных произведениях, перекрестных произведениях и расчетах величин, и теперь мы можем выполнять их, используя созданные нами методы.
Во-первых, мы хотим иметь возможность вычислить угол, заключенный двумя Vector
, для чего требуется метод dotProduct
:
public static double angleRadians(Vector u1, Vector u2) { Vector.checkLengths(u1, u2); return Math.acos(Vector.dotProduct(u1, u2) / (u1.magnitude() * u2.magnitude())); }
А затем мы хотим иметь возможность вычислить тройное скалярное произведение (для чего требуются как dotProduct
, так и crossProduct
методы:
public static double scalarTripleProduct(Vector a, Vector b, Vector c) { return Vector.dotProduct(a, Vector.crossProduct(b, c)); }
Если вам когда-либо приходилось делать это вручную (например, на экзамене по линейной алгебре), вы, вероятно, понимаете, насколько приятно было наблюдать, как эти элементы встают на свои места с таким небольшим усилием.
И вот оно! Целью этого поста было не открытие новых земель, а изучение того, как темы линейной алгебры взаимодействуют друг с другом, и выделить несколько интересных мне особенностей Java. Не стесняйтесь обращаться к исходному коду, если вы хотите поиграть с этими вашими собственными Vector
объектами.