20 сентября 2011 в 10:57

Генетический алгоритм. Просто о сложном из песочницы tutorial


В последнее время все больше «ходят» разговоры про новомодные алгоритмы, такие как нейронные сети и генетический алгоритм. Сегодня я расскажу про генетические алгоритмы, но давайте на этот раз постараемся обойтись без заумных определений и сложных терминах.
Как сказал один из великих ученных: «Если вы не можете объяснить свою теорию своей жене, ваша теория ничего не стоит!» Так давайте попытаемся во всем разобраться по порядку.

Щепотка истории


Как говорит Википедия: «Отец-основатель генетических алгоритмов Джон Холланд, который придумал использовать генетику в своих целях аж в 1975 году». Для справки в этом же году появился Альтаир 8800, и нет, это не террорист, а первый персональный компьютер. К тому времени Джону было уже целых 46 лет.

Где это используют


Поскольку алгоритм самообучающийся, то спектр применения крайне широк:
  • Задачи на графы
  • Задачи компоновки
  • Составление расписаний
  • Создание «Искусственного интеллекта»

Принцип действия


Генетический алгоритм — это в первую очередь эволюционный алгоритм, другими словами, основная фишка алгоритма — скрещивание (комбинирование). Как несложно догадаться идея алгоритма наглым образом взята у природы, благо она не подаст на это в суд. Так вот, путем перебора и самое главное отбора получается правильная «комбинация».
Алгоритм делится на три этапа:
  • Скрещивание
  • Селекция (отбор)
  • Формирования нового поколения

Если результат нас не устраивает, эти шаги повторяются до тех пор, пока результат нас не начнет удовлетворять или произойдет одно из ниже перечисленных условий:
  • Количество поколений (циклов) достигнет заранее выбранного максимума
  • Исчерпано время на мутацию

Более подробно о шагах

Создание новой популяции. На этом шаге создается начальная популяция, которая, вполне возможно, окажется не кошерной, однако велика вероятность, что алгоритм эту проблему исправит. Главное, чтобы они соответствовали «формату» и были «приспособлены к размножению».
Размножение. Ну тут все как у людей, для получения потомка требуется два родителя. Главное, чтобы потомок (ребенок) мог унаследовать у родителей их черты. При это размножаются все, а не только выжившие (эта фраза особенно абсурдна, но так как у нас все в сферическом вакууме, то можно все), в противном случае выделится один альфа самец, гены которого перекроют всех остальных, а нам это принципиально не приемлемо.
Мутации. Мутации схожи с размножением, из мутантов выбирают некое количество особей и изменяют их в соответствии с заранее определенными операциями.
Отбор. Тут начинается самое сладкое, мы начинаем выбирать из популяции долю тех, кто «пойдет дальше». При этом долю «выживших» после нашего отбора мы определяем заранее руками, указывая в виде параметра. Как ни печально, остальные особи должны погибнуть.

Практика


Вы успешно прослушали «сказку» про чудо-алгоритм и вполне возможно заждались, когда мы его начнем эксплуатировать наконец, хочу вас обрадовать, время настало.
Давайте рассмотрим на примере моих любимых Диофантовых уравнений (Уравнения с целочисленными корнями).
Наше уравнение: a+2b+3c+4d=30
Вы наверно уже подозреваете, что корни данного уравнения лежат на отрезке [1;30], поэтому мы берем 5
случайных значений a,b,c,d. (Ограничение в 30 взято специально для упрощения задачи)
И так, у нас есть первое поколение:
  1. (1,28,15,3)
  2. (14,9,2,4)
  3. (13,5,7,3)
  4. (23,8,16,19)
  5. (9,13,5,2)

Для того чтобы вычислить коэффициенты выживаемости, подставим каждое решение в выражение. Расстояние от полученного значения до 30 и будет нужным значением.
  1. |114-30|=84
  2. |54-30|=24
  3. |56-30|=26
  4. |163-30|=133
  5. |58-30|=28

