|
import torch, sys, os, argparse, textwrap, numbers, numpy, json, PIL |
|
from torchvision import transforms |
|
from torch.utils.data import TensorDataset |
|
from netdissect.progress import verbose_progress, print_progress |
|
from netdissect import InstrumentedModel, BrodenDataset, dissect |
|
from netdissect import MultiSegmentDataset, GeneratorSegRunner |
|
from netdissect import ImageOnlySegRunner |
|
from netdissect.parallelfolder import ParallelImageFolders |
|
from netdissect.zdataset import z_dataset_for_model |
|
from netdissect.autoeval import autoimport_eval |
|
from netdissect.modelconfig import create_instrumented_model |
|
from netdissect.pidfile import exit_if_job_done, mark_job_done |
|
|
|
help_epilog = '''\ |
|
Example: to dissect three layers of the pretrained alexnet in torchvision: |
|
|
|
python -m netdissect \\ |
|
--model "torchvision.models.alexnet(pretrained=True)" \\ |
|
--layers features.6:conv3 features.8:conv4 features.10:conv5 \\ |
|
--imgsize 227 \\ |
|
--outdir dissect/alexnet-imagenet |
|
|
|
To dissect a progressive GAN model: |
|
|
|
python -m netdissect \\ |
|
--model "proggan.from_pth_file('model/churchoutdoor.pth')" \\ |
|
--gan |
|
''' |
|
|
|
def main(): |
|
|
|
def strpair(arg): |
|
p = tuple(arg.split(':')) |
|
if len(p) == 1: |
|
p = p + p |
|
return p |
|
def intpair(arg): |
|
p = arg.split(',') |
|
if len(p) == 1: |
|
p = p + p |
|
return tuple(int(v) for v in p) |
|
|
|
parser = argparse.ArgumentParser(description='Net dissect utility', |
|
prog='python -m netdissect', |
|
epilog=textwrap.dedent(help_epilog), |
|
formatter_class=argparse.RawDescriptionHelpFormatter) |
|
parser.add_argument('--model', type=str, default=None, |
|
help='constructor for the model to test') |
|
parser.add_argument('--pthfile', type=str, default=None, |
|
help='filename of .pth file for the model') |
|
parser.add_argument('--unstrict', action='store_true', default=False, |
|
help='ignore unexpected pth parameters') |
|
parser.add_argument('--submodule', type=str, default=None, |
|
help='submodule to load from pthfile') |
|
parser.add_argument('--outdir', type=str, default='dissect', |
|
help='directory for dissection output') |
|
parser.add_argument('--layers', type=strpair, nargs='+', |
|
help='space-separated list of layer names to dissect' + |
|
', in the form layername[:reportedname]') |
|
parser.add_argument('--segments', type=str, default='dataset/broden', |
|
help='directory containing segmentation dataset') |
|
parser.add_argument('--segmenter', type=str, default=None, |
|
help='constructor for asegmenter class') |
|
parser.add_argument('--download', action='store_true', default=False, |
|
help='downloads Broden dataset if needed') |
|
parser.add_argument('--imagedir', type=str, default=None, |
|
help='directory containing image-only dataset') |
|
parser.add_argument('--imgsize', type=intpair, default=(227, 227), |
|
help='input image size to use') |
|
parser.add_argument('--netname', type=str, default=None, |
|
help='name for network in generated reports') |
|
parser.add_argument('--meta', type=str, nargs='+', |
|
help='json files of metadata to add to report') |
|
parser.add_argument('--merge', type=str, |
|
help='json file of unit data to merge in report') |
|
parser.add_argument('--examples', type=int, default=20, |
|
help='number of image examples per unit') |
|
parser.add_argument('--size', type=int, default=10000, |
|
help='dataset subset size to use') |
|
parser.add_argument('--batch_size', type=int, default=100, |
|
help='batch size for forward pass') |
|
parser.add_argument('--num_workers', type=int, default=24, |
|
help='number of DataLoader workers') |
|
parser.add_argument('--quantile_threshold', type=strfloat, default=None, |
|
choices=[FloatRange(0.0, 1.0), 'iqr'], |
|
help='quantile to use for masks') |
|
parser.add_argument('--no-labels', action='store_true', default=False, |
|
help='disables labeling of units') |
|
parser.add_argument('--maxiou', action='store_true', default=False, |
|
help='enables maxiou calculation') |
|
parser.add_argument('--covariance', action='store_true', default=False, |
|
help='enables covariance calculation') |
|
parser.add_argument('--rank_all_labels', action='store_true', default=False, |
|
help='include low-information labels in rankings') |
|
parser.add_argument('--no-images', action='store_true', default=False, |
|
help='disables generation of unit images') |
|
parser.add_argument('--no-report', action='store_true', default=False, |
|
help='disables generation report summary') |
|
parser.add_argument('--no-cuda', action='store_true', default=False, |
|
help='disables CUDA usage') |
|
parser.add_argument('--gen', action='store_true', default=False, |
|
help='test a generator model (e.g., a GAN)') |
|
parser.add_argument('--gan', action='store_true', default=False, |
|
help='synonym for --gen') |
|
parser.add_argument('--perturbation', default=None, |
|
help='filename of perturbation attack to apply') |
|
parser.add_argument('--add_scale_offset', action='store_true', default=None, |
|
help='offsets masks according to stride and padding') |
|
parser.add_argument('--quiet', action='store_true', default=False, |
|
help='silences console output') |
|
if len(sys.argv) == 1: |
|
parser.print_usage(sys.stderr) |
|
sys.exit(1) |
|
args = parser.parse_args() |
|
args.images = not args.no_images |
|
args.report = not args.no_report |
|
args.labels = not args.no_labels |
|
if args.gan: |
|
args.gen = args.gan |
|
|
|
|
|
verbose_progress(not args.quiet) |
|
|
|
|
|
if args.outdir is not None: |
|
exit_if_job_done(args.outdir) |
|
|
|
|
|
torch.backends.cudnn.benchmark = True |
|
|
|
|
|
if args.model is None and args.download: |
|
from netdissect.broden import ensure_broden_downloaded |
|
for resolution in [224, 227, 384]: |
|
ensure_broden_downloaded(args.segments, resolution, 1) |
|
from netdissect.segmenter import ensure_upp_segmenter_downloaded |
|
ensure_upp_segmenter_downloaded('dataset/segmodel') |
|
sys.exit(0) |
|
|
|
|
|
if not args.gen and not args.imagedir and not os.path.isdir(args.segments): |
|
print_progress('Segmentation dataset not found at %s.' % args.segments) |
|
print_progress('Specify dataset directory using --segments [DIR]') |
|
print_progress('To download Broden, run: netdissect --download') |
|
sys.exit(1) |
|
|
|
|
|
if args.gen and args.segmenter is None: |
|
args.segmenter = ("netdissect.segmenter.UnifiedParsingSegmenter(" + |
|
"segsizes=[256], segdiv='quad')") |
|
|
|
|
|
if args.quantile_threshold is None: |
|
if args.gen: |
|
args.quantile_threshold = 'iqr' |
|
else: |
|
args.quantile_threshold = 0.005 |
|
|
|
|
|
args.cuda = not args.no_cuda and torch.cuda.is_available() |
|
if args.cuda: |
|
torch.backends.cudnn.benchmark = True |
|
|
|
|
|
if args.model is None: |
|
print_progress('No model specified') |
|
sys.exit(1) |
|
model = create_instrumented_model(args) |
|
|
|
|
|
meta = getattr(model, 'meta', {}) |
|
if args.meta: |
|
for mfilename in args.meta: |
|
with open(mfilename) as f: |
|
meta.update(json.load(f)) |
|
|
|
|
|
mergedata = None |
|
if args.merge: |
|
with open(args.merge) as f: |
|
mergedata = json.load(f) |
|
|
|
|
|
if args.outdir is None: |
|
args.outdir = os.path.join('dissect', type(model).__name__) |
|
exit_if_job_done(args.outdir) |
|
print_progress('Writing output into %s.' % args.outdir) |
|
os.makedirs(args.outdir, exist_ok=True) |
|
train_dataset = None |
|
|
|
if not args.gen: |
|
|
|
|
|
perturbation = numpy.load(args.perturbation |
|
) if args.perturbation else None |
|
segrunner = None |
|
|
|
|
|
if args.imagedir is not None: |
|
dataset = try_to_load_images(args.imagedir, args.imgsize, |
|
perturbation, args.size) |
|
segrunner = ImageOnlySegRunner(dataset) |
|
else: |
|
dataset = try_to_load_broden(args.segments, args.imgsize, 1, |
|
perturbation, args.download, args.size) |
|
if dataset is None: |
|
dataset = try_to_load_multiseg(args.segments, args.imgsize, |
|
perturbation, args.size) |
|
if dataset is None: |
|
print_progress('No segmentation dataset found in %s', |
|
args.segments) |
|
print_progress('use --download to download Broden.') |
|
sys.exit(1) |
|
else: |
|
|
|
dataset = z_dataset_for_model(model, args.size) |
|
train_dataset = z_dataset_for_model(model, args.size, seed=2) |
|
segrunner = GeneratorSegRunner(autoimport_eval(args.segmenter)) |
|
|
|
|
|
dissect(args.outdir, model, dataset, |
|
train_dataset=train_dataset, |
|
segrunner=segrunner, |
|
examples_per_unit=args.examples, |
|
netname=args.netname, |
|
quantile_threshold=args.quantile_threshold, |
|
meta=meta, |
|
merge=mergedata, |
|
make_images=args.images, |
|
make_labels=args.labels, |
|
make_maxiou=args.maxiou, |
|
make_covariance=args.covariance, |
|
make_report=args.report, |
|
make_row_images=args.images, |
|
make_single_images=True, |
|
rank_all_labels=args.rank_all_labels, |
|
batch_size=args.batch_size, |
|
num_workers=args.num_workers, |
|
settings=vars(args)) |
|
|
|
|
|
mark_job_done(args.outdir) |
|
|
|
class AddPerturbation(object): |
|
def __init__(self, perturbation): |
|
self.perturbation = perturbation |
|
|
|
def __call__(self, pic): |
|
if self.perturbation is None: |
|
return pic |
|
|
|
npyimg = numpy.array(pic, numpy.uint8, copy=False |
|
).astype(numpy.float32) |
|
|
|
oy, ox = ((self.perturbation.shape[d] - npyimg.shape[d]) // 2 |
|
for d in [0, 1]) |
|
npyimg += self.perturbation[ |
|
oy:oy+npyimg.shape[0], ox:ox+npyimg.shape[1]] |
|
|
|
npyimg.clip(0, 255, npyimg) |
|
return npyimg / 255.0 |
|
|
|
def test_dissection(): |
|
verbose_progress(True) |
|
from torchvision.models import alexnet |
|
from torchvision import transforms |
|
model = InstrumentedModel(alexnet(pretrained=True)) |
|
model.eval() |
|
|
|
model.retain_layers([ |
|
('features.0', 'conv1'), |
|
('features.3', 'conv2'), |
|
('features.6', 'conv3'), |
|
('features.8', 'conv4'), |
|
('features.10', 'conv5') ]) |
|
|
|
bds = BrodenDataset('dataset/broden', |
|
transform=transforms.Compose([ |
|
transforms.ToTensor(), |
|
transforms.Normalize(IMAGE_MEAN, IMAGE_STDEV)]), |
|
size=100) |
|
|
|
dissect('dissect/test', model, bds, |
|
examples_per_unit=10) |
|
|
|
def try_to_load_images(directory, imgsize, perturbation, size): |
|
|
|
|
|
return ParallelImageFolders( |
|
[directory], |
|
transform=transforms.Compose([ |
|
transforms.Resize(imgsize), |
|
AddPerturbation(perturbation), |
|
transforms.ToTensor(), |
|
transforms.Normalize(IMAGE_MEAN, IMAGE_STDEV)]), |
|
size=size) |
|
|
|
def try_to_load_broden(directory, imgsize, broden_version, perturbation, |
|
download, size): |
|
|
|
ds_resolution = (224 if max(imgsize) <= 224 else |
|
227 if max(imgsize) <= 227 else 384) |
|
if not os.path.isfile(os.path.join(directory, |
|
'broden%d_%d' % (broden_version, ds_resolution), 'index.csv')): |
|
return None |
|
return BrodenDataset(directory, |
|
resolution=ds_resolution, |
|
download=download, |
|
broden_version=broden_version, |
|
transform=transforms.Compose([ |
|
transforms.Resize(imgsize), |
|
AddPerturbation(perturbation), |
|
transforms.ToTensor(), |
|
transforms.Normalize(IMAGE_MEAN, IMAGE_STDEV)]), |
|
size=size) |
|
|
|
def try_to_load_multiseg(directory, imgsize, perturbation, size): |
|
if not os.path.isfile(os.path.join(directory, 'labelnames.json')): |
|
return None |
|
minsize = min(imgsize) if hasattr(imgsize, '__iter__') else imgsize |
|
return MultiSegmentDataset(directory, |
|
transform=(transforms.Compose([ |
|
transforms.Resize(minsize), |
|
transforms.CenterCrop(imgsize), |
|
AddPerturbation(perturbation), |
|
transforms.ToTensor(), |
|
transforms.Normalize(IMAGE_MEAN, IMAGE_STDEV)]), |
|
transforms.Compose([ |
|
transforms.Resize(minsize, interpolation=PIL.Image.NEAREST), |
|
transforms.CenterCrop(imgsize)])), |
|
size=size) |
|
|
|
def add_scale_offset_info(model, layer_names): |
|
''' |
|
Creates a 'scale_offset' property on the model which guesses |
|
how to offset the featuremap, in cases where the convolutional |
|
padding does not exacly correspond to keeping featuremap pixels |
|
centered on the downsampled regions of the input. This mainly |
|
shows up in AlexNet: ResNet and VGG pad convolutions to keep |
|
them centered and do not need this. |
|
''' |
|
model.scale_offset = {} |
|
seen = set() |
|
sequence = [] |
|
aka_map = {} |
|
for name in layer_names: |
|
aka = name |
|
if not isinstance(aka, str): |
|
name, aka = name |
|
aka_map[name] = aka |
|
for name, layer in model.named_modules(): |
|
sequence.append(layer) |
|
if name in aka_map: |
|
seen.add(name) |
|
aka = aka_map[name] |
|
model.scale_offset[aka] = sequence_scale_offset(sequence) |
|
for name in aka_map: |
|
assert name in seen, ('Layer %s not found' % name) |
|
|
|
def dilation_scale_offset(dilations): |
|
'''Composes a list of (k, s, p) into a single total scale and offset.''' |
|
if len(dilations) == 0: |
|
return (1, 0) |
|
scale, offset = dilation_scale_offset(dilations[1:]) |
|
kernel, stride, padding = dilations[0] |
|
scale *= stride |
|
offset *= stride |
|
offset += (kernel - 1) / 2.0 - padding |
|
return scale, offset |
|
|
|
def dilations(modulelist): |
|
'''Converts a list of modules to (kernel_size, stride, padding)''' |
|
result = [] |
|
for module in modulelist: |
|
settings = tuple(getattr(module, n, d) |
|
for n, d in (('kernel_size', 1), ('stride', 1), ('padding', 0))) |
|
settings = (((s, s) if not isinstance(s, tuple) else s) |
|
for s in settings) |
|
if settings != ((1, 1), (1, 1), (0, 0)): |
|
result.append(zip(*settings)) |
|
return zip(*result) |
|
|
|
def sequence_scale_offset(modulelist): |
|
'''Returns (yscale, yoffset), (xscale, xoffset) given a list of modules''' |
|
return tuple(dilation_scale_offset(d) for d in dilations(modulelist)) |
|
|
|
|
|
def strfloat(s): |
|
try: |
|
return float(s) |
|
except: |
|
return s |
|
|
|
class FloatRange(object): |
|
def __init__(self, start, end): |
|
self.start = start |
|
self.end = end |
|
def __eq__(self, other): |
|
return isinstance(other, float) and self.start <= other <= self.end |
|
def __repr__(self): |
|
return '[%g-%g]' % (self.start, self.end) |
|
|
|
|
|
IMAGE_MEAN = [0.485, 0.456, 0.406] |
|
IMAGE_STDEV = [0.229, 0.224, 0.225] |
|
|
|
if __name__ == '__main__': |
|
main() |
|
|