Метка: python 3

Комплексные числа в Python. Бонус: фрактал

В Python есть встроенный тип данных complex, который моделируют комплексные числа. По-моему, теория комплексных чисел – настоящий прорыв в математике, оказавший колоссальное влияние на современную физику. Неудивительно, что комплексные числа оказались в стандартной библиотеке такого языка, как Python.

Фрактал

Ко́мпле́ксные чи́сла — числа вида a + bi, где a, b — вещественные числа, i — мнимая единица, то есть число, для которого выполняется равенство: i2 = -1.

Графическое представление комплексного числа
Графическое представление комплексного числа

Цель этой статьи – рассказать про комплексные числа с точки зрения программирования на Python, если вам хочется узнать математическую теорию или просто освежить воспоминания о комплексных числах, то смело переходите по ссылке: Комплексные числа (откроется в новом окне), а потом возвращайтесь сюда.

В Python комплексное число состоит из пары чисел с плавающей запятой, которые отвечают за реальную и мнимые части. В исходнике на Си – это структура из пары чисел типа double. (Вы же помните, что float в Python это числа двойной точности?):

typedef struct {
    double real;
    double imag;
} Py_complex;

✅ Давайте посмотрим, какими способами мы можем задать комплексное число. Во-первых, с помощью встроенной функции complex(real[, imag]):

>>> complex()   # нуль! (комплексный)
0j
>>> complex(1)   # из обычного числа int или float (мнимая часть будет 0)
(1+0j)
>>> complex(2, 3)   # из пары чисел (реальная и мнимая части)
(2+3j)
>>> complex('2+3j')  # из строки (без пробелов)!
(2+3j)

❌ Когда создаете комплексное число из строки, то в ней не должно быть пробелов, иначе будет ошибка ValueError!

✅ Во-вторых, что очень круто, комплексное число можно задать особым синтаксисом в форме a+bj. Примеры:

>>> 8+5j
(8+5j)
>>> -1-1j
(-1-1j)
>>> 0j
0j
>>> -4.4e+5 + 1.5e+6j
(-440000+1500000j)

❌ Но! Нельзя задавать их так:

>>> 1+j
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'j' is not defined
>>> a = 5
>>> 2 + aj
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'aj' is not defined

В первом случае надо писать 1j, вместо j, потому что j – это может быть название какой-то иной переменной, ведь имя j не зарезервировано под мнимую единицу. Чтобы задать мнимое число, обязательно требуется перед j ставить цифры.

Во втором случае, мы желаем задать мнимую часть через переменную, но Python думает, что у нас должна быть переменная с именем aj, которой нет. Правильно писать в таком случае a*1j.

✅ Правильные варианты:

>>> 1+1j
(1+1j)
>>> a = 5
>>> 2 + a * 1j
(2+5j)

Кстати, буква j может быть и заглавной J.

Какие свойства есть у типа complex?

Можно извлекать из комплексного числа его мнимую (imag) и реальную (real) части – это обычные float:

>>> z = -3+7j
>>> z.real, z.imag
(-3.0, 7.0)
>>> z == z.real + z.imag * 1j
True

Комплексно-сопряженное число – то же самое, только с другим знаком у мнимой части:

>>> z.conjugate()
(-3-7j)
>>> (4-1j).conjugate()
(4+1j)

Модуль комплексного числа – фактически длина вектора на комплексной плоскости – вычисляется обычной функцией abs:

>>> abs(4+3j)
5.0

Операции

✅ К комплексным числам применимы обычные арифметический операторы, такие как +, -, *, /.

Как вы догадались из формы синтаксиса 1+2j, можно без проблем складывать комплексные числа с обычными вещественными float или целыми int. Т.е. полное комплексное число в этой форме представляется суммой действительного числа и чисто мнимого. Аналогично, умножение комплексного числа на вещественное просто масштабирует пропорционально его компоненты на это число.

Пару комплексных чисел можно складывать и вычитать. Это просто, они работают подобно двумерным векторам на плоскости. Комплексные числа можно умножать и делить. Тут математика несколько сложнее, не буду повторять ее, читайте вики. А примеры кода вот:

