Перейти до основного вмісту

Імітація відпалу

Імітація відпалу (Simulated Annealing, SA) — це рандомізований алгоритм, який наближено знаходить глобальний оптимум функції. Його називають рандомізованим, бо в процесі пошуку він використовує певну частку випадковості, а отже його результат для одного й того самого входу може відрізнятися.

Коли підходить цей алгоритм?
  • Простір станів великий і дискретний, а цільова функція має багато локальних мінімумів, через що жадібний/точний перебір не працює?
  • Достатньо наближеного (не обов'язково оптимального) розв'язку, а є запас часу на багато ітерацій?
  • Цільова функція унімодальна на неперервному відрізку? (якщо так → Тернарний пошук)

Задача

Нам задано функцію E(s)E(s), яка обчислює енергію стану ss. Наше завдання — знайти стан sbests_{best}, у якому E(s)E(s) мінімізується. SA добре підходить для задач, де стани дискретні, а E(s)E(s) має кілька локальних мінімумів. Розглянемо приклад задачі комівояжера (TSP).

Задача комівояжера (TSP)

Вам задано набір вершин у двовимірному просторі. Кожна вершина характеризується своїми координатами xx і yy. Ваше завдання — знайти такий порядок обходу вершин, який мінімізує сумарну відстань, що проходиться під час відвідування цих вершин у цьому порядку.

Мотивація

Відпал — це металургійний процес, у якому матеріал нагрівають, а потім дають охолонути, щоб атоми всередині перебудувалися в розташування з мінімальною внутрішньою енергією, що, своєю чергою, надає матеріалу інших властивостей. Станом тут є розташування атомів, а внутрішня енергія — це функція, яку ми мінімізуємо. Початковий стан атомів можна розглядати як локальний мінімум внутрішньої енергії. Щоб матеріал перебудував свої атоми, нам потрібно змотивувати його пройти через область, де його внутрішня енергія не є мінімальною, аби досягти глобального мінімуму. Цю мотивацію забезпечує нагрівання матеріалу до вищої температури.

Імітація відпалу буквально імітує цей процес. Ми починаємо з деякого випадкового стану (матеріалу) і задаємо високу температуру (нагріваємо його). Тепер алгоритм готовий приймати стани, що мають вищу енергію, ніж поточний стан, бо його мотивує висока температура. Це не дає алгоритму застрягнути в локальних мінімумах і змушує його рухатися до глобального мінімуму. З плином часу алгоритм охолоджується, відмовляється приймати стани з вищою енергією і переходить до найближчого знайденого мінімуму.

Функція енергії E(s)

E(s)E(s) — це функція, яку потрібно мінімізувати (або максимізувати). Вона відображає кожен стан у дійсне число. У випадку TSP E(s)E(s) повертає відстань повного оберту по колу в тому порядку вершин, що задано станом.

Стан

Простір станів — це область визначення функції енергії E(s)E(s), а стан — це будь-який елемент, що належить простору станів. У випадку TSP усі можливі шляхи, якими ми можемо відвідати всі вершини, утворюють простір станів, а будь-який окремий такий шлях можна вважати станом.

Сусідній стан

Це стан у просторі станів, близький до попереднього стану. Зазвичай це означає, що ми можемо отримати сусідній стан з вихідного за допомогою простого перетворення. У випадку задачі комівояжера сусідній стан отримують, випадково обираючи 2 вершини й міняючи їхні позиції в поточному стані місцями.

Алгоритм

Ми починаємо з випадкового стану ss. На кожному кроці ми обираємо сусідній стан snexts_{next} поточного стану ss. Якщо E(snext)<E(s)E(s_{next}) < E(s), то ми оновлюємо s=snexts = s_{next}. Інакше ми використовуємо функцію ймовірності прийняття P(E(s),E(snext),T)P(E(s),E(s_{next}),T), яка вирішує, чи слід нам перейти до snexts_{next}, чи лишитися в ss. Тут TT — це температура, яку спочатку задають високою і яка повільно спадає з кожним кроком. Що вища температура, то ймовірніше перейти до snexts_{next}. Водночас ми також відстежуємо найкращий стан sbests_{best} серед усіх ітерацій. Продовжуємо, поки не настане збіжність або не закінчиться час.


Візуальне зображення імітації відпалу під час пошуку максимуму цієї функції з кількома локальними максимумами.

Температура (T) і спад (u)

Температура системи кількісно визначає готовність алгоритму прийняти стан з вищою енергією. Спад — це константа, що кількісно задає «швидкість охолодження» алгоритму. Відомо, що повільна швидкість охолодження (більше значення uu) дає кращі результати.

Функція ймовірності прийняття (PAF)

P(E,Enext,T)={Trueif U[0,1]exp(EnextET)FalseotherwiseP(E,E_{next},T) = \begin{cases} \text{True} &\quad\text{if } \mathcal{U}_{[0,1]} \le \exp(-\frac{E_{next}-E}{T}) \\ \text{False} &\quad\text{otherwise}\\ \end{cases}

Тут U[0,1]\mathcal{U}_{[0,1]} — це неперервна рівномірно розподілена випадкова величина на [0,1][0,1]. Ця функція приймає поточний стан, наступний стан і температуру та повертає булеве значення, яке повідомляє нашому пошуку, чи слід йому перейти до snexts_{next}, чи лишитися в ss. Зауважимо, що для Enext<EE_{next} < E ця функція завжди повертатиме True, інакше ж вона все одно може зробити перехід з імовірністю exp(EnextET)\exp(-\frac{E_{next}-E}{T}), що відповідає мірі Гіббса.

bool P(double E,double E_next,double T,mt19937 rng){
double prob = exp(-(E_next-E)/T);
if(prob > 1) return true;
else{
bernoulli_distribution d(prob);
return d(rng);
}
}

Шаблон коду

class state {
public:
state() {
// Генеруємо початковий стан
}
state next() {
state s_next;
// Змінюємо s_next на випадковий сусідній стан
return s_next;
}
double E() {
// Реалізуйте тут функцію енергії
};
};


pair<double, state> simAnneal() {
state s = state();
state best = s;
double T = 10000; // Початкова температура
double u = 0.995; // швидкість спаду
double E = s.E();
double E_next;
double E_best = E;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
while (T > 1) {
state next = s.next();
E_next = next.E();
if (P(E, E_next, T, rng)) {
s = next;
if (E_next < E_best) {
best = s;
E_best = E_next;
}
E = E_next;
}
T *= u;
}
return {E_best, best};
}

Як цим користуватися:

Заповніть функції класу state відповідним чином. Якщо ви намагаєтеся знайти глобальний максимум, а не мінімум, переконайтеся, що функція E()E() повертає функцію, яку ви максимізуєте, зі знаком мінус, і виведіть Ebest-E_{best} наприкінці. Задайте наведені нижче параметри відповідно до своїх потреб.

Параметри

  • TT : Початкова температура. Задайте їй більше значення, якщо хочете, щоб пошук тривав довше.
  • uu : Спад. Визначає швидкість охолодження. Повільніша швидкість охолодження (більше значення u) зазвичай дає кращі результати ціною довшого часу роботи. Переконайтеся, що u<1u < 1.

Кількість ітерацій, яку виконає цикл, задається виразом

N=loguTN = \lceil -\log_{u}{T} \rceil

Поради щодо вибору TT і uu : Якщо локальних мінімумів багато й простір станів широкий, задайте u=0.999u = 0.999 для повільної швидкості охолодження, що дасть алгоритму змогу дослідити більше можливостей. З іншого боку, якщо простір станів вужчий, має вистачити u=0.99u = 0.99. Якщо ви не впевнені, перестрахуйтеся, задавши u=0.998u = 0.998 або вище. Обчисліть часову складність однієї ітерації алгоритму й скористайтеся нею, щоб приблизно оцінити таке значення NN, яке запобіжить TLE, а потім скористайтеся наведеною нижче формулою, щоб отримати TT.

T=uNT = u^{-N}

Приклад реалізації для TSP


class state {
public:
vector<pair<int, int>> points;
std::mt19937 mt{ static_cast<std::mt19937::result_type>(
std::chrono::steady_clock::now().time_since_epoch().count()
) };
state() {
points = {%raw%} {{0,0},{2,2},{0,2},{2,0},{0,1},{1,2},{2,1},{1,0}} {%endraw%};
}
state next() {
state s_next;
s_next.points = points;
uniform_int_distribution<> choose(0, points.size()-1);
int a = choose(mt);
int b = choose(mt);
s_next.points[a].swap(s_next.points[b]);
return s_next;
}

double euclidean(pair<int, int> a, pair<int, int> b) {
return hypot(a.first - b.first, a.second - b.second);
}

double E() {
double dist = 0;
int n = points.size();
for (int i = 0;i < n; i++)
dist += euclidean(points[i], points[(i+1)%n]);
return dist;
};
};

int main() {
pair<double, state> res;
res = simAnneal();
double E_best = res.first;
state best = res.second;
cout << "Lenght of shortest path found : " << E_best << "\n";
cout << "Order of points in shortest path : \n";
for(auto x: best.points) {
cout << x.first << " " << x.second << "\n";
}
}

Подальші модифікації алгоритму:

  • Додайте до циклу while умову виходу за часом, щоб запобігти TLE
  • Реалізований вище спад — це експоненційний спад. Ви завжди можете замінити його на функцію спаду відповідно до своїх потреб.
  • Наведена вище функція ймовірності прийняття надає перевагу прийняттю станів з нижчою енергією через множник EnextEE_{next} - E у чисельнику показника степеня. Ви можете просто прибрати цей множник, щоб зробити PAF незалежною від різниці енергій.
  • Вплив різниці енергій EnextEE_{next} - E на PAF можна збільшувати/зменшувати, збільшуючи/зменшуючи основу степеня, як показано нижче:
bool P(double E, double E_next, double T, mt19937 rng) {
double e = 2; // задайте e будь-яким дійсним числом, більшим за 1
double prob = pow(e,-(E_next-E)/T);
if (prob > 1)
return true;
else {
bernoulli_distribution d(prob);
return d(rng);
}
}

Задачі

Відеоматеріали