Меньшие значения ближе к 30, соответственно они более желанны. Получается, что большие значения будут иметь меньший коэффициент выживаемости. Для создания системы вычислим вероятность выбора каждой (хромосомы). Но решение заключается в том, чтобы взять сумму обратных значений коэффициентов, и исходя из этого вычислять проценты. (P.S. 0.135266 — сумма обратных коэффициентов)
  1. (1/84)/0.135266 = 8.80%
  2. (1/24)/0.135266 = 30.8%
  3. (1/26)/0.135266 = 28.4%
  4. (1/133)/0.135266 = 5.56%
  5. (1/28)/0.135266 = 26.4%

Далее будем выбирать пять пар родителей, у которых будет ровно по одному ребенку. Давать волю случаю мы будем давать ровно пять раз, каждый раз шанс стать родителем будет одинаковым и будет равен шансу на выживание.
3-1, 5-2, 3-5, 2-5, 5-3
Как было сказано ранее, потомок содержит информацию о генах отца и матери. Это можно обеспечить различными способами, но в данном случае будет использоваться «кроссовер». (| = разделительная линия)
  • Х.-отец: a1 | b1,c1,d1 Х.-мать: a2 | b2,c2,d2 Х.-потомок: a1,b2,c2,d2 or a2,b1,c1,d1
  • Х.-отец: a1,b1 | c1,d1 Х.-мать: a2,b2 | c2,d2 Х.-потомок: a1,b1,c2,d2 or a2,b2,c1,d1
  • Х.-отец: a1,b1,c1 | d1 Х.-мать: a2,b2,c2 | d2 Х.-потомок: a1,b1,c1,d2 or a2,b2,c2,d1

Есть очень много путей передачи информации потомку, а кросс-овер — только один из множества. Расположение разделителя может быть абсолютно произвольным, как и то, отец или мать будут слева от черты.
А теперь сделаем тоже самое с потомками:
  • Х.-отец: (13 | 5,7,3) Х.-мать: (1 | 28,15,3) Х.-потомок: (13,28,15,3)
  • Х.-отец: (9,13 | 5,2) Х.-мать: (14,9 | 2,4) Х.-потомок: (9,13,2,4)
  • Х.-отец: (13,5,7 | 3) Х.-мать: (9,13,5 | 2) Х.-потомок: (13,5,7,2)
  • Х.-отец: (14 | 9,2,4) Х.-мать: (9 | 13,5,2) Х.-потомок: (14,13,5,2)
  • Х.-отец: (13,5 | 7, 3) Х.-мать: (9,13 | 5, 2) Х.-потомок: (13,5,5,2)