>>> z1 = 3 + 4j
>>> z2 = 5 - 2j
>>> z1 + z2
(8+2j)
>>> z1 - z2
(-2+6j)
>>> z1 * z2
(23+14j)
>>> z1 / z2
(0.24137931034482757+0.896551724137931j)

Не забывайте ставить скобки:

>>> 1+2j * 5-4j
(1+6j)
>>> (1+2j) * (5-4j)
(13+6j)

❌ Целочисленные деления, взятие остатка и подобное не применимы к комплексным числам.

✅ Комплексные числа можно сравнивать на равенство и неравенство.

>>> 1+2j == 2j+1
True
>>> 3 + 4j != 2 - 2j
True

❌ Но нельзя к ним применять знаки больше, меньше.

>>> 1j > 2j
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '>' not supported between instances of 'complex' and 'complex'

✅ Можно возводить одно комплексное число в степень другого следующими способами*:

>>> z1 ** z2
(3046.7438186304985+19732.04597993193j)
>>> pow(z1, z2)
(3046.7438186304985+19732.04597993193j)

Комплексный 0 в степени комплексного 0 даст… барабанная дробь… единицу:

>>> 0j ** 0j
(1+0j)

*) О многозначности замолвлено будет ниже, математики приберегите свои помидоры, пока не дочитаете до конца.

cmath

Модуль, который отвечает за стандартные функции над комплексными числами называется cmath (документация на английском). Обычный math не умеет извлекать корень из минус единицы, а cmath – запросто:

>>> import math, cmath
>>> math.sqrt(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: math domain error
>>> cmath.sqrt(-1)
1j

Рассмотрим основные функции из cmath:

cmath.phase(x) – фаза (или у нас она более известна, как аргумент Arg z) – эквивалент math.atan2(x.imag, x.real) – иными словами, это угол поворота φ вектора на комплексной плоскости. Результат лежит в промежутке [-ππ]. При этом разрез на комплексной области (т. е. луч, через который результат функции разрывается и перепрыгивает) выбирается вдоль отрицательной части реальной оси).

Представление комплексного числа в полярных координатах
Представление комплексного числа в полярных координатах
>>> phase(complex(-1.0, 0.0))
3.141592653589793
>>> phase(complex(-1.0, -0.0))  # минус ноль! подходим с другой стороны к точке разреза
-3.141592653589793

За модуль отвечает обычная abs (без cmath).

cmath.polar(x) – перевод комплексного числа в полярные координаты (кортеж из двух float): (abs(x), cmath.phase(x)).

cmath.rect(rphi) – наоборот выдает комплексное число по его полярным координатам – эквивалент: r * (math.cos(phi) + math.sin(phi)*1j).

cmath.sqrt(x) – корень квадратный из комплексного числа.

>>> cmath.sqrt(1j)
(0.7071067811865476+0.7071067811865475j)

Те, кто разбирается в математике сразу заметят, что многие функции от комплексной переменной – многозначны. (Видео о многозначных функциях) В частности у корня квадратного из комплексного числа всегда ровно два ответа. Но! Python всегда возвращает ровно одно значение. Он берет всегда «главную ветвь», а всякие вращения на 2πk/n остаются на совести программиста. Вот теория, как брать корень из комплексного числа. Я тщетно пытался нагуглить функцию, возвращающую множество значений корня n-й степени, и в итоге написал свою:

import cmath

def roots(z: complex, n):
    assert isinstance(n, int) and n > 1
    r, phi = cmath.polar(z)
    r **= 1 / n
    for k in range(n):
        yield cmath.rect(r, (phi + 2 * cmath.pi * k) / n)

z1, z2 = roots(1j, 2)
print(z1, z2)
print(cmath.isclose(z1 * z1, 1j))
print(cmath.isclose(z2 * z2, 1j))

# (0.7071067811865476+0.7071067811865475j) (-0.7071067811865477-0.7071067811865475j)
# True
# True
Разрез комплексной плоскости
Разрез комплексной плоскости

Функция cmath.isclose(ab*rel_tol=1e-09abs_tol=0.0) проверяет близки ли два комплексных числа между собой с некоторой точностью (относительной или абсолютной). Так как complex строится на базе чисел с плавающей точкой, то из-за ошибок округления редко можно получить точное равенство, приходится проверять близость. Код эквивалентен: abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol). Числа NaN не близки ни к кому, включая самих себя. А inf и -inf близки только каждое само к себе.

