Files
2026-07-13 13:22:52 +08:00

429 lines
14 KiB
Python

import copy
import itertools
import os
import pickle
import random
import subprocess
import sys
import time
from multiprocessing import Pool
from .. import __version__, datasets
from . import metrics, models
try:
from queue import Queue
except ImportError:
from Queue import Queue
from threading import Lock, Thread
regression_metrics = [
"local_accuracy",
"consistency_guarantees",
"keep_positive_mask",
"keep_positive_resample",
# "keep_positive_impute",
"keep_negative_mask",
"keep_negative_resample",
# "keep_negative_impute",
"keep_absolute_mask__r2",
"keep_absolute_resample__r2",
# "keep_absolute_impute__r2",
"remove_positive_mask",
"remove_positive_resample",
# "remove_positive_impute",
"remove_negative_mask",
"remove_negative_resample",
# "remove_negative_impute",
"remove_absolute_mask__r2",
"remove_absolute_resample__r2",
# "remove_absolute_impute__r2"
"runtime",
]
binary_classification_metrics = [
"local_accuracy",
"consistency_guarantees",
"keep_positive_mask",
"keep_positive_resample",
# "keep_positive_impute",
"keep_negative_mask",
"keep_negative_resample",
# "keep_negative_impute",
"keep_absolute_mask__roc_auc",
"keep_absolute_resample__roc_auc",
# "keep_absolute_impute__roc_auc",
"remove_positive_mask",
"remove_positive_resample",
# "remove_positive_impute",
"remove_negative_mask",
"remove_negative_resample",
# "remove_negative_impute",
"remove_absolute_mask__roc_auc",
"remove_absolute_resample__roc_auc",
# "remove_absolute_impute__roc_auc"
"runtime",
]
human_metrics = [
"human_and_00",
"human_and_01",
"human_and_11",
"human_or_00",
"human_or_01",
"human_or_11",
"human_xor_00",
"human_xor_01",
"human_xor_11",
"human_sum_00",
"human_sum_01",
"human_sum_11",
]
linear_regress_methods = [
"linear_shap_corr",
"linear_shap_ind",
"coef",
"random",
"kernel_shap_1000_meanref",
# "kernel_shap_100_meanref",
# "sampling_shap_10000",
"sampling_shap_1000",
"lime_tabular_regression_1000",
# "sampling_shap_100"
]
linear_classify_methods = [
# NEED LIME
"linear_shap_corr",
"linear_shap_ind",
"coef",
"random",
"kernel_shap_1000_meanref",
# "kernel_shap_100_meanref",
# "sampling_shap_10000",
"sampling_shap_1000",
# "lime_tabular_regression_1000"
# "sampling_shap_100"
]
tree_regress_methods = [
# NEED tree_shap_ind
# NEED split_count?
"tree_shap_tree_path_dependent",
"tree_shap_independent_200",
"saabas",
"random",
"tree_gain",
"kernel_shap_1000_meanref",
"mean_abs_tree_shap",
# "kernel_shap_100_meanref",
# "sampling_shap_10000",
"sampling_shap_1000",
"lime_tabular_regression_1000",
"maple",
# "sampling_shap_100"
]
rf_regress_methods = [ # methods that only support random forest models
"tree_maple"
]
tree_classify_methods = [
# NEED tree_shap_ind
# NEED split_count?
"tree_shap_tree_path_dependent",
"tree_shap_independent_200",
"saabas",
"random",
"tree_gain",
"kernel_shap_1000_meanref",
"mean_abs_tree_shap",
# "kernel_shap_100_meanref",
# "sampling_shap_10000",
"sampling_shap_1000",
"lime_tabular_classification_1000",
"maple",
# "sampling_shap_100"
]
deep_regress_methods = [
"deep_shap",
"expected_gradients",
"random",
"kernel_shap_1000_meanref",
"sampling_shap_1000",
# "lime_tabular_regression_1000"
]
deep_classify_methods = [
"deep_shap",
"expected_gradients",
"random",
"kernel_shap_1000_meanref",
"sampling_shap_1000",
# "lime_tabular_regression_1000"
]
_experiments = []
_experiments += [["corrgroups60", "lasso", m, s] for s in regression_metrics for m in linear_regress_methods]
_experiments += [["corrgroups60", "ridge", m, s] for s in regression_metrics for m in linear_regress_methods]
_experiments += [["corrgroups60", "decision_tree", m, s] for s in regression_metrics for m in tree_regress_methods]
_experiments += [
["corrgroups60", "random_forest", m, s]
for s in regression_metrics
for m in (tree_regress_methods + rf_regress_methods)
]
_experiments += [["corrgroups60", "gbm", m, s] for s in regression_metrics for m in tree_regress_methods]
_experiments += [["corrgroups60", "ffnn", m, s] for s in regression_metrics for m in deep_regress_methods]
_experiments += [["independentlinear60", "lasso", m, s] for s in regression_metrics for m in linear_regress_methods]
_experiments += [["independentlinear60", "ridge", m, s] for s in regression_metrics for m in linear_regress_methods]
_experiments += [
["independentlinear60", "decision_tree", m, s] for s in regression_metrics for m in tree_regress_methods
]
_experiments += [
["independentlinear60", "random_forest", m, s]
for s in regression_metrics
for m in (tree_regress_methods + rf_regress_methods)
]
_experiments += [["independentlinear60", "gbm", m, s] for s in regression_metrics for m in tree_regress_methods]
_experiments += [["independentlinear60", "ffnn", m, s] for s in regression_metrics for m in deep_regress_methods]
_experiments += [["cric", "lasso", m, s] for s in binary_classification_metrics for m in linear_classify_methods]
_experiments += [["cric", "ridge", m, s] for s in binary_classification_metrics for m in linear_classify_methods]
_experiments += [["cric", "decision_tree", m, s] for s in binary_classification_metrics for m in tree_classify_methods]
_experiments += [["cric", "random_forest", m, s] for s in binary_classification_metrics for m in tree_classify_methods]
_experiments += [["cric", "gbm", m, s] for s in binary_classification_metrics for m in tree_classify_methods]
_experiments += [["cric", "ffnn", m, s] for s in binary_classification_metrics for m in deep_classify_methods]
_experiments += [["human", "decision_tree", m, s] for s in human_metrics for m in tree_regress_methods]
def experiments(dataset=None, model=None, method=None, metric=None):
for experiment in _experiments:
if dataset is not None and dataset != experiment[0]:
continue
if model is not None and model != experiment[1]:
continue
if method is not None and method != experiment[2]:
continue
if metric is not None and metric != experiment[3]:
continue
yield experiment
def run_experiment(experiment, use_cache=True, cache_dir="/tmp"):
dataset_name, model_name, method_name, metric_name = experiment
# see if we have a cached version
cache_id = __gen_cache_id(experiment)
cache_file = os.path.join(cache_dir, cache_id + ".pickle")
if use_cache and os.path.isfile(cache_file):
with open(cache_file, "rb") as f:
# print(cache_id.replace("__", " ") + " ...loaded from cache.")
return pickle.load(f)
# compute the scores
print(cache_id.replace("__", " ", 4) + " ...")
sys.stdout.flush()
start = time.time()
X, y = getattr(datasets, dataset_name)()
score = getattr(metrics, metric_name)(X, y, getattr(models, dataset_name + "__" + model_name), method_name)
print("...took %f seconds.\n" % (time.time() - start))
# cache the scores
with open(cache_file, "wb") as f:
pickle.dump(score, f)
return score
def run_experiments_helper(args):
experiment, cache_dir = args
return run_experiment(experiment, cache_dir=cache_dir)
def run_experiments(dataset=None, model=None, method=None, metric=None, cache_dir="/tmp", nworkers=1):
experiments_arr = list(experiments(dataset=dataset, model=model, method=method, metric=metric))
if nworkers == 1:
out = list(map(run_experiments_helper, zip(experiments_arr, itertools.repeat(cache_dir))))
else:
with Pool(nworkers) as pool:
out = pool.map(run_experiments_helper, zip(experiments_arr, itertools.repeat(cache_dir)))
return list(zip(experiments_arr, out))
nexperiments = 0
total_sent = 0
total_done = 0
total_failed = 0
host_records = {}
worker_lock = Lock()
ssh_conn_per_min_limit = 0 # set as an argument to run_remote_experiments
def __thread_worker(q, host):
global total_sent, total_done
hostname, python_binary = host.split(":")
while True:
# make sure we are not sending too many ssh connections to the host
# (if we send too many connections ssh throttling will lock us out)
while True:
all_clear = False
worker_lock.acquire()
try:
if hostname not in host_records:
host_records[hostname] = []
if len(host_records[hostname]) < ssh_conn_per_min_limit:
all_clear = True
elif time.time() - host_records[hostname][-ssh_conn_per_min_limit] > 61:
all_clear = True
finally:
worker_lock.release()
# if we are clear to send a new ssh connection then break
if all_clear:
break
# if we are not clear then we sleep and try again
time.sleep(5)
experiment = q.get()
# if we are not loading from the cache then we note that we have called the host
cache_dir = "/tmp"
cache_file = os.path.join(cache_dir, __gen_cache_id(experiment) + ".pickle")
if not os.path.isfile(cache_file):
worker_lock.acquire()
try:
host_records[hostname].append(time.time())
finally:
worker_lock.release()
# record how many we have sent off for execution
worker_lock.acquire()
try:
total_sent += 1
__print_status()
finally:
worker_lock.release()
__run_remote_experiment(experiment, hostname, cache_dir=cache_dir, python_binary=python_binary)
# record how many are finished
worker_lock.acquire()
try:
total_done += 1
__print_status()
finally:
worker_lock.release()
q.task_done()
def __print_status():
print(
f"Benchmark task {total_done} of {nexperiments} done ({total_failed} failed, {total_sent - total_done} running)",
end="\r",
)
sys.stdout.flush()
def run_remote_experiments(experiments, thread_hosts, rate_limit=10):
"""Use ssh to run the experiments on remote machines in parallel.
Parameters
----------
experiments : iterable
Output of shap.benchmark.experiments(...).
thread_hosts : list of strings
Each host has the format "host_name:path_to_python_binary" and can appear multiple times
in the list (one for each parallel execution you want on that machine).
rate_limit : int
How many ssh connections we make per minute to each host (to avoid throttling issues).
"""
global ssh_conn_per_min_limit
ssh_conn_per_min_limit = rate_limit
# first we kill any remaining workers from previous runs
# note we don't check_call because pkill kills our ssh call as well
thread_hosts = copy.copy(thread_hosts)
random.shuffle(thread_hosts)
for host in set(thread_hosts):
hostname, _ = host.split(":")
try:
subprocess.run(["ssh", hostname, "pkill -f shap.benchmark.run_experiment"], timeout=15)
except subprocess.TimeoutExpired:
print("Failed to connect to", hostname, "after 15 seconds! Exiting.")
return
experiments = copy.copy(list(experiments))
random.shuffle(experiments) # this way all the hard experiments don't get put on one machine
global nexperiments, total_sent, total_done, total_failed, host_records
nexperiments = len(experiments)
total_sent = 0
total_done = 0
total_failed = 0
host_records = {}
q = Queue()
for host in thread_hosts:
worker = Thread(target=__thread_worker, args=(q, host))
worker.daemon = True
worker.start()
for experiment in experiments:
q.put(experiment)
q.join()
def __run_remote_experiment(experiment, remote, cache_dir="/tmp", python_binary="python"):
global total_failed
dataset_name, model_name, method_name, metric_name = experiment
# see if we have a cached version
cache_id = __gen_cache_id(experiment)
cache_file = os.path.join(cache_dir, cache_id + ".pickle")
if os.path.isfile(cache_file):
with open(cache_file, "rb") as f:
return pickle.load(f)
# this is just so we don't dump everything at once on a machine
time.sleep(random.uniform(0, 5))
# run the benchmark on the remote machine
# start = time.time()
func = f"shap.benchmark.run_experiment(['{dataset_name}', '{model_name}', '{method_name}', '{metric_name}'], cache_dir='{cache_dir}')"
cmd = 'CUDA_VISIBLE_DEVICES="" ' + python_binary + f' -c "import shap; {func}" &> {cache_dir}/{cache_id}.output'
try:
subprocess.check_output(["ssh", remote, cmd])
except subprocess.CalledProcessError as e:
print(f"The following command failed on {remote}:", file=sys.stderr)
print(cmd, file=sys.stderr)
total_failed += 1
print(e)
return
# copy the results back
subprocess.check_output(["scp", remote + ":" + cache_file, cache_file])
if os.path.isfile(cache_file):
with open(cache_file, "rb") as f:
# print(cache_id.replace("__", " ") + " ...loaded from remote after %f seconds" % (time.time() - start))
return pickle.load(f)
else:
raise FileNotFoundError("Remote benchmark call finished but no local file was found!")
def __gen_cache_id(experiment):
dataset_name, model_name, method_name, metric_name = experiment
return "v" + "__".join([__version__, dataset_name, model_name, method_name, metric_name])