Теперь вычислим коэффициенты выживаемости потомков.
  • (13,28,15,3) — |126-30|=96(9,13,2,4) — |57-30|=27
    (13,5,7,2) — |57-30|=22
    (14,13,5,2) — |63-30|=33
    (13,5,5,2) — |46-30|=16

    Печально так как средняя приспособленность (fitness) потомков оказалась 38.8, а у родителей этот коэффициент равнялся 59.4. Именно в этот момент целесообразнее использовать мутацию, для этого заменим один или более значений на случайное число от 1 до 30.
    Алгоритм будет работать до тех, пор, пока коэффициент выживаемости не будет равен нулю. Т.е. будет решением уравнения.
    Системы с большей популяцией (например, 50 вместо 5-и сходятся к желаемому уровню (0) более быстро и стабильно.

    Код


    На этом простота заканчивается и начинается чудесный C++...
    Класс на C++ требует 5 значений при инициализации: 4 коэффициента и результат. Для вышепривиденного примера это будет выглядеть так: CDiophantine dp(1,2,3,4,30);

    Затем, чтобы решить уравнение, вызовите функцию Solve(), которая возвратит аллель, содержащую решение. Вызовите GetGene(), чтобы получить ген с правильными значениями a, b, c, d. Стандартная процедура main.cpp, использующая этот класс, может быть такой:

    #include "<iostream.h>"
    #include "diophantine.h"
    
    void main() {
    
       CDiophantine dp(1,2,3,4,30);
    
       int ans;
       ans = dp.Solve();
       if (ans == -1) {
          cout << "No solution found." << endl;
       } else {
          gene gn = dp.GetGene(ans);
    
          cout << "The solution set to a+2b+3c+4d=30 is:\n";
          cout << "a = " << gn.alleles[0] << "." << endl;
          cout << "b = " << gn.alleles[1] << "." << endl;
          cout << "c = " << gn.alleles[2] << "." << endl;
          cout << "d = " << gn.alleles[3] << "." << endl;
       }
    }
    


    Сам класс CDiophantine:

    #include <stdlib.h>
    #include <time.h>
    
    #define MAXPOP	25
    
    struct gene {
    	int alleles[4];
    	int fitness;
    	float likelihood;
    
    	// Test for equality.
    	operator==(gene gn) {
    		for (int i=0;i<4;i++) {
    			if (gn.alleles[i] != alleles[i]) return false;
    		}
    
    		return true;
    	}
    };
    
    class CDiophantine {
    	public:
    		CDiophantine(int, int, int, int, int);// Constructor with coefficients for a,b,c,d.
    		int Solve();// Solve the equation.
    		
    		// Returns a given gene.
    		gene GetGene(int i) { return population[i];}
    
    	protected:
    		int ca,cb,cc,cd;// The coefficients.
    		int result;
    		gene population[MAXPOP];// Population.
    
    		int Fitness(gene &);// Fitness function.
    		void GenerateLikelihoods();	// Generate likelihoods.
    		float MultInv();// Creates the multiplicative inverse.
    		int CreateFitnesses();
    		void CreateNewPopulation();
    		int GetIndex(float val);
    
    		gene Breed(int p1, int p2);
    
    };
    
    CDiophantine::CDiophantine(int a, int b, int c, int d, int res) : ca(a), cb(b), cc(c), cd(d), result(res) {}
    
    int CDiophantine::Solve() {
    	int fitness = -1;
    
    	// Generate initial population.
    	srand((unsigned)time(NULL));
    
    	for(int i=0;i<MAXPOP;i++) {// Fill the population with numbers between
    		for (int j=0;j<4;j++) {// 0 and the result.
    			population[i].alleles[j] = rand() % (result + 1);
    		}
    	}
    
    	if (fitness = CreateFitnesses()) {
    		return fitness;
    	}
    
    	int iterations = 0;// Keep record of the iterations.
    	while (fitness != 0 || iterations < 50) {// Repeat until solution found, or over 50 iterations.
    		GenerateLikelihoods();// Create the likelihoods.
    		CreateNewPopulation();
    		if (fitness = CreateFitnesses()) {
    			return fitness;
    		}
    		
    		iterations++;
    	}
    
    	return -1;
    }
    
    int CDiophantine::Fitness(gene &gn) {
    	int total = ca * gn.alleles[0] + cb * gn.alleles[1] + cc * gn.alleles[2] + cd * gn.alleles[3];
    
    	return gn.fitness = abs(total - result);
    }
    
    int CDiophantine::CreateFitnesses() {
    	float avgfit = 0;
    	int fitness = 0;
    	for(int i=0;i<MAXPOP;i++) {
    		fitness = Fitness(population[i]);
    		avgfit += fitness;
    		if (fitness == 0) {
    			return i;
    		}
    	}
    	
    	return 0;
    }
    
    float CDiophantine::MultInv() {
    	float sum = 0;
    
    	for(int i=0;i<MAXPOP;i++) {
    		sum += 1/((float)population[i].fitness);
    	}
    
    	return sum;
    }
    
    void CDiophantine::GenerateLikelihoods() {
    	float multinv = MultInv();
    
    	float last = 0;
    	for(int i=0;i<MAXPOP;i++) {
    		population[i].likelihood = last = last + ((1/((float)population[i].fitness) / multinv) * 100);
    	}
    }
    
    int CDiophantine::GetIndex(float val) {
    	float last = 0;
    	for(int i=0;i<MAXPOP;i++) {
    		if (last <= val && val <= population[i].likelihood) return i;
    		else last = population[i].likelihood;
    	}
    	
    	return 4;
    }
    
    gene CDiophantine::Breed(int p1, int p2) {
    	int crossover = rand() % 3+1;// Create the crossover point (not first).
    	int first = rand() % 100;// Which parent comes first?
    
    	gene child = population[p1];// Child is all first parent initially.
    
    	int initial = 0, final = 3;// The crossover boundaries.
    	if (first < 50) initial = crossover;	// If first parent first. start from crossover.
    	else final = crossover+1;// Else end at crossover.
    
    	for(int i=initial;i<final;i++) {// Crossover!
    		child.alleles[i] = population[p2].alleles[i];
    		if (rand() % 101 < 5) child.alleles[i] = rand() % (result + 1);
    	}
    
    	return child;// Return the kid...
    }
    
    void CDiophantine::CreateNewPopulation() {
    	gene temppop[MAXPOP];
    
    	for(int i=0;i<MAXPOP;i++) {
    		int parent1 = 0, parent2 = 0, iterations = 0;
    		while(parent1 == parent2 || population[parent1] == population[parent2]) {
    			parent1 = GetIndex((float)(rand() % 101));
    			parent2 = GetIndex((float)(rand() % 101));
    			if (++iterations > 25) break;
    		}
    		
    		temppop[i] = Breed(parent1, parent2);// Create a child.
    	}
    
    	for(i=0;i<MAXPOP;i++) population[i] = temppop[i];
    }
    
    


    Статья основана на материалах Википедии и сайта algolist.manual.ru.
+36
46023
224
bos4r 10,0

Комментарии (40)

+2
GavriKos, #
В общем случае генетический алгоритм — это оптимизация полного перебора, поэтому теоретически он применим ко всем задачам, которые можно решить полным перебором.
0
klakhman, #
Генетический алгоритм даже в общем случае не является оптимизацией полного перебора — слишком уж огромная разница в эффективности и в методах реализации. Мутации и селекция — сильнейшие механизмы, которые позволяют осуществлять «направленный» (в кавычках) поиск в пространстве признаков.
+5
GavriKos, #
Хотелось бы в статье видеть не только конкретный пример (который, как по мне, не самый удачный), а и обобщение методов кроссовера, обоснования выбора способа расчета выживаемости и т.д.
+4
Osmin, #
И подробнее про сходимость алгоритма. Ведь это одна из главных проблем.
+44
Maxkn, #
Скажите, а ваша жена поняла это объяснение?:)