cmath.exp(x),cmath.log(x[, base]), cmath.log10(x), cmath.acos(x), cmath.asin(x), cmath.atan(x), cmath.cos(x), cmath.sin(x), cmath.tan(x), cmath.acosh(x), cmath.asinh(x), cmath.atanh(x), cmath.cosh(x), cmath.sinh(x), cmath.tanh(x) –   обычный набор функций, только для комплексных чисел. Каждая из функций возвращает один результат. Думаю, если вам действительно нужны в работе комплексные функции, значит вы и так знаете, как достать все значения функций.

Множество Мандельброта

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

Мно́жество Мандельбро́та — это множество таких точек c на комплексной плоскости, для которых рекуррентное соотношение  задаёт ограниченную последовательность.

Реккурентное соотношение
Реккурентное соотношение

Иными словами мы берем каждую точку с = x + yj на выбранном участке комплексной плоскости и возводим в квадрат, потом снова прибавляем c, опять возводим в квадрат и так несколько раз. Смотрим, улетает ли результат в бесконечность. Если не улетает, значит точка принадлежит множеству, красим ее в черный, а если улетает, то красим в белый.

Нам понадобятся библиотеки для работы с изображениями и для полосы прогресса:

 pip install pillow tqdm 

Пишем код:

from PIL import Image
from tqdm import tqdm

W, H = 1024, 768  # размеры картинки
ITER = 100  # максимальное число итераций, чтобы убедиться расходится или нет формула в данной точке
LIMIT = 2.0  # предельное значение, выше которого уже наверняка расходится

img = Image.new('RGB', (W, H))

for px in tqdm(range(W)):
    for py in range(H):
        # преобразование координат
        x = px / W * 3 - 2  # x = -2..1
        y = py / H * 2 - 1  # y = -1..1

        color = (0, 0, 0)  # черный
        c = x + 1j * y  # смещение из координат
        z = 0j  # начальная точка
        for n in range(ITER):
            z = z ** 2 + c
            if abs(z) > LIMIT:  # разошлось
                color = (255, 255, 255)  # белый цвет
                break
        img.putpixel((px, py), color)

img.save('mand0.png')  # сохраним
img.show()  # покажем

Результат:

Фрактал в ЧБ
Фрактал в ЧБ

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

from PIL import Image
from tqdm import tqdm
import math

W, H = 1024, 768  # размеры картинки
ITER = 1000  # максимальное число итераций, чтобы убедиться расходися или нет формула в данной точке
LIMIT = 2.0  # предельное значение, выше которого уже расходится

img = Image.new('RGB', (W, H))

# создадим палитру от числа итераций
palette = [
    (
        int(255 * math.sin(i / 50.0 + 1.0) ** 2),
        int(255 * math.sin(i / 50.0 + 0.5) ** 2),
        int(255 * math.sin(i / 50.0 + 1.7) ** 2)
    ) for i in range(ITER - 1)
]
palette.append((0, 0, 0))  # последняя итерация - значит мы внутри - черный

for px in tqdm(range(W)):
    for py in range(H):
        # преобразование координат
        x = px / W * 3 - 2  # x = -2..1
        y = py / H * 2 - 1  # y = -1..1

        c = x + 1j * y  # смещение из координат
        z = 0j  # начальная точка
        for n in range(ITER):
            z = z ** 2 + c
            if abs(z) > LIMIT:  # разошлось
                break
        img.putpixel((px, py), palette[n])


img.save('mand1.png')  # сохраним
img.show()  # покажем

Вот итог:

Цветной фрактал
Цветной фрактал

Вот такая красота получается из простейшей формулы!

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

                if (z * z.conjugate()).real > 4.0:  # вместо abs(z) > 2.0!
                    break

От кадра к кадру будем менять область отображения. Генератор фрактала для заданной области описан функцией:

def mandelbrot(w, h, palette, x1=-2, y1=-1, x2=1, y2=1):
    img = Image.new('RGB', (w, h))

    dx = x2 - x1
    dy = y2 - y1
    iters = len(palette)

    n = 0
    for px in range(w):
        for py in range(h):
            # преобразование координат
            x = px / w * dx + x1
            y = py / h * dy + y1

            c = x + 1j * y  # смещение из координат
            z = 0j  # начальная точка
            for n in range(iters):
                z = z ** 2 + c
                if (z * z.conjugate()).real > 4.0:  # разошлось
                    break
            img.putpixel((px, py), palette[n])
    return img

