blob: 72b6f341af23342cfefa3171dc9a56821ad28fe4 [file]
#-------------------------------------------------------------
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#-------------------------------------------------------------
# This script runs latin hypercube sampling.
#
# INPUT:
# ------------------------------------------------------------------------------
# N The number of samples to be taken
# d The number of dimension
# method The method to be used for sampling, possible values are:
# - "random" - random sampling
# - "build" - build the sample iteratively
# - "cp_sweep" - iterative columnwise pairwise swapping
# - "genetic" - genetic algorithm
# reps The number of repetitions for each method:
# - for "random" method, the best sample is chosen from reps random samples
# - for "build" method, reps candidates are generated for each point and the best candidate is chosen
# - for "cp_sweep" method, reps sweeps are performed for each column
# - for "genetic" method, reps candidates are generated in the initial design
# goal The goal to be minimized during the sampling procedure, possible values are:
# - "maximin" - maximize the minimum distance between points (in practice the negative minimum distance is minimized)
# - "opt" - minimize the difference between the minimum distance and the optimal distance N/(N^(1/d))
# - "sum_inv" - minimize the sum of the inverse squared distances between points
# - "avg_dist" - maximize avg euclidean distance between points (in practice the negative is minimized)
# eps The threshold for stopping the cp_sweep method
# genetic_generations Number of generations ("genetic" method)
# genetic_population_size Size of the population ("genetic" method)
# genetic_mutation_rate Mutation rate, between 0 and 1 ("genetic" method)
# return_type How should the matrix be returned:
# - "buckets": return the matrix with bucket ids
# - "numerical": return the matrix as vectors sampled from the d-dimensional uniform distribution in range 0-1
# seed The seed for making results reproducible. Defaults to -1, which creates random seeds.
# ------------------------------------------------------------------------------
#
# OUTPUT:
# ------------------------------------------------------------------------------
# M A N x d matrix M
# G Value of the given objective
# ------------------------------------------------------------------------------
lhs = function(
Integer N = 10,
Integer d = 2,
String method = "random",
Integer reps = 1,
String goal = "maximin",
Double eps = 0.001,
Integer genetic_generations = 5,
Integer genetic_population_size = 10,
Double genetic_mutation_rate = 0.25,
String return_type = "buckets",
Integer seed = -1
)
return (Matrix[Double] M, Double G) {
if (goal != "maximin" & goal != "opt" & goal != "sum_inv" & goal != "avg_dist") {
stop("Goal should be one of: maximin, opt, sum_inv, avg_dist")
}
if (return_type != "buckets" & return_type != "numerical") {
stop("Return type should be one of: buckets, numerical")
}
if (N < 2) {
stop("N should be a positive integer larger than 1")
}
if (d < 1) {
stop("d should be a positive integer")
}
if (reps < 1) {
stop("reps should be a positive integer")
}
if (method == "genetic" & genetic_generations < 1) {
stop("genetic_generations should be >= 1")
}
if (method == "genetic" & genetic_population_size < 2) {
stop("genetic_population_size should be >= 2")
}
if (method == "genetic" & (genetic_mutation_rate < 0 | genetic_mutation_rate > 1)) {
stop("genetic_mutation_rate should be between 0 and 1")
}
if (method == "random") {
M = randomLHS(N, d, reps, goal, seed)
}
else if (method == "build") {
M = buildLHS(N, d, reps, goal, seed)
}
else if (method == "cp_sweep") {
M = cpSweepLHS(N, d, reps, goal, eps, seed)
}
# Reference: https://bertcarnell.github.io/lhs/reference/geneticLHS.html
else if (method == "genetic") {
M = geneticLHS(N, d, reps, goal, genetic_generations, genetic_population_size, genetic_mutation_rate, seed)
}
else {
stop("Method should be one of: random, build, cp_sweep, genetic")
}
G = evaluateGoalFullMatrix(M,goal)
if (return_type == "numerical") {
r = rand(rows = N, cols = d, min = 0, max = 1 / N, seed = seed)
M = M / N - r
}
}
randomLHS = function(Integer N, Integer d, Integer reps, String goal, Integer seed)
return (Matrix[Double] M) {
G = 1 / 0
M = matrix(0, rows = N, cols = d)
for (i in 1:reps) {
newM = matrix(0, rows = N, cols = d)
for (j in 1:d) {
newM[, j] = sample(N, N, deriveSeed(seed, (i - 1) * d + j))
}
newG = evaluateGoalFullMatrix(newM, goal)
if (newG < G) {
M = newM
G = newG
}
}
}
buildLHS = function(Integer N, Integer d, Integer reps, String goal, Integer seed)
return (Matrix[Double] M) {
# available buckets per column
free_buckets = seq(1, N) %*% matrix(1, rows = 1, cols = d)
# generate first point at random
first_sample = sample(N, d, TRUE, deriveSeed(seed, 1))
M = t(first_sample)
# replace used ints for each column, with last sample
# in subsequent runs we only look at the first N-1,N-2,... rows
replacement_mask = table(first_sample, seq(1, d), N, d)
free_buckets = (1 - replacement_mask) * free_buckets + (matrix(1, rows = N, cols = 1) %*% (free_buckets[N, ])) * replacement_mask
# for the next N - 2 points:
# generate reps candidates
# choose the candidate with the lowest G
# and update the free_buckets matrix
if (N>2){
for (i in 2:(N - 1)) {
# number of buckets left to choose from
num_free_buckets = N - i + 1
# initialize G to infinity
G = 1 / 0
# initialize row vector and replacement mask for free buckets
row = matrix(0, rows = 1, cols = d)
replacement_mask = matrix(0, rows = num_free_buckets, cols = d)
for (j in 1:reps) {
# random mask for bucket generation
rand = table(sample(num_free_buckets, d, TRUE, deriveSeed(seed, i * reps + j)), seq(1, d), N, d)
# get buckets and flatten to row vector
vals_matrix = rand * free_buckets
row_candidate = matrix(1, rows = 1, cols = N) %*% vals_matrix
# check if the candidate is better then the current best
# according to the goal and update the row vector and replacement mask accordingly
newG = evaluateGoalNewRow(M, row_candidate, goal, N)
if (newG < G) {
row = row_candidate
G = newG
replacement_mask = rand
}
}
# add the best candidate to the matrix of chosen points
M = rbind(M, row)
# replace chosen buckets with last, not yet picked buckets
free_buckets = (1 - replacement_mask) * free_buckets + (matrix(1, rows = N, cols = 1) %*% (free_buckets[num_free_buckets, ])) * replacement_mask
}
}
# for the last point there is only one bucket left
M = rbind(M, free_buckets[1, ])
}
cpSweepLHS = function(Integer N, Integer d, Integer reps, String goal, Double eps, Integer seed)
return (Matrix[Double] M) {
# generate initial random latin hypercube
M = matrix(0, rows = N, cols = d)
for (i in 1:d) {
M[, i] = sample(N, N, deriveSeed(seed, i))
}
G = evaluateGoalFullMatrix(M, goal)
pred = TRUE
iter = 0
while (pred) {
iter = iter + 1
if (iter >= reps) {
pred = FALSE
}
oldG = G
# iterate over all columns and swap all pairs of rows in each column
for (j in 1:d) {
# initialize new goal vector, for easier calculation we flatten the 2D matrix to a 1D vector
# where the index of the new goal vector corresponds to the row pair (k,l) with k < l
# so the 1D vector index = l + (k-1)*N
new_goal_vector = matrix(1/0, rows = 1, cols = N * (N - 1))
for (k in 1:(N - 1)) {
for (l in (k + 1):(N)) {
# add new goal to the goal vector according to the goal
newM = swap(M, j, k, l)
new_goal_vector[1, l + (k - 1) * N] = evaluateGoalFullMatrix(newM, goal)
}
}
# if there was an improvement, swap the rows with the best improvement
if (min(new_goal_vector) < G) {
# calculate k and l from the index of the goal vector
idx = as.scalar(rowIndexMin(new_goal_vector))
row1 = ((idx - 1) %/% N) + 1
row2 = ((idx - 1) %% N) + 1
M = swap(M, j, row1, row2)
G = min(new_goal_vector)
}
}
# stop if no significant improvement was made, otherwise update G
if (abs(oldG - G) < eps) {
pred = FALSE
}
}
}
geneticLHS = function(Integer N, Integer d, Integer reps, String goal, Integer genetic_generations, Integer genetic_population_size, Double genetic_mutation_rate, Integer seed)
return (Matrix[Double] M) {
population = matrix(0, rows = genetic_population_size * N, cols = d)
scores = matrix(0, rows = genetic_population_size, cols = 1)
population_seed_offsets = genetic_population_size * reps * d
# 1. Generate initial random Latin Hypercube designs and store them all in the population matrix
for (p in 1:genetic_population_size) {
design = lhs(N = N, d = d, method = "random", reps = reps, goal = goal, seed = deriveSeed(seed, (p - 1) * reps * d))
population_start = designStart(p, N)
population_end = designEnd(p, N)
population[population_start:population_end, ] = design
# 2. Calculate the score for each design according to the goal
scores[p, 1] = evaluateGoalFullMatrix(design, goal)
}
for (generation in 1:genetic_generations) {
# 3. Put the best design in the first position of survivors and throw away the worst half of the remaining population
survivors_cutoff = floor(genetic_population_size / 2) + 1
survivors = matrix(0, rows = survivors_cutoff * N, cols = d)
for (survivor in 1:survivors_cutoff) {
current_best_index = as.scalar(rowIndexMin(t(scores)))
current_best_design = getDesignByIndex(population, current_best_index, N)
# Put the current best design in the survivors matrix, then set its score to +inf (worst) so that it is not selected again
survivors_start = designStart(survivor, N)
survivors_end = designEnd(survivor, N)
survivors[survivors_start:survivors_end, ] = current_best_design
scores[current_best_index, 1] = 1 / 0
}
# 4. Create new designs from the survivors
new_population = matrix(0, rows = genetic_population_size * N, cols = d)
best_design = survivors[1:N, ]
new_population[1:N, ] = best_design
next_free_slot = 2
# 4.1: use each other survivor as base and transplant a column from the best design
for (survivor_index in 2:survivors_cutoff) {
child_base = population_seed_offsets + ((generation - 1) * genetic_population_size + next_free_slot) * 5
survivor_design = getDesignByIndex(survivors, survivor_index, N)
new_design = makeGeneticChild(survivor_design, best_design, genetic_mutation_rate, deriveSeed(seed, child_base))
# Store the generated child design in the new population
new_population_start = designStart(next_free_slot, N)
new_population_end = designEnd(next_free_slot, N)
new_population[new_population_start:new_population_end, ] = new_design
next_free_slot = next_free_slot + 1
}
# 4.2: for the empty positions, use the best design as base and transplant a column from a survivor
for (survivor_index in 2:survivors_cutoff) {
if (next_free_slot <= genetic_population_size) {
child_base = population_seed_offsets + ((generation - 1) * genetic_population_size + next_free_slot) * 5
survivor_design = getDesignByIndex(survivors, survivor_index, N)
new_design = makeGeneticChild(best_design, survivor_design, genetic_mutation_rate, deriveSeed(seed, child_base))
# Store the generated child design in the new population
new_population_start = designStart(next_free_slot, N)
new_population_end = designEnd(next_free_slot, N)
new_population[new_population_start:new_population_end, ] = new_design
next_free_slot = next_free_slot + 1
}
}
population = new_population
# Score the new population according to the goal
for (p in 1:genetic_population_size) {
design = getDesignByIndex(population, p, N)
scores[p, 1] = evaluateGoalFullMatrix(design, goal)
}
}
# Return the best design from the final population
best_index = as.scalar(rowIndexMin(t(scores)))
M = getDesignByIndex(population, best_index, N)
}
minLhsDistance = function(Matrix[Double] M)
return (Double min_dist) {
D = dist(M) + diag(matrix(1 / 0, rows = nrow(M), cols = 1))
min_dist = min(D)
}
swap = function(Matrix[Double] M, Integer col, Integer row1, Integer row2)
return (Matrix[Double] M) {
temp = M[row1, col]
M[row1, col] = M[row2, col]
M[row2, col] = temp
}
avgDistance = function(Matrix[Double] M)
return (Double score) {
D = dist(M)
score = sum(D) / (nrow(M) * (nrow(M) - 1))
}
# Returns seed base + offset, but keeps the -1 (random) seed unchanged.
deriveSeed = function(Integer seed, Integer offset)
return (Integer s) {
s = ifelse(seed == -1, -1, seed + offset)
}
designStart = function(Integer index, Integer N)
return (Integer start) {
start = (index - 1) * N + 1
}
designEnd = function(Integer index, Integer N)
return (Integer end) {
end = index * N
}
getDesignByIndex = function(Matrix[Double] population, Integer index, Integer N)
return (Matrix[Double] design) {
start = designStart(index, N)
end = designEnd(index, N)
design = population[start:end, ]
}
# Creates a child design by transplanting a random column from a donor design
# into the base design and attempting a mutation.
makeGeneticChild = function(Matrix[Double] base, Matrix[Double] donor, Double mutation_rate, Integer child_seed)
return (Matrix[Double] child) {
source_col = as.scalar(sample(ncol(base), 1, deriveSeed(child_seed, 1)))
target_col = as.scalar(sample(ncol(base), 1, deriveSeed(child_seed, 2)))
child = base
child[, target_col] = donor[, source_col]
# Attempt to mutate the child design by switching two elements in a random column.
child = attemptGeneticMutation(child, mutation_rate, deriveSeed(child_seed, 3))
}
attemptGeneticMutation = function(Matrix[Double] design, Double mutation_rate, Integer seed)
return (Matrix[Double] design) {
if (as.scalar(rand(rows = 1, cols = 1, min = 0, max = 1, seed = seed)) < mutation_rate) {
column_to_mutate = as.scalar(sample(ncol(design), 1, deriveSeed(seed, 1)))
rows_to_mutate = sample(nrow(design), 2, deriveSeed(seed, 2))
row1 = as.scalar(rows_to_mutate[1, 1])
row2 = as.scalar(rows_to_mutate[2, 1])
design = swap(design, column_to_mutate, row1, row2)
}
}
sumInvDistance = function(Matrix[Double] M)
return (Double sum_inv) {
row_sq = rowSums(M ^ 2)
ones = matrix(1, rows=nrow(M), cols=1)
squared_dist = (row_sq %*% t(ones)) + (ones %*% t(row_sq)) - (2 * M %*% t(M))
inv_dist = 1 / lower.tri(target = squared_dist, diag = FALSE, values = TRUE)
inv_dist = replace(target = inv_dist, pattern = 1 / 0, replacement = 0)
sum_inv = sum(inv_dist)
}
evaluateGoalFullMatrix = function(Matrix[Double] M, String goal)
return (Double G) {
if (goal == "maximin") {
G = -minLhsDistance(M)
}
else if (goal == "opt") {
N = nrow(M)
d = ncol(M)
opt = N / (N^(1 / d))
G = abs(opt - minLhsDistance(M))
}
else if (goal == "sum_inv") {
G = sumInvDistance(M)
}
else if (goal == "avg_dist") {
G = -avgDistance(M)
}
}
evaluateGoalNewRow = function (Matrix[Double] M, Matrix[Double] row, String goal, Integer N)
return(Double G){
if(goal == "maximin"){
# sqrt not needed
G = -min(rowSums((M - (matrix(1, rows = nrow(M), cols = 1) %*% row))^2))
}
else if(goal == "opt"){
d = ncol(row)
opt = (N/(N^(1/d)))
# sqrt is necessary here
G = abs(opt - sqrt(min(rowSums((M - (matrix(1, rows = nrow(M), cols = 1) %*% row))^2))))
}
else if(goal == "sum_inv"){
G = sumInvDistance(rbind(M,row))
}
else if(goal == "avg_dist"){
G = -avgDistance(rbind(M,row))
}
}