Кому интересно, наглядная демонстрация генетических алгоритмов. Часами можно смотреть.

megaswf.com/serve/1031310/

+1
AlexeyTokar, #
оооо. Это шикарно!
+7
Edro, #
Надо отправить это чувакам из Тольятти. :)
0
Maxkn, #
им надо руководство нормальное отправить. хотя они тут же взвоют и скажут, что их права ущемляют и заставляют работать :)
0
Akson87, #
По-моему у них уже есть… очень уж результаты похожи:)))
0
vak, #
на питоне бы этот велосипед поиметь с физикой
0
Maxkn, #
для чего, если не секрет?
0
vak, #
1. Питон очень выразительный язык.
2. у меня уже много лет своё видение GA/EA, имеющее принципиальные отличия от mainstream взглядов
3. визуальные игрушки вроде этой, делают результаты наглядными ещё до того, как понятно внутреннее устройство алгоритмов.
0
Maxkn, #
ну имея свое видение и знание питона, за пару месяцев реально это сделать. учитывая, что там места для улучшательств вагон
0
irishrover, #
См pyevolve (http://pyevolve.sourceforge.net/).
0
GD666, #
Вот сайт с оригиналом этой игры www.boxcar2d.com/
Там же есть собственный редактор, форум и возможность попробовать свои алгоритмы.
+8
GreatRash, #
> 0.135266

А это что такое?
0
guyfawkes, #
Как я понял, это
1)
|114-30|=84
|54-30|=24
|56-30|=26
|163-30|=133
|58-30|=28
2) 1/84+1/24+1/26+1/133+1/28
3) Понял примерно по вот этому фрагменту:
...
float multinv = MultInv();
....
float CDiophantine::MultInv() {
float sum = 0;

for(int i=0;i<MAXPOP;i++) {
sum += 1/((float)population[i].fitness);
}

return sum;
}


4) Но не понял, почему, что, зачем и почему именно так это высчитывается :) Может кто пояснить?
0
ramshteks, #
Вообще автор упустил такой момент, как функция ранжирования (fitness-функция или «приспособленность»). Не то чтобы упустил, не описал должным образом, хотя эта функция пожалуй является ключевой в этом алгоритме.