Остаетя задать закон движения камеры и сохранить пачку кадров в GIF.

x, y, r = 0, 0, 5  # от
x_tar, y_tar, r_tar = -0.74529, 0.113075, 1.5e-6  # до

frames = []
for _ in tqdm(range(FRAMES)):
    frames.append(mandelbrot(W, H, palette, x - r, y - r, x + r, y + r))
    x += (x_tar - x) * 0.1
    y += (y_tar - y) * 0.1
    r += (r_tar - r) * 0.1

frames[0].save('mandel.gif', save_all=True, append_images=frames[1:], duration=60, loop=0)

Полный код генератора анимации здесь.

Анимация погружения в фрактал
Анимация погружения в фрактал

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

globals, locals, vars, dir – инспектируем переменные

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

Функция globals() выдает словарь глобальных переменных (ключ – имя переменной). Функция locals() возвращает словарь только локальных переменных. Пример:

x, y = 5, 10

def test():
    y, z = 33, 44
    print('globals:', globals())
    print('locals:', locals())

test()

"""Вывод:
globals: {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': ...>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/Users/.../vars.py', '__cached__': None, 'x': 5, 'y': 10, 'test': <function test at 0x107677280>}
locals: {'y': 33, 'z': 44}"""

Обратите внимание, что переменная y в locals() имеет другое значение, нежели чем в globals(). Это две разные переменные из разных областей, но внутри функции приоритет имеет локальная y.

Еще важно знать, что в список переменных входят не только простые переменные, которые вы определяете через знак присваивания, но и функции, классы и импортированные модули!

Через словари из locals() и globals() переменные можно не только читать, но и создавать, перезаписывать и удалять:

>>> x = 10
>>> globals()['x'] = 5
>>> x
5
>>> globals()['new_var'] = 10
>>> new_var
10
>>> del globals()['new_var']
>>> new_var
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'new_var' is not defined

vars()

vars() ведет себя как locals(), если вызвана без аргумента, а если с аргументом, то она просто получает __dict__ от аргумента. Если его нет у аргумента, то будет TypeError.

class Foo:
    def __init__(self):
        self.x = 5

f = Foo()
print(vars(f))  # {'x': 5}
print(vars(f) == f.__dict__)  # True

В глобальном контексте все три функции возвращают одно и тоже – глобальные переменные. Проверьте:

print(globals())
print(locals())
print(vars())
print(globals() == locals() == vars()) # True

dir()

Без параметров dir() возвращает список имен переменных. В глобальном контексте – глобальных переменных, в локальном – список имен локальных переменных.

def test():
    x = 10
    print(dir())  # ['x']

y = 10
test()
print(dir())  # ['__annotations__', ..., '__spec__', 'test', 'y']

Все рассмотренные выше функции являются встроенными и не требуют импортов.

P. S.

В отличие он некоторых других языков в Python блоки типа for, if, while, with не создают областей видимости (scope) для переменных, то есть переменная внутри и снаружи блока будет одна и та же:

x = 1
if True:
    x = 2
print(x)  # 2

Частая ошибка – затирание внешней переменной в цикле for:

i = 10
for i in range(5):  # затирает i
    ...
print(i)  # 4

Зоны видимости отделяются только функциями, классами и модулями. Здесь все переменные x – разные:

x = 1
class Foo:
    x = 2
    def method(self):
        x = 3
        return x
print(x, Foo.x, Foo().method())  # все 3 разные

Самая широкая зона видимости называется builtin. В нее попадают все имена, известные интерпретатору в данный момент, включая вещи импортированные из других модулей.

>>> from math import pi
>>> pi, id(pi)
(3.141592653589793, 4465320624)
>>> pi = 3
>>> pi, id(pi)
(3, 4462262880)
>>> from math import pi
>>> pi, id(pi)
(3.141592653589793, 4465320624)

Казалось бы мы затерли pi, но мы затерли его лишь в глобальной области видимости. Повторно импортируя pi, мы получаем старую переменную с тем же адресом, иными словами мы достаем ее из builtin области в global.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Задача на ключи словаря

