Взгляд на 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 объектами.