Так вот это по всей видимости и есть ее расчет
+1
sergeypid, #
Самое главное — расчет по пункту 1. Чем меньше число, тем ближе результат к желаемому значению. Генетический алгоритм случайно перебирает варианты и закрепляет в следующих поколениях те, что ближе к идеалу, то есть к желаемому значению. Достаточно рассчитать пункт 1, и затем выбрать для следующего поколения те варианты, которые ближе всего к идеалу. Но так решение не получишь — нужно мутировать и видоизменять варианты решения. Поэтому остальные расчеты дают вероятность, с которой родители каждого типа дадут потомство. То есть папа с мамой, наиболее близкие к идеалу, дадут больше потомков (мутантов, но все равно унаследуют часть признаков от папы и мамы). Наихудшие особи сдохнут, но есть маленькая вероятность, что некоторые все-таки спарятся и дадут потомство — жутких выродков. Но это необходимо, т.к. у выродков случайно может появиться хороший ген (генами в данном случае являются просто числа — решения уравнения).

Кстати, выбирать числа — решения уравнений или коэффициенты или другие константы — методом генерации случайной функции ОЧЕНЬ плохо. Вероятность подобрать нужное число низкая (поэтому в примере взято уравнение с областью решений — целыми числами от 0 до 30). В генетических алгоритмах и числа надо кодировать генетически, и для этого двоичное представление идеально. Тогда мутация будет не в виде random(), а в виде случайной смены 0 на 1 или 1 на 0 в случайно выбранном разряде. Число требуемых вариантов для перебора, по теории оптимального эксперимента, будет пропорционально логарифму от числа вариантов случайного перебора.
0
guyfawkes, #
Ну общий принцип в словах-то ясен, но почему надо именно делить (причем не саму «близость», а 1/близость) на сумму таких же разделений?
0
uxIg, #
Потому что нам нужны именно обратные коэфициенты — чем больше расстояние, тем процент выживаемости меньше, и наоборот. А если делить например, 84 / (84+24+26+133+28) то это получается прямая пропорциональность — чем больше расстояние, тем процент выживаемости больше, но нам не это нужно. Ну а делить нужно для того, чтобы определить в процентном соотношении, насколько близок данный результат от желаемого.
+12
HomoLuden, #
Считаю тут будет уместно представить список постов на хабре:
Генетический алгоритм: боремся с преждевременной сходимостью
Генетический алгоритм: оптимальный размер популяции
Генетический алгоритм на примере бота Robocode
Выбор размера популяции для генетического алгоритма
Генетические алгоритмы в MATLAB
Генетические алгоритмы для поиска решений и генерации
Концепции практического использования генетических алгоритмов
Что такое генетический алгоритм?
ТАУ-Дарвинизм
Обзор методов эволюции нейронных сетей
ТАУ-Дарвинизм: реализация на Ruby
Применение эволюционных стратегий для идентификации параметров нечетких систем
Вариант синхронной импульсной нейронной сети с обратными связями
«Живые графы» — выращивание графов на клеточных автоматах с примерами на Silverlight
Метод имитации отжига — как альтернатива
habrahabr.ru/blogs/personal/18844/
0
smbd, #
Хорошо, но просто — благо и задача несложная, что в реальном мире бывает редко…
+8
jov, #
Уравнения с целочисленными корнями… Вам наверно уже очевидно, что корни данного уравнения лежат на отрезке [1;30]

Лично мне это совсем не очевидно. Z = {...,-2, -1, 0, 1, 2, ...}
+2
ramshteks, #
>Поскольку алгоритм самообучающийся

Мне одному показалась эта фраза несколько неверной?
Это алгоритм не управления, а оптимизации.
Причем это модификация простейшего алгоритма случайного поиска.
Алгоритм не в коем случае не самообучающийся.
Сходимость зависит от той задачи которую вы пытаетесь решить и от ее размерности.

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

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

Автору так же думаю стоило упомянуть о том, что существует множество методов мутации генов и стратегий селекции.

А так конечно же спасибо за статью
+1
HomoLuden, #
> Мне одному показалась эта фраза несколько неверной?
Тоже сразу хотел написать. Этот алгоритм не самообучающийся. Применительно к некоторому классу задач (например Нейросети) он сам является учителем.