Имеется такой код, где мы делаем 5 записей в словарь:

d = {}

d[float('nan')] = 1
d[float('nan')] = 2
d[1.0] = 'float'
d[1] = 'int'
d[True] = 'bool'

print(len(d))

Давайте решим ее. Для ключа словаря нам важны две вещи:

  • Хэш hash(key) – ключи с разными хэшами дадут нам разные записи в словаре.
  • Равенство ключей – если хэши равны, то проверяется равенство ключей (==), и если и они равны, то считается, что ключ тот же самый – это будет одна и та же запись в словаре.

float(‘nan’)

float('nan') – создает нам новый объект типа float со значением NaN (not a number – не число). Это специально значение. Оно получается, если результат операции не определен. Например, вычитание бесконечности из бесконечности не даст нам конкретного определенного результата, потому что бесконечность – это не число:

>>> print(float('Inf') - float('Inf'))
nan

В соответствии с IEEE 754, такое состояние задаётся через установку показателя степени в зарезервированное значение 11…11, а мантиссы — во что угодно, кроме 0 (зарезервированное значение для машинной бесконечности).

У NaN есть замечательно свойство, что он не равен никакому другому float, даже самому себе или другому NaN.

>>> x = float('nan')
>>> x == x
False
>>> hash(x)
0

Но hash от NaN всегда равен 0. Таким образом, словарь видит, что мы кладем в него ключи с одинаковым хэшем, но не равные между собой. Вывод: мы можем создать сколько угодно ключей с NaN, на вид они одинаковые, даже побитово могут совпадать, но так как каждый NaN не равен другому NaN по определению, то и dict все их считает разными!

>>> d = {}
>>> d[float('nan')] = 1
>>> d[float('nan')] = 2
>>> d
{nan: 1, nan: 2}

>>> d[float('nan')]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: nan

1, 1.0 и True

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

>>> hash(1), hash(1.0), hash(True)
(1, 1, 1)
>>> 1 == 1.0 == True
True

Все они равны между собой! Поэтому в словаре все эти три ключа будут отвечать ровно одной записи! Посмотрите:

>>> d = {}

>>> d[1.0] = 'float'
>>> d[1] = 'int'
>>> d[True] = 'bool'

>>> d
{1.0: 'bool'}
>>> len(d)
1

>>> d[1]
'bool'
>>> d[1.0]
'bool'
>>> d[True]
'bool'

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

Ответ: 3

У нас в словаре две записи от разных float('nan') и только одна запись от трех присваиваний 1.0, 1 и True. Итого ответ – 3 (три) записи будет в словаре!

Пусть вас не путает, что в условии задачи было 5 операторов.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Пакуем байты на Python: struct

Кто сказал, что нельзя делать низкоуровневые вещи на Python? Конечно, можно. Давайте научимся упаковывать данные из Python в байты и распаковывать их обратно.

Встроенный модуль struct как раз создан для этих целей. В низкоуровневом деле важны детали, а именно размер каждого элемента данных, их порядок в структуре, а также порядок байт для многобайтовых типов данных. Для определения этих деталей модуль struct вводит форматные строки (не путать с str.format, там другой формат).

Начнем с простого примера:

>>> import struct
>>> struct.pack("hhl", 1, 2, 3)
b'\x01\x00\x02\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00'

Здесь происходит вот что. Мы берем три числа: 1, 2, 3 и пакуем их в байты, таким образом, что первое и второе числа трактуются как тип short int (4 байта на моей машине), а последнее, как long int (8 байт на моей машине). Это типы не из Python, а из языка Си. Ознакомьтесь с типами языка Си, если хотите понимать, что они из себя представляют и какой размер в байтах имеют.

Обратная распаковка байт в кортеж значений по заданному формату:

>>> struct.unpack("hhl", b'\x01\x00\x02\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00')
(1, 2, 3)

Форматы запаковки и распаковки должны совпадать, иначе данные будут неправильно интерпретированы или испорчены, или же вообще возникнет ошибка из-за того, что размер данных не подходит под ожидаемый формат (struct.error):

>>> struct.unpack("hhl", b'\x01\x02\x03')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
struct.error: unpack requires a buffer of 16 bytes