> Если использовать этот алгоритм использовать для решения системы двух линейных уравнений или к примеру восстановления исходной строчки
А если использовать его в обработке сигналов для оптимизации нейросети хотя бы с 50-100 вещественных весовых коэффициентов, то пространство поиска разростается так, что и сходимость становится очень непредсказуемой и минимальный размер популяции становится слишком большим.
+3
Jabberwok, #
Не понял почему алгоритм новомодный если ему больше 35 лет?
0
mihaild, #
Потому что модным он стал сравнительно недавно.
+2
HomoLuden, #
В науке вообще все небыстро…
+2
leron, #
«про новомодные алгоритмы, такие как нейронные сети»

Вы издеваетесь? Эти алгоритмы успели уже много раз появится, стать взрывобразно популярными, забыться и заново возродится.
0
AlexChudd, #
Автозамена сработала: cc(​c​) на cc©© заменился.
+4
sergeypid, #
Есть разновидность генетических алгоритмов — генетическое программирование. Очень понятно описано в захабренной книге «Программируем коллективный разум». Суть в том, что программы представляются в виде деревьев, и мутируют деревья.

Я сейчас веду проект в области поиска мошеннических схем в потребительском кредитовании, и попробовал применить эту методику на практике. Мутируемым решил сделать SQL. То есть представляем запрос типа select * where x>10 and y<=23 and z=x в виде дерева select * where (((x>10) and y<=23) and z=x) и понеслась. В конце концов, это те же деревья решений, столь любимые работниками департаментов риск-менеджмента.

Если интересно, постараюсь выбрать время и написать полноценную статью. Алгоритм сходится неплохо на выборках свыше 1000 примеров при правильном выборе целевой функции (то, что автор поста назвал коэффициентом выживаемости). Скажем, до 200 поколений. Конечно, при условии, что в обучающей выбоке действительно есть паттерн, который можно отловить деревом решения.
–1
Chvanikoff, #
Вот если бы с каждым новым уравнением время решения сокращалось (засчет опыта предыдущих решений) — тогда было бы здорово. А так… Брутфорс…
0
ramshteks, #
это не в коем случае не брутфорс. Представьте себе 100мерное пространство признаков. Долго вы брутфорсить будете. В то время как генетические алгоритмы позволяют найти результат за более приемлемое время.
Причем учтите еще тот факт, что зачастую каждый признак это не дискретная величина с ограниченным диапазоном значений, а непрерывная величина в заранее неизвестных пределах. Так же существуют задачи у которых решение может быть не одно. При определенных модификациях ГА могут найти их.
0
HomoLuden, #
> Причем учтите еще тот факт, что зачастую каждый признак это не дискретная величина с ограниченным диапазоном значений, а непрерывная величина в заранее неизвестных пределах.

Во-первых, изначально (в классической постановке) ГА оптимизируют именно дискретные параметры (нуклеотид, как прообраз, имеет четыре значения А-Ц-Т-Г). Во-вторых, любой алгоритм, реализованный на ЭВМ работает с дискретными данными.
Но мысль ваша в принципе верна. 100 параметров даже в виде вещественного числа одинарной точности это (2^32)^100 пространство поиска. Если я ошибся с комбинаторикой — поправьте. Но пространство поиска действительно ООООЧЕНЬ огромное для простого брутфорса.
0
ramshteks, #
про дискретность я имел ввиду такую задачу брутфорса как скажем перебор паролей или к примеру всех карточных комбинаций. Или восстановление исходной строчки.
0
HomoLuden, #
Я понял вашу мысль. Для корректности нужно было уточнить, что работа с вещественными значениями — это по сути та же оптимизация дискретных значений, только с много бОльшим количеством значений каждого параметра.
0
NetBUG, #
Для гладких функций можно снижать пространство поиска.
0
enkryptor, #
«Если вы не можете объяснить свою теорию своей жене, ваша теория ничего не стоит» — возможно, имелась в виду цитата Эйнштейна «Если вы что-то не можете объяснить 6-летнему ребёнку, вы сами этого не понимаете». Есть ещё похожие слова, приписываемые Курту Воннегуту: «Плох тот учёный, который не может объяснить суть своей теории пятилетнему ребёнку доступными для него словами». Вариант с женой какой-то неполиткорректный :-)
0
HomoLuden, #
Особенно в случаях, когда жена докторскую защитила, а ты и до кандидата не дотянул.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.