Обратите внимание, что я выше писал, что размер элемента «h» – 4 байта именно на моей машине. Может статься так, что на машине с другим процессором, архитектурой или просто с другой версией ОС размер типа будет другой. Для 32 битных систем, это обычно будет 2 байта.

Но, что если данных передаются по сети или через носители информации между системами с разной или неизвестной заранее архитектурой? Конечно, у struct есть средства на такие случаи. Первый символ форматной строки обозначит порядок байт. Обратите внимание на таблицу:

СимволПорядок байтРазмеры типовВыравнивание
@нативныйнативныйнативное
=нативныйстандартныенет
<little-endianстандартныенет
>big-endianстандартныенет
!сетевой
(= big-endian)
стандартныенет

Нативный – значит родной для конкретно вашей машины и системы. По умолчанию порядок байт и размер типов данных как раз нативный (символ @).

Стандартный размер – размер, который фиксирован стандартом и не зависит от текущей платформы. Например, char всегда 1 байт, а int – 4 байта. Если мы планируем распространять запакованные байты, мы должны гарантировать, что размер типов будет всегда стандартный. Для этого подходит любой из символов «=«, «<«, «>«, «!» в начале форматной строки.

Little-endian и big-endian

Little-endian и big-endian – это два основных порядка байт. Представим, что у нас есть короткое целое (short int), и оно занимает два (2) байта. Какой из байтов должен идти сначала, а какой в конце?

В big-endian порядок от старшего байта к младшему. В little-endian порядок от младшего байта к старшему. Как узнать на Python какой порядок байт в системе:

>>> import sys
>>> sys.byteorder
'little'

Давайте наглядно посмотрим как пакуются байты при разных порядках. Для числа 258 в форме short младший байт будет = 2, а старший = 1:

258 = 2*20 + 1*28

>>> struct.pack("<h", 258)  # little-endian
b'\x02\x01'
>>> struct.pack(">h", 258)  # big-endian
b'\x01\x02'

Как видите порядок байт противоположный для разных случаев.

В сетевых протоколах принято использовать big-endian (символ «!» – псевдоним к «>«), а на большинстве современных настольных систем используется little-endian.

Таблица типов данных

Теперь ознакомимся с таблицей типов данных, которая дает соответствие символу форматной строки (код преобразования) с Си-типом данных, Python-типом данных и стандартный размером. Еще раз: стандартный размер будет только, если задан первый символ как «<«, «>«, «!» или «=«. Для «@» или по умолчанию – размер данных определяется текущей системой (платформо-зависимо).

СимволТип в языке СиPython типСтанд. размер
xбайт набивкинет значения
ccharbytes длины 11
bsigned charinteger1
Bunsigned charinteger1
?_Boolbool1
hshortinteger2
Hunsigned shortinteger2
iintinteger4
Iunsigned intinteger4
llonginteger4
Lunsigned longinteger4
qlong longinteger8
Qunsigned long longinteger8
nssize_tintegerзависит
Nsize_tintegerзависит
e«половинный float«float2
ffloatfloat4
ddoublefloat8
schar[]bytesуказывается явно
pchar[] — строка из Паскаляbytesуказывается явно

Коды «e«, «f«, «d» используют бинарный формат IEEE-754.

Код «x» это просто байт набивки. Он не попадает в распакованные данные, а нужен, чтобы выравнивать данные. «x» при запаковке забиваются пустыми байтами. Пример: «пусто-число-пусто-пусто-число-пусто»:

>>> struct.pack(">xBxxBx", 255, 128)
b'\x00\xff\x00\x00\x80\x00'

>>> struct.unpack('>xBxxBx', b'\x00\xff\x00\x00\x80\x00')
(255, 128)

О форматной строке

Если в форматной строке перед символом кода – число, то значит этот символ повторяется столько раз, сколько указывает число. Два кусочка кода аналогичны:

>>> struct.pack(">3h", 1, 2, 3)
b'\x00\x01\x00\x02\x00\x03'

>>> struct.pack(">hhh", 1, 2, 3)
b'\x00\x01\x00\x02\x00\x03'

Для строк (коды «s» и «p«) надо указывать число байт – длину строки, иначе будет считаться 1 байт:

>>> struct.pack("ss", b"abc", b"XYZW")  # не указал длину - потерял байты
b'aX'

>>> struct.pack("3s4s", b"abc", b"XYZW")
b'abcXYZW'

10s – одна 10-символьная строка, а 10c – 10 отдельных символов:

>>> struct.unpack('10c', b'abracadabr')
(b'a', b'b', b'r', b'a', b'c', b'a', b'd', b'a', b'b', b'r')

>>> struct.unpack('10s', b'abracadabr')
(b'abracadabr',)

Можно вставлять пробелы между отдельными элементами форматной строки (но нельзя отделать число от символа). Пробелы игнорируются при чтении строки и нужны для удобства чтения кода программистом:

>>> struct.pack('>6sh?', b'python', 65, True)
b'python\x00A\x01'

>>> struct.pack('> 6s h ?', b'python', 65, True)  # тоже, но с пробелами
b'python\x00A\x01'

>>> struct.unpack('> 6s h ?', b'python\x00A\x01')
(b'python', 65, True)

Полезности

Можно вычислить размер данных из форматной строки без фактической запаковки или распаковки данных:

>>> struct.calcsize('> 6s h ?')
9

Удобно распаковывать байты прямо в именованные кортежи:

>>> from collections import namedtuple
>>> Student = namedtuple('Student', 'name serialnum school gradelevel')
>>> record = b'raymond   \x32\x12\x08\x01\x08'
>>> Student._make(struct.unpack('<10sHHb', record))
Student(name=b'raymond   ', serialnum=4658, school=264, gradelevel=8)

Запаковка в буффер со смещением struct.pack_into(formatbufferoffsetv1v2...):

>>> buffer = bytearray(40)
>>> struct.pack_into('h l', buffer, 10, 3432, 340840)
>>> buffer
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00h\r\x00\x00\x00\x00\x00\x00h3\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
>>>

Распаковка из буффера со смещением:

>>> x, y = struct.unpack_from('h l', buffer, 10)
>>> x, y
(3432, 340840)

Распаковка нескольких однотипных структур:

>>> chunks = struct.pack('hh', 10, 20) * 5  
>>> chunks  # 5 одинаковых штук
b'\n\x00\x14\x00\n\x00\x14\x00\n\x00\x14\x00\n\x00\x14\x00\n\x00\x14\x00'

>>> [(x, y) for x, y in struct.iter_unpack('hh', chunks)]
[(10, 20), (10, 20), (10, 20), (10, 20), (10, 20)]

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

NumPy-бродкастинг

Эта тема не очень освещена на русском языке, но является весьма важной по паре причин: бродкастинг упрощает жизнь, и порой он же ее усложняет. Давайте разберемся что это, и как оно работает?

Бродкастинг (broadcasting) – автоматическое расширение размерности (ndim) и размеров (shape) массивов, при совершении операций (сложение, умножение и подобные) над массивами с разными размерами или размерностями, при условии, что они совместимы с правилами бродкастинга.

Очень животрепещущий пример из жизни, где вы используете бродскастинг и даже об этом не думаете: совершение операций между вектором и скаляром. Что значит умножить вектор на число? Вероятно, каждый скажет, что это значит, что нужно домножить каждый компонент этого вектора на это число. Иными словами, можно копировать число столько раз, сколько у нас компонент в векторе, получив из числа вектор той же размерности, а потом поэлементно умножить эти вектора:

>>> y = np.array([2] * 3)
>>> y
array([2, 2, 2])
>>> x * y
array([2, 4, 6])

>>> x = np.array([1, 2, 3])
>>> x * 2
array([2, 4, 6])

NumPy не заставляет нас вручную превращать скаляр (2) в вектор [2, 2, 2], он сам добавляет размерность и клонирует содержимое нужно число раз, а потом уже производит поэлементное умножение.

Бродкастинг скалара в вектор

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

Бродкастинг работает и в более сложных ситуациях по четким правилам.

Правила бродкастинга

Буду сразу объяснять на примере. Есть два массива, которые мы желаем сложить:

>>> a = np.ones((8, 1, 6, 1))
>>> b = np.ones((7, 1, 5))
>>> a.shape
(8, 1, 6, 1)
>>> b.shape
(7, 1, 5)

# (a + b) = ?

Сначала размеры (shape) массивов выстраивается друг над другом, выравнивая по правому краю. Напомню, что справа у нас самая «глубокая» размерность.

A         (4d массив):  8 x 1 x 6 x 1
B         (3d массив):      7 x 1 x 5

Затем NumPy идет справа налево, поэлементно сравнивая каждый размер операндов. Два размера считаются совместимыми, если они равны или один из них равен единице (1). Если два размера несовместимы, бродкастинг не пройдет, возникнет ошибка.
ValueError: operands could not be broadcast together with shapes

Если слева не хватает размерности, то она автоматически расширяется единицей, это значит, что мы как будто бы оборачиваем массив в еще одни квадратные скобки. В нашем примере, у B не хватает одной размерности, так как он трехмерный вектор, а мы превратим его в четырехмерный.

A         (4d массив):  8 x 1 x 6 x 1

B         (3d массив):      7 x 1 x 5
B'        (4d массив):  1 x 7 x 1 x 5
B' = [ B ] 

Мы видим, что в примере два массива полностью совместимы для бродкастинга – (8 над 1, 1 над 7, 6 над 1, 1 над 5): в каждом из столбиков есть единичка.

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

A         (4d массив):  8 x 1 x 6 x 1
B         (3d массив):      7 x 1 x 5
Результат (4d массив):  8 x 7 x 6 x 5

Т. е. у A на глубоком уровне по одному числу, а у B – по 5 штук. Примерно так:

A = [ [ [ [123], ... ] ], ... ]
B = [ [ [456, 456, 456, 456, 456] ], ... ]

A' = [ [ [ [123, 123, 123, 123, 123], ... ] ], ... ] 

Значит внутренний подмассив [123] у A тоже раскопируется в 5 значений [123, 123, 123, 123] и, таким образом, станет совместим с внутренним подмассивом B, где уже было 5 чисел.

Как только все размерности выровнены путем «копирования», то можно делать любую операцию поэлементно. Форма результата будет равна форме операндов. В итоге:

>>> (a + b).shape
(8, 7, 6, 5)

Еще примеры

Примеры привожу по следам документации. Такой код:

import numpy as np

a = np.array([[0, 0, 0],
              [10, 10, 10],
              [20, 20, 20],
              [30, 30, 30]])
b = np.array([1, 2, 3])

print(a + b)
# [[ 1  2  3]
#  [11 12 13]
#  [21 22 23]
#  [31 32 33]]

Работает по следующей схеме:

Сложение матрицы и вектора

Еще больше примеров, как получается финальный размер после операции:

Картинка  (3d массив)	256 x	256 x	3
Масштаб   (1d массив)	 	 	3
Результат (3d массив)	256 x	256 x	3

A      (2d array):  5 x 4
B      (1d array):      1
Result (2d array):  5 x 4

A      (2d array):  5 x 4
B      (1d array):      4
Result (2d array):  5 x 4

A      (3d array):  15 x 3 x 5
B      (3d array):  15 x 1 x 5
Result (3d array):  15 x 3 x 5

A      (3d array):  15 x 3 x 5
B      (2d array):       3 x 5
Result (3d array):  15 x 3 x 5

A      (3d array):  15 x 3 x 5
B      (2d array):       3 x 1
Result (3d array):  15 x 3 x 5

Примеры несовместимости

А вот примеры несовместимости:

A      (1d array):  3
B      (1d array):  4 # не совпадают 3 и 4 (и ни одна из них не 1)

A      (2d array):      2 x 1
B      (3d array):  8 x 4 x 3 # второй столбик справа не совпадает (2 и 4) 

Такой код на практике даст ошибку:

import numpy as np

a = np.array([[0, 0, 0],
              [10, 10, 10],
              [20, 20, 20],
              [30, 30, 30]])
b = np.array([0, 1, 2, 3])

print(a + b)
# ValueError: operands could not be broadcast together with shapes (4,3) (4,)

Потому что тут бродкастинг не работает, так как нарушены его правила:

Тут бродкастинг не работает

Опасность бродкастинга

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

Я пока не нашел опции запретить бродкастинг в NumPy, а ответы со Stackoverflow вроде [1], [2] оказались НЕРАБОЧИМИ. Как всегда, будьте осторожны!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